From 930d07d8d346117fb94213261277c465cbde1e39 Mon Sep 17 00:00:00 2001 From: Scott Pfister Date: Thu, 16 Jul 2026 19:33:47 -0500 Subject: [PATCH 1/3] fix: use git-subdir source for claude-usage-report plugin A plugin entry that combined a bare relative-string source ("./skills/claude-usage-report") with a skills array ([".") tripped Claude Code's plugin resolver into its default: throw, surfaced as a misleading "update Claude Code" error. Switch to the git-subdir object form used by other skill-bearing marketplaces. After merge, re-pull with `/plugin marketplace update 7factor` (the local marketplaces copy is a cache). Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude-plugin/marketplace.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index e1792f6..0ce7918 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,7 +6,12 @@ "plugins": [ { "name": "claude-usage-report", - "source": "./skills/claude-usage-report", + "source": { + "source": "git-subdir", + "url": "https://github.com/7Factor/skills.git", + "path": "skills/claude-usage-report", + "ref": "main" + }, "skills": [ "." ], From 3f23b0b64fff9e0a1d6f52303925268204d4b662 Mon Sep 17 00:00:00 2001 From: Scott Pfister Date: Thu, 16 Jul 2026 20:59:28 -0500 Subject: [PATCH 2/3] fix(marketplace): use relative-path source and conventional skills layout The git-subdir source from the prior attempt made the plugin resolver throw its generic "source type your Claude Code version does not support" error, even on the latest Claude Code (2.1.212). That message is a misleading catch-all the resolver emits when it can't process an entry. Two coupled problems were feeding the throw: 1. the `git-subdir` object source, and 2. `skills: ["."]`, which trips the "Path escapes plugin directory" loader bug (same issue nicobailon/visual-explainer hit). `skills: ["."]` existed because SKILL.md sat at the plugin root, so the default `skills/` scan found nothing. Fix both by matching the conventional layout: - move the skill (SKILL.md + usage_report.py + update_pricing.py + prices.json) into skills/claude-usage-report/skills/claude-usage-report/ so the default scan finds it; drop the `skills` field entirely. - set the plugin source to the plain relative path "./skills/claude-usage-report" (the original, universally-supported form; resolves against the cloned marketplace repo). record_account.sh/.py, hooks/hooks.json, and .claude-plugin/plugin.json stay at the plugin root, so ${CLAUDE_PLUGIN_ROOT}/record_account.sh and the existing settings.json hook path keep working. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude-plugin/marketplace.json | 10 +--------- .../{ => skills/claude-usage-report}/SKILL.md | 0 .../{ => skills/claude-usage-report}/prices.json | 0 .../{ => skills/claude-usage-report}/update_pricing.py | 0 .../{ => skills/claude-usage-report}/usage_report.py | 0 5 files changed, 1 insertion(+), 9 deletions(-) rename skills/claude-usage-report/{ => skills/claude-usage-report}/SKILL.md (100%) rename skills/claude-usage-report/{ => skills/claude-usage-report}/prices.json (100%) rename skills/claude-usage-report/{ => skills/claude-usage-report}/update_pricing.py (100%) rename skills/claude-usage-report/{ => skills/claude-usage-report}/usage_report.py (100%) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 0ce7918..f4b3ae7 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,15 +6,7 @@ "plugins": [ { "name": "claude-usage-report", - "source": { - "source": "git-subdir", - "url": "https://github.com/7Factor/skills.git", - "path": "skills/claude-usage-report", - "ref": "main" - }, - "skills": [ - "." - ], + "source": "./skills/claude-usage-report", "description": "Usage/cost reports with self-refreshing pricing and per-account attribution." } ] diff --git a/skills/claude-usage-report/SKILL.md b/skills/claude-usage-report/skills/claude-usage-report/SKILL.md similarity index 100% rename from skills/claude-usage-report/SKILL.md rename to skills/claude-usage-report/skills/claude-usage-report/SKILL.md diff --git a/skills/claude-usage-report/prices.json b/skills/claude-usage-report/skills/claude-usage-report/prices.json similarity index 100% rename from skills/claude-usage-report/prices.json rename to skills/claude-usage-report/skills/claude-usage-report/prices.json diff --git a/skills/claude-usage-report/update_pricing.py b/skills/claude-usage-report/skills/claude-usage-report/update_pricing.py similarity index 100% rename from skills/claude-usage-report/update_pricing.py rename to skills/claude-usage-report/skills/claude-usage-report/update_pricing.py diff --git a/skills/claude-usage-report/usage_report.py b/skills/claude-usage-report/skills/claude-usage-report/usage_report.py similarity index 100% rename from skills/claude-usage-report/usage_report.py rename to skills/claude-usage-report/skills/claude-usage-report/usage_report.py From 955f4f68a9caccc7bb549985f88363aa180202b5 Mon Sep 17 00:00:00 2001 From: Scott Pfister Date: Thu, 16 Jul 2026 20:24:41 -0500 Subject: [PATCH 3/3] fix(claude-usage-report): price cache-writes at their actual TTL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parser costed every cache-write token at the 5-minute TTL rate (1.25x input), ignoring the 5m/1h split that each transcript records in its `cache_creation` object. In practice most Claude Code cache-writes use the 1-hour TTL (2x input), so reports under-counted materially — a 16-day sample came out ~$509 when the TTL-correct figure was ~$599. - usage_report.py: read `ephemeral_5m_input_tokens` / `ephemeral_1h_input_tokens` per message and cost each at `cw` / `cw_1h`; fall back to the combined field (as 5m) for legacy transcripts, and to 2x input when a price record omits `cw_1h`. Fix a token double-count in the per-session total introduced by the new fields. RATES output now shows both cache-write columns. - prices.json: add explicit `cw_1h` (= 2x input) to the models that lacked it; document the field in the header comment. - SKILL.md: add the 1h column to the reference table and replace the stale "assumes 5-minute TTL" caveat. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../skills/claude-usage-report/SKILL.md | 8 +++-- .../skills/claude-usage-report/prices.json | 12 ++++++-- .../claude-usage-report/usage_report.py | 29 +++++++++++++++---- 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/skills/claude-usage-report/skills/claude-usage-report/SKILL.md b/skills/claude-usage-report/skills/claude-usage-report/SKILL.md index 1237e74..29fb3d1 100644 --- a/skills/claude-usage-report/skills/claude-usage-report/SKILL.md +++ b/skills/claude-usage-report/skills/claude-usage-report/SKILL.md @@ -122,15 +122,17 @@ account-recording hook existed; `mixed: a | b` = a session that switched account ## Reference Model prices ($/MTok), records in effect during the period: -| Model | Effective | Input | Output | Cache-write (5m) | Cache-read | Note | -|---|---|---:|---:|---:|---:|---| +| Model | Effective | Input | Output | Cache-write (5m) | Cache-write (1h) | Cache-read | Note | +|---|---|---:|---:|---:|---:|---:|---| {the RATES block from parser output — one row per applicable record; a model with a mid-period price change contributes more than one row} Caveats: - Estimate from local transcripts × public list prices, not a bill — for the authoritative number use the Anthropic Console usage dashboard for the period. -- Cache-write assumes the 5-minute TTL (1.25× input); a 1-hour TTL would be 2× input. +- Cache-write is priced at each message's actual TTL (5-minute = 1.25× input, 1-hour = 2× + input), read from the transcript's `cache_creation` breakdown. Legacy transcripts that + lack the breakdown fall back to the 5-minute rate. - Opus's 1M-context (`[1m]`) runs bill at standard rates; there is no >200K premium tier. - Account attribution comes from this plugin's `SessionStart` hook (`hooks/hooks.json` → `record_account.sh` → `record_account.py` → `~/.claude/session-accounts.jsonl`) and is diff --git a/skills/claude-usage-report/skills/claude-usage-report/prices.json b/skills/claude-usage-report/skills/claude-usage-report/prices.json index 4cfad19..3f42461 100644 --- a/skills/claude-usage-report/skills/claude-usage-report/prices.json +++ b/skills/claude-usage-report/skills/claude-usage-report/prices.json @@ -1,5 +1,5 @@ { - "_comment": "Anthropic list prices, $/MTok. Fields: in=input, out=output, cw=5-minute cache write (1.25x input), cr=cache read (0.1x input). Each model has one or more records sorted by 'effective' date; the rate applied to a message is the record with the latest effective date <= that message's (UTC) day. This makes a report use the price in effect on the day being reported. Validate against current published pricing when generating a report and append a new dated record when a price changes (see SKILL.md). Known gap: if a price changes between two report runs, days billed before this file was updated will be costed at the older record.", + "_comment": "Anthropic list prices, $/MTok. Fields: in=input, out=output, cw=5-minute cache write (1.25x input), cw_1h=1-hour cache write (2x input), cr=cache read (0.1x input). Each message's cache-write tokens are split into 5m/1h from the transcript's cache_creation breakdown and costed at cw / cw_1h respectively; if a record omits cw_1h the parser falls back to 2x in. Each model has one or more records sorted by 'effective' date; the rate applied to a message is the record with the latest effective date <= that message's (UTC) day. This makes a report use the price in effect on the day being reported. Validate against current published pricing when generating a report and append a new dated record when a price changes (see SKILL.md). Known gap: if a price changes between two report runs, days billed before this file was updated will be costed at the older record.", "models": { "claude-fable-5": [ { @@ -7,6 +7,7 @@ "in": 10.0, "out": 50.0, "cw": 12.5, + "cw_1h": 20.0, "cr": 1.0 } ], @@ -16,6 +17,7 @@ "in": 5.0, "out": 25.0, "cw": 6.25, + "cw_1h": 10.0, "cr": 0.5 } ], @@ -25,6 +27,7 @@ "in": 5.0, "out": 25.0, "cw": 6.25, + "cw_1h": 10.0, "cr": 0.5 } ], @@ -34,6 +37,7 @@ "in": 5.0, "out": 25.0, "cw": 6.25, + "cw_1h": 10.0, "cr": 0.5 } ], @@ -43,6 +47,7 @@ "in": 2.0, "out": 10.0, "cw": 2.5, + "cw_1h": 4.0, "cr": 0.2, "note": "introductory through 2026-08-31" }, @@ -51,6 +56,7 @@ "in": 3.0, "out": 15.0, "cw": 3.75, + "cw_1h": 6.0, "cr": 0.3, "note": "standard" } @@ -61,6 +67,7 @@ "in": 3.0, "out": 15.0, "cw": 3.75, + "cw_1h": 6.0, "cr": 0.3 } ], @@ -70,6 +77,7 @@ "in": 1.0, "out": 5.0, "cw": 1.25, + "cw_1h": 2.0, "cr": 0.1 } ], @@ -145,7 +153,7 @@ ] }, "_meta": { - "last_checked": "2026-07-07T16:38:34", + "last_checked": "2026-07-16T19:46:42", "source": "https://platform.claude.com/docs/en/about-claude/pricing.md" } } diff --git a/skills/claude-usage-report/skills/claude-usage-report/usage_report.py b/skills/claude-usage-report/skills/claude-usage-report/usage_report.py index f3cee53..2459519 100644 --- a/skills/claude-usage-report/skills/claude-usage-report/usage_report.py +++ b/skills/claude-usage-report/skills/claude-usage-report/usage_report.py @@ -62,8 +62,15 @@ def rate_for(model, day): else: break return best # None if day precedes the earliest record +def cw1_rate(rec): + # 1-hour cache-write rate; fall back to 2x input if a record predates the cw_1h column + return rec.get("cw_1h", 2*rec["in"]) + def msg_cost(rec, u): - return 0.0 if not rec else sum(u[f]*rec[f] for f,_ in FIELDS)/1e6 + if not rec: return 0.0 + c = sum(u[f]*rec[f] for f,_ in FIELDS if f != "cw") + c += u["cw5"]*rec["cw"] + u["cw1"]*cw1_rate(rec) # cache-write split by actual TTL + return c/1e6 # --- arg parsing: period + optional --top N argv = sys.argv[1:] @@ -157,12 +164,24 @@ def acct_of(sid): if model: usage = msg["usage"] u = {fk: (usage.get(k,0) or 0) for fk,k in FIELDS} + # split cache-write by TTL; legacy transcripts w/o the + # breakdown fall back to the combined field, costed as 5m + cc = usage.get("cache_creation") or {} + if "ephemeral_5m_input_tokens" in cc or "ephemeral_1h_input_tokens" in cc: + u["cw5"] = cc.get("ephemeral_5m_input_tokens",0) or 0 + u["cw1"] = cc.get("ephemeral_1h_input_tokens",0) or 0 + else: + u["cw5"] = u["cw"]; u["cw1"] = 0 rec = rate_for(model, day) if rec is None: unpriced.add(model) - c = msg_cost(rec, u); t = sum(u.values()) + c = msg_cost(rec, u); t = sum(u[fk] for fk,_ in FIELDS) for fk,_ in FIELDS: model_tok[model][fk] += u[fk] - if rec: model_catcost[model][fk] += u[fk]*rec[fk]/1e6 + if not rec: continue + if fk == "cw": + model_catcost[model][fk] += (u["cw5"]*rec["cw"] + u["cw1"]*cw1_rate(rec))/1e6 + else: + model_catcost[model][fk] += u[fk]*rec[fk]/1e6 model_cost[model] += c sess_cost[sid] += c; sess_tok[sid] += t day_cost[day] += c; day_tok[day] += t; day_sids[day].add(sid) @@ -199,13 +218,13 @@ def applicable_records(model): if unpriced: print(f"WARNING: no price record covers these models for part of the period: {', '.join(sorted(unpriced))} — add/extend them in prices.json.") -print("\nRATES in effect this period ($/MTok): model | effective | in | out | cache-write | cache-read | note") +print("\nRATES in effect this period ($/MTok): model | effective | in | out | cache-write(5m) | cache-write(1h) | cache-read | note") for m in sorted(model_tok, key=lambda m: model_cost[m], reverse=True): recs = applicable_records(m) if not recs: print(f" {m} | (no price record — UNPRICED)") for r in recs: - print(f" {m} | {r['effective']} | {r['in']} | {r['out']} | {r['cw']} | {r['cr']} | {r.get('note','')}") + print(f" {m} | {r['effective']} | {r['in']} | {r['out']} | {r['cw']} | {cw1_rate(r)} | {r['cr']} | {r.get('note','')}") print("\nBY MODEL: model | total$ | in(tok,$) | out(tok,$) | cache-write(tok,$) | cache-read(tok,$)") for m in sorted(model_tok, key=lambda m: model_cost[m], reverse=True):