Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
3 changes: 3 additions & 0 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand All @@ -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:
Expand Down
13 changes: 8 additions & 5 deletions dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -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) {
Expand All @@ -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'];
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
16 changes: 15 additions & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
15 changes: 15 additions & 0 deletions tests/test_scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading