Skip to content
Merged
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
2 changes: 1 addition & 1 deletion plugins/cache-tax/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
6 changes: 3 additions & 3 deletions plugins/cache-tax/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
```

Expand Down Expand Up @@ -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.

Expand Down
13 changes: 7 additions & 6 deletions plugins/cache-tax/cache-tax.js
Original file line number Diff line number Diff line change
Expand Up @@ -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],
};
Expand All @@ -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);
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -259,15 +260,15 @@ 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'; }

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.`;
}
Expand Down Expand Up @@ -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`);
Expand Down
14 changes: 10 additions & 4 deletions plugins/cache-tax/tests/cache-tax.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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)', () => {
Expand Down Expand Up @@ -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/);
Expand Down
Loading