diff --git a/cli.py b/cli.py index 01cf362..fb64a2b 100644 --- a/cli.py +++ b/cli.py @@ -16,46 +16,8 @@ DB_PATH = Path.home() / ".claude" / "usage.db" -PRICING = { - "claude-opus-4-7": {"input": 5.00, "output": 25.00, "cache_read": 0.50, "cache_write": 6.25}, - "claude-opus-4-6": {"input": 5.00, "output": 25.00, "cache_read": 0.50, "cache_write": 6.25}, - "claude-opus-4-5": {"input": 5.00, "output": 25.00, "cache_read": 0.50, "cache_write": 6.25}, - "claude-sonnet-4-7": {"input": 3.00, "output": 15.00, "cache_read": 0.30, "cache_write": 3.75}, - "claude-sonnet-4-6": {"input": 3.00, "output": 15.00, "cache_read": 0.30, "cache_write": 3.75}, - "claude-sonnet-4-5": {"input": 3.00, "output": 15.00, "cache_read": 0.30, "cache_write": 3.75}, - "claude-haiku-4-7": {"input": 1.00, "output": 5.00, "cache_read": 0.10, "cache_write": 1.25}, - "claude-haiku-4-6": {"input": 1.00, "output": 5.00, "cache_read": 0.10, "cache_write": 1.25}, - "claude-haiku-4-5": {"input": 1.00, "output": 5.00, "cache_read": 0.10, "cache_write": 1.25}, -} - -def get_pricing(model): - if not model: - return None - if model in PRICING: - return PRICING[model] - for key in PRICING: - if model.startswith(key): - return PRICING[key] - # Substring fallback: match model family by keyword - m = model.lower() - if "opus" in m: - return PRICING["claude-opus-4-7"] - if "sonnet" in m: - return PRICING["claude-sonnet-4-6"] - if "haiku" in m: - return PRICING["claude-haiku-4-5"] - return None +from pricing import PRICING, get_pricing, calc_cost -def calc_cost(model, inp, out, cache_read, cache_creation): - p = get_pricing(model) - if not p: - return 0.0 - return ( - inp * p["input"] / 1_000_000 + - out * p["output"] / 1_000_000 + - cache_read * p["cache_read"] / 1_000_000 + - cache_creation * p["cache_write"] / 1_000_000 - ) def fmt(n): if n >= 1_000_000: diff --git a/dashboard.py b/dashboard.py index ebf8d5f..7f5855d 100644 --- a/dashboard.py +++ b/dashboard.py @@ -7,6 +7,8 @@ import sqlite3 from http.server import HTTPServer, BaseHTTPRequestHandler from pathlib import Path + +from pricing import PRICING from datetime import datetime DB_PATH = Path.home() / ".claude" / "usage.db" @@ -418,17 +420,7 @@ def get_dashboard_data(db_path=DB_PATH): } // ── Pricing (Anthropic API, April 2026) ──────────────────────────────────── -const PRICING = { - 'claude-opus-4-7': { input: 5.00, output: 25.00, cache_write: 6.25, cache_read: 0.50 }, - 'claude-opus-4-6': { input: 5.00, output: 25.00, cache_write: 6.25, cache_read: 0.50 }, - 'claude-opus-4-5': { input: 5.00, output: 25.00, cache_write: 6.25, cache_read: 0.50 }, - 'claude-sonnet-4-7': { input: 3.00, output: 15.00, cache_write: 3.75, cache_read: 0.30 }, - 'claude-sonnet-4-6': { input: 3.00, output: 15.00, cache_write: 3.75, cache_read: 0.30 }, - 'claude-sonnet-4-5': { input: 3.00, output: 15.00, cache_write: 3.75, cache_read: 0.30 }, - 'claude-haiku-4-7': { input: 1.00, output: 5.00, cache_write: 1.25, cache_read: 0.10 }, - 'claude-haiku-4-6': { input: 1.00, output: 5.00, cache_write: 1.25, cache_read: 0.10 }, - 'claude-haiku-4-5': { input: 1.00, output: 5.00, cache_write: 1.25, cache_read: 0.10 }, -}; +const PRICING = /*__PRICING_JSON__*/; function isBillable(model) { if (!model) return false; @@ -1237,6 +1229,15 @@ def get_dashboard_data(db_path=DB_PATH): """ +def render_html(): + """Inject the Python PRICING table into the HTML so the JS table + can never drift from the Python one.""" + return HTML_TEMPLATE.replace( + "/*__PRICING_JSON__*/", + json.dumps(PRICING), + ).encode("utf-8") + + class DashboardHandler(BaseHTTPRequestHandler): def log_message(self, format, *args): pass @@ -1246,7 +1247,7 @@ def do_GET(self): self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.end_headers() - self.wfile.write(HTML_TEMPLATE.encode("utf-8")) + self.wfile.write(render_html()) elif self.path == "/api/data": data = get_dashboard_data() diff --git a/pricing.py b/pricing.py new file mode 100644 index 0000000..d4393e0 --- /dev/null +++ b/pricing.py @@ -0,0 +1,56 @@ +""" +Anthropic API pricing — single source of truth for both the Python CLI cost +calculator and the JavaScript dashboard. + +USD per million tokens. Updated April 2026. +Source: https://docs.claude.com/en/docs/about-claude/pricing +""" + +PRICING = { + "claude-opus-4-7": {"input": 5.00, "output": 25.00, "cache_read": 0.50, "cache_write": 6.25}, + "claude-opus-4-6": {"input": 5.00, "output": 25.00, "cache_read": 0.50, "cache_write": 6.25}, + "claude-opus-4-5": {"input": 5.00, "output": 25.00, "cache_read": 0.50, "cache_write": 6.25}, + "claude-sonnet-4-7": {"input": 3.00, "output": 15.00, "cache_read": 0.30, "cache_write": 3.75}, + "claude-sonnet-4-6": {"input": 3.00, "output": 15.00, "cache_read": 0.30, "cache_write": 3.75}, + "claude-sonnet-4-5": {"input": 3.00, "output": 15.00, "cache_read": 0.30, "cache_write": 3.75}, + "claude-haiku-4-7": {"input": 1.00, "output": 5.00, "cache_read": 0.10, "cache_write": 1.25}, + "claude-haiku-4-6": {"input": 1.00, "output": 5.00, "cache_read": 0.10, "cache_write": 1.25}, + "claude-haiku-4-5": {"input": 1.00, "output": 5.00, "cache_read": 0.10, "cache_write": 1.25}, +} + + +def get_pricing(model): + """Look up per-MTok pricing for a model name. + + Tries exact match, then prefix match, then keyword fallback (any name + containing 'opus'/'sonnet'/'haiku' falls back to the latest of that family). + Returns None for unknown / non-Anthropic models so callers can show 'n/a'. + """ + if not model: + return None + if model in PRICING: + return PRICING[model] + for key in PRICING: + if model.startswith(key): + return PRICING[key] + m = model.lower() + if "opus" in m: + return PRICING["claude-opus-4-7"] + if "sonnet" in m: + return PRICING["claude-sonnet-4-6"] + if "haiku" in m: + return PRICING["claude-haiku-4-5"] + return None + + +def calc_cost(model, inp, out, cache_read, cache_creation): + """Cost in USD for one batch of token usage. Returns 0 for unknown models.""" + p = get_pricing(model) + if p is None: + return 0.0 + return ( + (inp or 0) * p["input"] / 1_000_000 + + (out or 0) * p["output"] / 1_000_000 + + (cache_read or 0) * p["cache_read"] / 1_000_000 + + (cache_creation or 0) * p["cache_write"] / 1_000_000 + ) diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 76287d1..a82709b 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -233,16 +233,16 @@ class TestPricingParity(unittest.TestCase): """Verify CLI and dashboard pricing tables stay in sync.""" def _extract_js_pricing(self): - """Extract pricing values from the dashboard JS PRICING object.""" - import re - prices = {} - for match in re.finditer( - r"'(claude-[^']+)':\s*\{\s*input:\s*([\d.]+),\s*output:\s*([\d.]+)", - HTML_TEMPLATE - ): - model, inp, out = match.group(1), float(match.group(2)), float(match.group(3)) - prices[model] = {"input": inp, "output": out} - return prices + """Decode the JSON-injected PRICING table from the rendered HTML. + The HTML_TEMPLATE now carries a placeholder; the real table is + injected at request time by render_html().""" + import re, json + from dashboard import render_html + html = render_html().decode("utf-8") + m = re.search(r"const PRICING = (\{.*?\});", html, re.DOTALL) + if not m: + return {} + return json.loads(m.group(1)) def test_all_cli_models_in_dashboard(self): from cli import PRICING as CLI_PRICING