diff --git a/plugins/cache-tax/.claude-plugin/plugin.json b/plugins/cache-tax/.claude-plugin/plugin.json index a441485..7f646c2 100644 --- a/plugins/cache-tax/.claude-plugin/plugin.json +++ b/plugins/cache-tax/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "cache-tax", "description": "The comeback price of a cold prompt cache, shown before you pay it. A UserPromptSubmit guard warns (or refuses once) when your next message will re-write a lapsed 1-hour cache at up to 80x the read rate, a SessionStart hook prices a stale resume, and the same script is a one-line status line segment with the minutes left and the cold-comeback cost.", - "version": "1.0.2", + "version": "1.0.3", "author": { "name": "Karan Bansal", "url": "https://github.com/karanb192" diff --git a/plugins/cache-tax/README.md b/plugins/cache-tax/README.md index c689967..89cc01c 100644 --- a/plugins/cache-tax/README.md +++ b/plugins/cache-tax/README.md @@ -29,7 +29,7 @@ The guard ignores slash commands, so `/clear` and `/compact` never trigger it. A ### The warning, verbatim ``` -cache-tax: the 1h prompt cache lapsed 3h00m ago. This message re-writes 300,002 tokens at $20/MTok = $6.00 +cache-tax: the 1h prompt cache lapsed 2h00m ago. This message re-writes 300,002 tokens at $20/MTok = $6.00 (a warm turn would have cost $0.08). If most of that context is stale, /clear and start from a handoff note instead. ``` @@ -72,11 +72,11 @@ All optional, set via environment variables: | `CACHE_TAX_TTL` | auto | Force `5m` or `1h` when the transcript has no cache-write tier to infer from. | | `CACHE_TAX_PRICES` | list rates | JSON overriding per-family prices as `[cache read, 5m write, 1h write]` in $/MTok, e.g. `{"fable-5-1":[0.25,12.5,20]}`. | -Built-in list rates (September 2026): Fable 5.1 `[0.25, 12.5, 20]`, Fable 5 `[1, 12.5, 20]`, Opus 5 and 4.x `[0.5, 6.25, 10]`, Sonnet `[0.3, 3.75, 6]`, Haiku `[0.1, 1.25, 2]`. Unknown models get token counts and no dollar figure. If you are on a subscription the dollars are what the same traffic would cost at API list price, which is the only public yardstick; how a cache write weighs against your plan limits is not documented. +Built-in list rates (September 2026): Fable 5.1 `[0.25, 12.5, 20]`, Fable 5 `[1, 12.5, 20]`, Opus 5 and 4.x `[0.5, 6.25, 10]`, Sonnet 5 `[0.2, 2.5, 4]`, Sonnet 4.x `[0.3, 3.75, 6]`, Haiku `[0.1, 1.25, 2]`. Unknown models get token counts and no dollar figure. If you are on a subscription the dollars are what the same traffic would cost at API list price, which is the only public yardstick; how a cache write weighs against your plan limits is not documented. ## How it decides -The tier comes from the newest assistant usage block in the transcript: `cache_creation.ephemeral_1h_input_tokens` means the 1h tier, `ephemeral_5m_input_tokens` the 5m tier. Age is wall-clock time since that block's timestamp. Lapsed means age past the tier's TTL. The re-write cost is the block's full context (input plus cache read plus cache write) at the tier's write rate. A "full miss" on the card is a request whose cache read covered under half of the previous request's context while its write covered over half of it. +The tier comes from the newest assistant usage block in the transcript: `cache_creation.ephemeral_1h_input_tokens` means the 1h tier, `ephemeral_5m_input_tokens` the 5m tier. Age is wall-clock time since that block's timestamp. Lapsed means age past the tier's TTL; the "lapsed N ago" figure is the time since that expiry, not since the block. The re-write cost is the block's full context (input plus cache read plus cache write) at the tier's write rate. A "full miss" on the card is a request whose cache read covered under half of the previous request's context while its write covered over half of it. Hooks receive no token counts, so nothing here comes from the hook input except the resume fields Claude Code added in v2.1.251. Everything else is read from `transcript_path`, the file Claude Code is already writing. diff --git a/plugins/cache-tax/cache-tax.js b/plugins/cache-tax/cache-tax.js index f30913c..7cf7ba6 100644 --- a/plugins/cache-tax/cache-tax.js +++ b/plugins/cache-tax/cache-tax.js @@ -55,6 +55,7 @@ const DEFAULT_PRICES = { 'fable-5': [1.0, 12.5, 20], 'opus-5': [0.5, 6.25, 10], 'opus-4': [0.5, 6.25, 10], + 'sonnet-5': [0.2, 2.5, 4], 'sonnet': [0.3, 3.75, 6], 'haiku': [0.1, 1.25, 2], }; @@ -64,7 +65,7 @@ function prices() { return Object.assign({}, DEFAULT_PRICES, extra); } -// Longest key first so fable-5-1 wins over fable-5 and opus-5 over opus. +// Longest key first so fable-5-1 wins over fable-5 and sonnet-5 over sonnet. function family(model) { const m = String(model || '').toLowerCase(); const keys = Object.keys(prices()).sort((a, b) => b.length - a.length); @@ -208,7 +209,7 @@ function stateFrom(u, nowMs) { const c = u.compacted; const pr = priceFor(u.model); return { - compacted: c, ttl, ageSec: Math.max(0, (nowMs - c.ts) / 1000), leftSec: 0, lapsed: false, + compacted: c, ttl, ageSec: Math.max(0, (nowMs - c.ts) / 1000), leftSec: 0, lapsedSec: 0, lapsed: false, ctx: c.postTokens, model: u.model, family: pr ? pr.family : null, rewriteUsd: pr && c.postTokens ? c.postTokens * pr.write1h / 1e6 : null, warmUsd: null, writeRate: pr ? pr.write1h : null, @@ -219,7 +220,7 @@ function stateFrom(u, nowMs) { const pr = priceFor(u.model); const writeRate = pr ? (ttl === '5m' ? pr.write5m : pr.write1h) : null; return { - ttl, ageSec, leftSec, lapsed: leftSec <= 0, ctx: u.ctx, model: u.model, family: pr ? pr.family : null, + ttl, ageSec, leftSec, lapsedSec: Math.max(0, -leftSec), lapsed: leftSec <= 0, ctx: u.ctx, model: u.model, family: pr ? pr.family : null, rewriteUsd: writeRate == null ? null : u.ctx * writeRate / 1e6, warmUsd: pr ? u.ctx * pr.read / 1e6 : null, writeRate, @@ -259,7 +260,7 @@ function statusLine(payload, nowMs) { if (!st) return 'cache ?'; if (st.compacted) return `cache reset by ${compactName(st.compacted)}` + (st.ctx ? ` · ${fmtTok(st.ctx)} carried · next msg writes ≥ ${fmtUsd(st.rewriteUsd)}` : ' · next msg writes the summary fresh'); if (!st.lapsed) return `cache ${fmtDur(st.leftSec)} left · ${fmtTok(st.ctx)} · cold costs ${fmtUsd(st.rewriteUsd)}`; - return `cache LAPSED ${fmtDur(st.ageSec)} ago · next msg re-writes ${fmtTok(st.ctx)} = ${fmtUsd(st.rewriteUsd)}`; + return `cache LAPSED ${fmtDur(st.lapsedSec)} ago · next msg re-writes ${fmtTok(st.ctx)} = ${fmtUsd(st.rewriteUsd)}`; } function compactName(c) { return c.trigger === 'auto' ? 'auto-compact' : '/compact'; } @@ -267,7 +268,7 @@ function compactName(c) { return c.trigger === 'auto' ? 'auto-compact' : '/compa function guardMessage(st) { const rate = st.writeRate == null ? 'the cache-write rate' : '$' + st.writeRate + '/MTok'; const warm = st.warmUsd == null ? '' : ` (a warm turn would have cost ${fmtUsd(st.warmUsd)})`; - return `cache-tax: the ${st.ttl} prompt cache lapsed ${fmtDur(st.ageSec)} ago. This message re-writes ` + + return `cache-tax: the ${st.ttl} prompt cache lapsed ${fmtDur(st.lapsedSec)} ago. This message re-writes ` + `${st.ctx.toLocaleString('en-US')} tokens at ${rate} = ${fmtUsd(st.rewriteUsd)}${warm}. ` + `If most of that context is stale, /clear and start from a handoff note instead.`; } @@ -329,7 +330,7 @@ function renderCard(transcriptPath, nowMs) { lines.push(`context ${st.ctx ? st.ctx.toLocaleString('en-US') + ' tokens carried' : 'size unknown'} (summary and kept turns; system prompt and tools are not in the transcript)`); lines.push(`cold cost ${st.rewriteUsd == null ? 'n/a' : 'at least ' + fmtUsd(st.rewriteUsd)}, the first message writes the carried tokens fresh`); } else lines.push(st.lapsed - ? `state COLD, lapsed ${fmtDur(st.ageSec)} ago` + ? `state COLD, lapsed ${fmtDur(st.lapsedSec)} ago` : `state warm, ${fmtDur(st.leftSec)} left`); if (!st.compacted) { lines.push(`context ${st.ctx.toLocaleString('en-US')} tokens`); diff --git a/plugins/cache-tax/tests/cache-tax.test.js b/plugins/cache-tax/tests/cache-tax.test.js index 2296364..5aedae1 100644 --- a/plugins/cache-tax/tests/cache-tax.test.js +++ b/plugins/cache-tax/tests/cache-tax.test.js @@ -56,9 +56,14 @@ describe('pricing', () => { assert.strictEqual(family('claude-fable-5'), 'fable-5'); assert.strictEqual(family('claude-opus-5'), 'opus-5'); assert.strictEqual(family('claude-opus-4-8'), 'opus-4'); - assert.strictEqual(family('claude-sonnet-5'), 'sonnet'); + assert.strictEqual(family('claude-sonnet-5'), 'sonnet-5'); + assert.strictEqual(family('claude-sonnet-4-6'), 'sonnet'); assert.strictEqual(family('something-else'), null); }); + it('prices Sonnet 5 below Sonnet 4.x', () => { + assert.deepStrictEqual([priceFor('claude-sonnet-5').read, priceFor('claude-sonnet-5').write5m, priceFor('claude-sonnet-5').write1h], [0.2, 2.5, 4]); + assert.deepStrictEqual([priceFor('claude-sonnet-4-6').read, priceFor('claude-sonnet-4-6').write5m, priceFor('claude-sonnet-4-6').write1h], [0.3, 3.75, 6]); + }); it('prices the 1h write at 80x the read on Fable 5.1', () => { const p = priceFor('claude-fable-5-1'); assert.strictEqual(p.write1h / p.read, 80); @@ -113,6 +118,7 @@ describe('state', () => { assert.ok(warm.leftSec > 49 * 60 && warm.leftSec <= 50 * 60); const cold = stateFrom(parseUsageLine(usageLine({ ts: now - 2 * HOUR, write: 1000, read: 200000 })), now); assert.strictEqual(cold.lapsed, true); + assert.ok(cold.lapsedSec > 3599 && cold.lapsedSec <= 3600, 'lapsed counts from expiry, not from the last turn'); assert.ok(Math.abs(cold.rewriteUsd - 201002 * 20 / 1e6) < 1e-6); }); it('uses the 5m TTL when the last write was on the 5m tier', () => { @@ -144,7 +150,7 @@ describe('status line', () => { const now = Date.now(); const p = writeTranscript(tmp, [{ ts: now - 2 * HOUR, write: 1000, read: 299000 }]); const line = statusLine({ transcript_path: p }, now); - assert.match(line, /^cache LAPSED 2h00m ago · next msg re-writes 300k = \$6\.00$/); + assert.match(line, /^cache LAPSED 1h00m ago · next msg re-writes 300k = \$6\.00$/); }); it('falls back to the transcript when the native object is incomplete (right after compaction)', () => { const now = Date.now(); @@ -179,7 +185,7 @@ describe('guard (UserPromptSubmit)', () => { const r = run({ hook_event_name: 'UserPromptSubmit', transcript_path: p, session_id: 's-warn' }, { CACHE_TAX_BLOCK: '' }); assert.strictEqual(r.code, 0); const o = JSON.parse(r.out); - assert.match(o.systemMessage, /lapsed 3h00m ago/); + assert.match(o.systemMessage, /the 1h prompt cache lapsed 2h00m ago/); assert.match(o.systemMessage, /300,002 tokens at \$20\/MTok = \$6\.00/); }); it('blocks once, then lets the resend through (integration, exit 2 then 0)', () => { @@ -217,7 +223,7 @@ describe('resume (SessionStart)', () => { describe('card (--render)', () => { it('renders state, cost and session totals', () => { const now = Date.now(); - const p = writeTranscript(tmp, [{ ts: now - 3 * HOUR, write: 100000, read: 0, id: 'a' }, { ts: now - 2 * HOUR, write: 500, read: 100000, id: 'b' }]); + const p = writeTranscript(tmp, [{ ts: now - 4 * HOUR, write: 100000, read: 0, id: 'a' }, { ts: now - 3 * HOUR, write: 500, read: 100000, id: 'b' }]); const card = renderCard(p, now); assert.match(card, /cache-tax · claude-fable-5-1 · 1h tier/); assert.match(card, /COLD, lapsed 2h00m ago/);