diff --git a/README.md b/README.md index 80e1a673..b6b9152a 100644 --- a/README.md +++ b/README.md @@ -115,10 +115,11 @@ Claude Code writes one JSONL file per session to `~/.claude/projects/`. Each lin Costs are calculated using **Anthropic API pricing as of April 2026** ([claude.com/pricing#api](https://claude.com/pricing#api)). -**Only models whose name contains `opus`, `sonnet`, or `haiku` are included in cost calculations.** Local models, unknown models, and any other model names are excluded (shown as `n/a`). +**Only models whose name contains `fable`, `opus`, `sonnet`, or `haiku` are included in cost calculations.** Local models, unknown models, and any other model names are excluded (shown as `n/a`). | Model | Input | Output | Cache Write | Cache Read | |-------|-------|--------|------------|-----------| +| claude-fable-5 | $10.00/MTok | $50.00/MTok | $12.50/MTok | $1.00/MTok | | claude-opus-4-7 | $5.00/MTok | $25.00/MTok | $6.25/MTok | $0.50/MTok | | claude-opus-4-6 | $5.00/MTok | $25.00/MTok | $6.25/MTok | $0.50/MTok | | claude-sonnet-4-6 | $3.00/MTok | $15.00/MTok | $3.75/MTok | $0.30/MTok | diff --git a/cli.py b/cli.py index 222b4a1b..317142c6 100644 --- a/cli.py +++ b/cli.py @@ -17,6 +17,7 @@ DB_PATH = Path.home() / ".claude" / "usage.db" PRICING = { + "claude-fable-5": {"input": 10.00, "output": 50.00, "cache_read": 1.00, "cache_write": 12.50}, "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}, @@ -38,6 +39,8 @@ def get_pricing(model): return PRICING[key] # Substring fallback: match model family by keyword m = model.lower() + if "fable" in m: + return PRICING["claude-fable-5"] if "opus" in m: return PRICING["claude-opus-4-7"] if "sonnet" in m: diff --git a/dashboard.py b/dashboard.py index 0aba9e9d..5d3f60bb 100644 --- a/dashboard.py +++ b/dashboard.py @@ -485,6 +485,7 @@ def get_dashboard_data(db_path=DB_PATH): // ── Pricing (Anthropic API, April 2026) ──────────────────────────────────── const PRICING = { + 'claude-fable-5': { input: 10.00, output: 50.00, cache_write: 12.50, cache_read: 1.00 }, '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 }, @@ -499,7 +500,7 @@ def get_dashboard_data(db_path=DB_PATH): function isBillable(model) { if (!model) return false; const m = model.toLowerCase(); - return m.includes('opus') || m.includes('sonnet') || m.includes('haiku'); + return m.includes('fable') || m.includes('opus') || m.includes('sonnet') || m.includes('haiku'); } function getPricing(model) { @@ -509,6 +510,7 @@ def get_dashboard_data(db_path=DB_PATH): if (model.startsWith(key)) return PRICING[key]; } const m = model.toLowerCase(); + if (m.includes('fable')) return PRICING['claude-fable-5']; if (m.includes('opus')) return PRICING['claude-opus-4-7']; if (m.includes('sonnet')) return PRICING['claude-sonnet-4-6']; if (m.includes('haiku')) return PRICING['claude-haiku-4-5']; @@ -675,10 +677,11 @@ def get_dashboard_data(db_path=DB_PATH): // ── Model filter ─────────────────────────────────────────────────────────── function modelPriority(m) { const ml = m.toLowerCase(); - if (ml.includes('opus')) return 0; - if (ml.includes('sonnet')) return 1; - if (ml.includes('haiku')) return 2; - return 3; + if (ml.includes('fable')) return 0; + if (ml.includes('opus')) return 1; + if (ml.includes('sonnet')) return 2; + if (ml.includes('haiku')) return 3; + return 4; } function readURLModels(allModels) { diff --git a/scanner.py b/scanner.py index 5175591b..93e69a54 100644 --- a/scanner.py +++ b/scanner.py @@ -15,7 +15,7 @@ DEFAULT_PROJECTS_DIRS = [PROJECTS_DIR, XCODE_PROJECTS_DIR] # Higher number = higher priority when choosing a session's primary model -MODEL_PRIORITY = {"opus": 3, "sonnet": 2, "haiku": 1} +MODEL_PRIORITY = {"fable": 4, "opus": 3, "sonnet": 2, "haiku": 1} def _model_priority(model): diff --git a/tests/test_cli.py b/tests/test_cli.py index 229c51a5..278aeb71 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -15,13 +15,27 @@ def test_exact_model_match(self): self.assertEqual(p["output"], 25.00) def test_all_known_models_have_pricing(self): - for model in ("claude-opus-4-7", "claude-opus-4-6", "claude-opus-4-5", + for model in ("claude-fable-5", + "claude-opus-4-7", "claude-opus-4-6", "claude-opus-4-5", "claude-sonnet-4-7", "claude-sonnet-4-6", "claude-sonnet-4-5", "claude-haiku-4-7", "claude-haiku-4-6", "claude-haiku-4-5"): p = get_pricing(model) self.assertGreater(p["input"], 0, f"Missing input price for {model}") self.assertGreater(p["output"], 0, f"Missing output price for {model}") + def test_fable_5_has_explicit_entry(self): + """Fable 5 must be present as an explicit entry, not resolved via a + generic substring fallback.""" + self.assertIn("claude-fable-5", PRICING) + p = get_pricing("claude-fable-5") + self.assertEqual(p["input"], 10.00) + self.assertEqual(p["output"], 50.00) + + def test_fable_substring_fallback(self): + """An unrecognized fable-family id resolves to Fable 5 pricing.""" + p = get_pricing("claude-fable-5-20260601") + self.assertEqual(p["input"], 10.00) + def test_opus_4_7_has_explicit_entry(self): """Regression guard for issue #61 — Opus 4.7 must be present.""" p = get_pricing("claude-opus-4-7") diff --git a/tests/test_scanner.py b/tests/test_scanner.py index e6eb4524..a38aa34c 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -10,9 +10,24 @@ from scanner import ( get_db, init_db, project_name_from_cwd, parse_jsonl_file, aggregate_sessions, upsert_sessions, insert_turns, scan, + _model_priority, ) +class TestModelPriority(unittest.TestCase): + def test_fable_outranks_opus(self): + self.assertGreater( + _model_priority("claude-fable-5"), + _model_priority("claude-opus-4-7"), + ) + + def test_descending_order(self): + order = ["claude-fable-5", "claude-opus-4-7", + "claude-sonnet-4-6", "claude-haiku-4-5", "gpt-4o"] + scores = [_model_priority(m) for m in order] + self.assertEqual(scores, sorted(scores, reverse=True)) + + class TestProjectNameFromCwd(unittest.TestCase): def test_two_components(self): self.assertEqual(project_name_from_cwd("/home/user/myproject"), "user/myproject")