[BUG] Max 20x plan: 100% usage after 2 hours of light work (5 commits, no agents)

Open 💬 42 comments Opened Apr 1, 2026 by weilhalt

Description

Max 20x plan ($200/mo), hit 100% usage after approximately 2 hours today (April 1, 2026). This has never happened before with comparable workloads.

What I did

  • 5 small commits (security tests, doc updates, test fixes)
  • No subagents, no Agent tool usage
  • Normal sequential workflow: read → edit → test → commit
  • Model: Opus 4.6 (1M context)

Expected behavior

2 hours of light work should not exhaust the 5-hour Max 20x window. Previously, similar sessions used maybe 20-30% of the budget.

Environment

  • Claude Code CLI on Linux
  • Max 20x plan
  • Model: claude-opus-4-6 (1M context)

Related issues

This appears to be the same underlying problem reported since ~March 23:

  • #38335
  • #38357
  • #37394
  • #40903

Something changed server-side in quota calculation around that date. The budget is being decremented far faster than actual token usage would justify.

View original on GitHub ↗

42 Comments

weilhalt · 3 months ago

Adding context for the Anthropic team:

I'm paying $200/month for the Max 20x plan specifically to have sufficient capacity for professional development work. Since approximately March 23, the same workloads that previously used 20-30% of my budget now exhaust it completely in under 2 hours.

This is not a minor inconvenience — it's a significant reduction in the service I'm paying for. I'd like to understand:

  1. What changed around March 23 in the quota calculation?
  2. When will this be fixed?
  3. Will affected Max plan subscribers receive credits or a partial refund for the reduced capacity over the past ~10 days?

I rely on Claude Code for daily professional work and the current state makes the Max 20x plan poor value compared to what was delivered before March 23.

timurco · 3 months ago
I rely on Claude Code for daily professional work and the current state makes the Max 20x plan poor value compared to what was delivered before March 23.

Thanks for your Issue! I had the same thing happen several times in the last few days, but on Max 5x I thought about switching to 20x but after your message I changed my mind, now I'm testing OpenAI Codex, let's see what happens. Although I really love Claude in its thought process

angelacaldas · 3 months ago

I've been having the same problem with Pro plan. Few small fixes and code refactors are quickly spending my budget in under two hours. A week ago the same amount of work (or even bigger changes) would never reach the usage limit.

p948tzqwxr-bit · 3 months ago

Same issue! This is crazy. I pay 200 a month for this!

MaxHermez · 3 months ago

Same exact problem, I believe it might be related to this issue #34629 and they marked it as closed but there's someone commenting asking why they closed it if the issue persist.
The most insightful comment in that thread is the guy saying that

I feel like this "bug" isn't getting patched anytime soon lol

Anthropic better wake up any sleeping engineers and reset everyone's usage once this is fixed. 200 dollars is not a small chunk of money and I won't be waiting until next monday for my quota to reset.

Confirmed still broken on v2.1.89 (Windows, interactive + --print --resume)

Environment:

  • Platform: Windows 11
  • Claude Code: v2.1.89 (standalone binary, auto-updated)
  • Model: claude-opus-4-6[1m]
  • Install method: Standalone .exe (~/.local/bin/claude.exe)

Clean reproduction

Ran from an empty directory with no CLAUDE.md or memory files to avoid tool-use turns muddying the results. All messages were single-turn (1 API call each), simple math questions.

# Message 1 — fresh session
echo "What is 2+2?" | claude --print --output-format stream-json --verbose

# Message 2 — resume
echo "What is 3+3?" | claude --print --resume <session-id> --output-format stream-json --verbose

# Message 3 — resume
echo "What is 4+4?" | claude --print --resume <session-id> --output-format stream-json --verbose

Results

| Message | cache_read | cache_creation | input_tokens | num_turns | cost |
|---------|-----------|----------------|-------------|-----------|------|
| 1 (fresh) | 11,272 | 4,885 | 2 | 1 | $0.036 |
| 2 (resume) | 11,272 | 4,900 | 2 | 1 | $0.036 |
| 3 (resume) | 11,272 | 4,915 | 2 | 1 | $0.036 |

cache_read is stuck at exactly 11,272 across all three messages — only the system prompt is being cached. The conversation history is re-created from scratch on every resume call (cache_creation grows by ~15 tokens per message instead of shifting to cache_read).

Expected behavior

cache_read should grow as conversation accumulates (e.g. ~16k on msg 2, ~17k on msg 3), with cache_creation dropping to a small delta.

timurco · 3 months ago

I noticed that sometimes when it writes something like:
· Shimmying…
⎿  Tip: ...

It takes a long time and nothing happens, it's a really bad sign, after that my limits get eaten up by 30%

cnighswonger · 3 months ago

Our accounting dept is planning to initiate charge back requests with our bank for all bills covering the period of token burn. Sadly this is quickly crossing into civil liability territory. Silence is not golden in cases such as this.

cnighswonger · 3 months ago
  1. Usage @ 6%
  2. Ran claude --resume <id>
  3. Gave this prompt: What is 2+2? Just give me the number answer.
  4. Latency was unusually high, but the agent finally returned just '4'
  5. Usage @ 26%

20% for "2+2" on a resumed 605k session

I suspect Anthropic is trying to drive us to /clear much more often.

agon252009 · 3 months ago

Same here. I'm on the $200/month max plan and all I was doing was reviewing plans and research and I hit my limit in 50 minutes. I mean this is really out of hand. It's ridiculous. Thanks for the issue.

timurco · 3 months ago
@cnighswonger I suspect Anthropic is trying to drive us to /clear much more often.

btw yeah i also noticed that new sessions dont have this problem. It feels like sessions created before the update spam tokens under the hood and can be dangerous as they eat up limits

cnighswonger · 3 months ago
cnighswonger · 3 months ago

TLDR; This is what we are running as a workaround. It does not fix the single, large hit that comes from resuming a long context, but it does seem to stop the bleeding otherwise. It is based on the work of others (see the end) as well as Claude itself. YMMV.

The Problem

Two bugs in Claude Code v2.1.69+ cause prompt cache misses, leading to ~20x cost increases:

  1. Resume cache regression (#34629): On --resume, attachment blocks (skills, MCP, deferred tools) are injected at the END of the message array instead of the BEGINNING. This breaks the cache prefix match — every turn rebuilds the full context from scratch instead of reading from cache.
  1. Within-session cache invalidation (#40524): The cc_version fingerprint in the attribution header is computed from meta/attachment content instead of real user input. When attachment content changes between turns, the fingerprint changes → system prompt changes → cache bust. Additionally, tool schema ordering is non-deterministic, causing further cache invalidation.

How the Fix Works

A Node.js fetch interceptor (--import preload) runs between Claude Code and the Anthropic API. On every /v1/messages request, it:

  • Relocates attachment blocks to messages[0] on every API call, matching fresh session layout
  • Stabilizes the fingerprint by computing it from real user text, not meta blocks
  • Sorts tool schemas alphabetically for deterministic ordering
  • Bypasses the Zig sentinel (cch=00000) by using the npm package instead of the standalone binary

Installation

Prerequisites

  • Node.js 18+ (node --version)
  • npm (npm --version)

Step 1: Install the Claude Code npm package

The standalone binary has a Zig-level HTTP stack that cannot be intercepted from JavaScript. The npm package uses Node.js globalThis.fetch, which we can intercept.

mkdir -p ~/.npm-global
npm config set prefix ~/.npm-global
npm install -g @anthropic-ai/claude-code

Step 2: Create the fetch interceptor

Save the following as ~/.claude/cache-fix-preload.mjs:

<details>
<summary>Click to expand cache-fix-preload.mjs</summary>

// cache-fix-preload.mjs — Node.js fetch interceptor for Claude Code cache regression fixes.
//
// Fixes two bugs:
//
// Bug 1 (github.com/anthropics/claude-code/issues/34629):
//   On --resume, attachment blocks (hooks, skills, deferred-tools, MCP) land in
//   the LAST user message instead of the FIRST. This breaks the prompt cache
//   prefix match, causing ~20x cost increase. Fix: relocate them to messages[0].
//
// Bug 2 (github.com/anthropics/claude-code/issues/40524):
//   The cc_version fingerprint in the attribution header is computed from
//   messages[0] content INCLUDING meta/attachment blocks. When those blocks
//   change between turns (tool pool change, MCP reconnection, skill reload),
//   the fingerprint changes -> attribution header changes -> system prompt
//   bytes change -> cache bust within the same session. Fix: stabilize the
//   fingerprint by recomputing it from the real user message, and freeze the
//   attribution header's cc_version across turns.
//
// Based on community fix by @VictorSun92 / @jmarianski (issue #34629),
// enhanced with fingerprint stabilization from source analysis.
//
// Load via: NODE_OPTIONS="--import $HOME/.claude/cache-fix-preload.mjs"

import { createHash } from "node:crypto";

// --------------------------------------------------------------------------
// Fingerprint stabilization (Bug 2)
// --------------------------------------------------------------------------

// Must match src/utils/fingerprint.ts exactly.
const FINGERPRINT_SALT = "59cf53e54c78";
const FINGERPRINT_INDICES = [4, 7, 20];

/**
 * Recompute the 3-char hex fingerprint the same way the source does:
 *   SHA256(SALT + msg[4] + msg[7] + msg[20] + version)[:3]
 * but using the REAL user message text, not the first (possibly meta) message.
 */
function computeFingerprint(messageText, version) {
  const chars = FINGERPRINT_INDICES.map((i) => messageText[i] || "0").join("");
  const input = `${FINGERPRINT_SALT}${chars}${version}`;
  return createHash("sha256").update(input).digest("hex").slice(0, 3);
}

/**
 * Find the first REAL user message text (not a <system-reminder> meta block).
 * The original bug: extractFirstMessageText() grabs content from messages[0]
 * which may be a synthetic attachment message, not the actual user prompt.
 */
function extractRealUserMessageText(messages) {
  for (const msg of messages) {
    if (msg.role !== "user") continue;
    const content = msg.content;
    if (!Array.isArray(content)) {
      if (typeof content === "string" && !content.startsWith("<system-reminder>")) {
        return content;
      }
      continue;
    }
    for (const block of content) {
      if (block.type === "text" && typeof block.text === "string" && !block.text.startsWith("<system-reminder>")) {
        return block.text;
      }
    }
  }
  return "";
}

/**
 * Extract current cc_version from system prompt blocks and recompute with
 * stable fingerprint. Returns { oldVersion, newVersion, stableFingerprint }.
 */
function stabilizeFingerprint(system, messages) {
  if (!Array.isArray(system)) return null;

  const attrIdx = system.findIndex(
    (b) => b.type === "text" && typeof b.text === "string" && b.text.includes("x-anthropic-billing-header:")
  );
  if (attrIdx === -1) return null;

  const attrBlock = system[attrIdx];
  const versionMatch = attrBlock.text.match(/cc_version=([^;]+)/);
  if (!versionMatch) return null;

  const fullVersion = versionMatch[1];
  const dotParts = fullVersion.split(".");
  if (dotParts.length < 4) return null;

  const baseVersion = dotParts.slice(0, 3).join(".");
  const oldFingerprint = dotParts[3];

  const realText = extractRealUserMessageText(messages);
  const stableFingerprint = computeFingerprint(realText, baseVersion);

  if (stableFingerprint === oldFingerprint) return null;

  const newVersion = `${baseVersion}.${stableFingerprint}`;
  const newText = attrBlock.text.replace(
    `cc_version=${fullVersion}`,
    `cc_version=${newVersion}`
  );

  return { attrIdx, newText, oldFingerprint, stableFingerprint };
}

// --------------------------------------------------------------------------
// Resume message relocation (Bug 1)
// --------------------------------------------------------------------------

function isSystemReminder(text) {
  return typeof text === "string" && text.startsWith("<system-reminder>");
}
function isHooksBlock(text) {
  return isSystemReminder(text) && text.includes("hook success");
}
function isSkillsBlock(text) {
  return isSystemReminder(text) && text.includes("skills are available");
}
function isDeferredToolsBlock(text) {
  return isSystemReminder(text) && text.includes("deferred tools");
}
function isMcpBlock(text) {
  return isSystemReminder(text) && text.includes("MCP Server Instructions");
}
function isRelocatableBlock(text) {
  return (
    isHooksBlock(text) ||
    isSkillsBlock(text) ||
    isDeferredToolsBlock(text) ||
    isMcpBlock(text)
  );
}

function sortSkillsBlock(text) {
  const match = text.match(
    /^([\s\S]*?\n\n)(- [\s\S]+?)(\n<\/system-reminder>\s*)$/
  );
  if (!match) return text;
  const [, header, entriesText, footer] = match;
  const entries = entriesText.split(/\n(?=- )/);
  entries.sort();
  return header + entries.join("\n") + footer;
}

function stripSessionKnowledge(text) {
  return text.replace(
    /\n<session_knowledge[^>]*>[\s\S]*?<\/session_knowledge>/g,
    ""
  );
}

/**
 * On EVERY call, scan the entire message array for the LATEST relocatable
 * blocks and ensure they are in messages[0]. This matches fresh session
 * behavior where attachments are always prepended to messages[0].
 */
function normalizeResumeMessages(messages) {
  if (!Array.isArray(messages) || messages.length < 2) return messages;

  let firstUserIdx = -1;
  for (let i = 0; i < messages.length; i++) {
    if (messages[i].role === "user") {
      firstUserIdx = i;
      break;
    }
  }
  if (firstUserIdx === -1) return messages;

  const firstMsg = messages[firstUserIdx];
  if (!Array.isArray(firstMsg?.content)) return messages;

  const firstAlreadyHas = firstMsg.content.some((b) =>
    isRelocatableBlock(b.text)
  );
  if (firstAlreadyHas) return messages;

  const found = new Map();

  for (let i = messages.length - 1; i > firstUserIdx; i--) {
    const msg = messages[i];
    if (msg.role !== "user" || !Array.isArray(msg.content)) continue;

    for (let j = msg.content.length - 1; j >= 0; j--) {
      const block = msg.content[j];
      const text = block.text || "";
      if (!isRelocatableBlock(text)) continue;

      let blockType;
      if (isSkillsBlock(text)) blockType = "skills";
      else if (isMcpBlock(text)) blockType = "mcp";
      else if (isDeferredToolsBlock(text)) blockType = "deferred";
      else if (isHooksBlock(text)) blockType = "hooks";
      else continue;

      if (!found.has(blockType)) {
        let fixedText = text;
        if (blockType === "hooks") fixedText = stripSessionKnowledge(text);
        if (blockType === "skills") fixedText = sortSkillsBlock(text);

        const { cache_control, ...rest } = block;
        found.set(blockType, {
          block: { ...rest, text: fixedText },
          msgIdx: i,
          blockIdx: j,
        });
      }
    }
  }

  if (found.size === 0) return messages;

  const removals = new Map();
  for (const { msgIdx, blockIdx } of found.values()) {
    if (!removals.has(msgIdx)) removals.set(msgIdx, new Set());
    removals.get(msgIdx).add(blockIdx);
  }

  const ORDER = ["skills", "mcp", "deferred", "hooks"];
  const toRelocate = ORDER.filter((t) => found.has(t)).map(
    (t) => found.get(t).block
  );

  const allFirstBlocks = [...toRelocate, ...firstMsg.content];

  const result = [...messages];
  result[firstUserIdx] = { ...firstMsg, content: allFirstBlocks };

  for (const [msgIdx, blockIndices] of removals) {
    const msg = result[msgIdx];
    const filtered = msg.content.filter((_, i) => !blockIndices.has(i));
    result[msgIdx] = { ...msg, content: filtered };
  }

  return result;
}

// --------------------------------------------------------------------------
// Tool schema stabilization (Bug 2 secondary cause)
// --------------------------------------------------------------------------

function stabilizeToolOrder(tools) {
  if (!Array.isArray(tools) || tools.length === 0) return tools;
  return [...tools].sort((a, b) => {
    const nameA = a.name || "";
    const nameB = b.name || "";
    return nameA.localeCompare(nameB);
  });
}

// --------------------------------------------------------------------------
// Debug logging
// --------------------------------------------------------------------------

import { appendFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";

const DEBUG = process.env.CACHE_FIX_DEBUG === "1";
const LOG_PATH = join(homedir(), ".claude", "cache-fix-debug.log");

function debugLog(...args) {
  if (!DEBUG) return;
  const line = `[${new Date().toISOString()}] ${args.join(" ")}\n`;
  try { appendFileSync(LOG_PATH, line); } catch {}
}

// --------------------------------------------------------------------------
// Fetch interceptor
// --------------------------------------------------------------------------

const _origFetch = globalThis.fetch;

globalThis.fetch = async function (url, options) {
  const urlStr = typeof url === "string" ? url : url?.url || String(url);

  const isMessagesEndpoint =
    urlStr.includes("/v1/messages") &&
    !urlStr.includes("batches") &&
    !urlStr.includes("count_tokens");

  if (isMessagesEndpoint && options?.body && typeof options.body === "string") {
    try {
      const payload = JSON.parse(options.body);
      let modified = false;

      debugLog("--- API call to", urlStr);
      debugLog("message count:", payload.messages?.length);

      if (payload.messages) {
        if (DEBUG) {
          let firstUserIdx = -1, lastUserIdx = -1;
          for (let i = 0; i < payload.messages.length; i++) {
            if (payload.messages[i].role === "user") {
              if (firstUserIdx === -1) firstUserIdx = i;
              lastUserIdx = i;
            }
          }
          if (firstUserIdx !== -1) {
            const firstContent = payload.messages[firstUserIdx].content;
            const lastContent = payload.messages[lastUserIdx].content;
            debugLog("firstUserIdx:", firstUserIdx, "lastUserIdx:", lastUserIdx);
            debugLog("first user msg blocks:", Array.isArray(firstContent) ? firstContent.length : "string");
            if (Array.isArray(firstContent)) {
              for (const b of firstContent) {
                const t = (b.text || "").substring(0, 80);
                debugLog("  first[block]:", isRelocatableBlock(b.text) ? "RELOCATABLE" : "keep", JSON.stringify(t));
              }
            }
            if (firstUserIdx !== lastUserIdx) {
              debugLog("last user msg blocks:", Array.isArray(lastContent) ? lastContent.length : "string");
              if (Array.isArray(lastContent)) {
                for (const b of lastContent) {
                  const t = (b.text || "").substring(0, 80);
                  debugLog("  last[block]:", isRelocatableBlock(b.text) ? "RELOCATABLE" : "keep", JSON.stringify(t));
                }
              }
            } else {
              debugLog("single user message (fresh session)");
            }
          }
        }

        const normalized = normalizeResumeMessages(payload.messages);
        if (normalized !== payload.messages) {
          payload.messages = normalized;
          modified = true;
          debugLog("APPLIED: resume message relocation");
        } else {
          debugLog("SKIPPED: resume relocation (not a resume or already correct)");
        }
      }

      if (payload.tools) {
        const sorted = stabilizeToolOrder(payload.tools);
        const changed = sorted.some(
          (t, i) => t.name !== payload.tools[i]?.name
        );
        if (changed) {
          payload.tools = sorted;
          modified = true;
          debugLog("APPLIED: tool order stabilization");
        }
      }

      if (payload.system && payload.messages) {
        const fix = stabilizeFingerprint(payload.system, payload.messages);
        if (fix) {
          payload.system = [...payload.system];
          payload.system[fix.attrIdx] = {
            ...payload.system[fix.attrIdx],
            text: fix.newText,
          };
          modified = true;
          debugLog("APPLIED: fingerprint stabilized from", fix.oldFingerprint, "to", fix.stableFingerprint);
        }
      }

      if (modified) {
        options = { ...options, body: JSON.stringify(payload) };
        debugLog("Request body rewritten");
      }
    } catch (e) {
      debugLog("ERROR in interceptor:", e?.message);
    }
  }

  return _origFetch.apply(this, [url, options]);
};

</details>

Step 3: Create the wrapper script

Save the following as ~/bin/claude and make it executable:

#!/bin/bash
# claude-fixed — Claude Code wrapper with cache regression fixes.
#
# Routes through Node.js (npm package) with a fetch interceptor preload that:
#   1. Fixes resume cache regression (issue #34629)
#   2. Fixes within-session cache invalidation (issue #40524)
#
# The standalone binary has Zig-level attestation (cch= sentinel replacement)
# that also busts cache. Using the npm package avoids that entirely.

CLAUDE_NPM_CLI="$HOME/.npm-global/lib/node_modules/@anthropic-ai/claude-code/cli.js"
CACHE_FIX="$HOME/.claude/cache-fix-preload.mjs"

if [ ! -f "$CLAUDE_NPM_CLI" ]; then
  echo "Error: Claude Code npm package not found at $CLAUDE_NPM_CLI" >&2
  echo "Install with: npm install -g @anthropic-ai/claude-code" >&2
  exit 1
fi

if [ ! -f "$CACHE_FIX" ]; then
  echo "Warning: cache-fix-preload.mjs not found at $CACHE_FIX" >&2
  echo "Running without cache fix." >&2
  exec node "$CLAUDE_NPM_CLI" "$@"
fi

exec env NODE_OPTIONS="--import $CACHE_FIX" node "$CLAUDE_NPM_CLI" "$@"
mkdir -p ~/bin
chmod +x ~/bin/claude

Step 4: Ensure ~/bin takes PATH priority

The Claude Code auto-updater overwrites ~/.local/bin/claude on every update. Placing our wrapper in ~/bin (which takes PATH priority) ensures it survives.

echo 'export PATH="$HOME/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc

Step 5: Verify

which claude          # Should show: /home/youruser/bin/claude
file $(which claude)  # Should show: Bourne-Again shell script

# Quick test
echo "Hi" | claude --print

# Debug mode (optional)
echo "Hi" | CACHE_FIX_DEBUG=1 claude --print
cat ~/.claude/cache-fix-debug.log

What to Expect

| Scenario | Cost |
|----------|------|
| Fresh session, first turn | Normal (small context) |
| Fresh session, subsequent turns | ~0% (cache hit) |
| Resume, first turn | Expensive — proportional to session size (cold start cache_create) |
| Resume, subsequent turns | ~0% (cache hit) |
| Long idle then next turn | Same as resume first turn (cache expired server-side) |

Verified Results (2026-04-01)

Tested on a resumed 605k token session (2400+ messages):

| Turn | Usage jump | Cache behavior |
|------|-----------|----------------|
| Resume (first turn) | ~14% | cache_create — cold start, expected |
| Second turn ("What is 3+3?") | 0% | cache_read — fix confirmed working |
| Ongoing active turns | <1% | cache_read — stable |

Cost Management Tips

  • Keep sessions hot. Consecutive turns with no idle gaps cost nearly nothing. The cache stays warm between turns.
  • /compact before stepping away. Shrinks context so the next cold start (from idle or resume) is cheap.
  • Don't resume stale large sessions. A 600k token session that's been idle for hours will cost ~14% of your quota just to reload. Start fresh or use "Resume from summary" instead.
  • Start new sessions for new tasks. Fresh sessions have small context and trivial cold start costs.

Updating

When Claude Code releases a new version, update the npm package:

npm install -g @anthropic-ai/claude-code

The wrapper script and preload interceptor don't need changes unless Claude Code significantly changes its message structure.

Reverting

To go back to the unpatched standalone binary:

rm ~/bin/claude
hash -r

Limitations

  • Cold start cost on resume is unavoidable — the server-side cache expires (likely 5min–1hr TTL). The first turn after resume or long idle pays the full cache_create tax proportional to session size. This is an API-level behavior, not something fixable client-side.
  • Requires the npm package — the standalone ELF binary has a Zig-level HTTP stack that cannot be intercepted from JavaScript.
  • npm package needs manual updates — the auto-updater only updates the standalone binary at ~/.local/bin/claude.

Credits

  • Root cause analysis by @jmarianski — MITM proxy capture + Ghidra reverse engineering identifying the messages[0] asymmetry
  • Original fetch-hook fix approach by @VictorSun92
  • Fingerprint stabilization and multi-turn fix developed from source analysis of the Claude Code source dump
VictorSun92 · 3 months ago

Thanks to @cnighswonger for the excellent preload interceptor approach. While testing on v2.1.90, we found three issues and applied fixes.

TL;DR

v2.1.89+ changed internal message normalization, causing partial attachment scatter on resume — some blocks stay in messages[0], others drift.

Findings (via CACHE_FIX_DEBUG=1)

Fresh session messages[0]: [deferred_tools, MCP, skills, context, user_text]
Resume session:

messages[0]: [deferred_tools, MCP, context, user_text]   ← skills gone
messages[2]: [skills, resume_text]                        ← scattered here

Script logged SKIPPED because firstAlreadyHas saw deferred+MCP and bailed early.

Three Fixes Applied

1. Detection: partial scatter missed

- const firstAlreadyHas = firstMsg.content.some(b => isRelocatableBlock(b.text));
- if (firstAlreadyHas) return messages;
+ // Scan for scattered blocks OUTSIDE first message
+ let hasScatteredBlocks = false;
+ for (let i = firstUserIdx + 1; i < messages.length && !hasScatteredBlocks; i++) {
+   const msg = messages[i];
+   if (msg.role !== "user" || !Array.isArray(msg.content)) continue;
+   for (const block of msg.content)
+     if (isRelocatableBlock(block.text || "")) { hasScatteredBlocks = true; break; }
+ }
+ if (!hasScatteredBlocks) return messages;

Also changed scan range to i >= firstUserIdx (include first msg), then remove all relocatable from all messages and rebuild — handles both full and partial scatter.

2. ORDER doesn't match fresh session

- const ORDER = ["skills", "mcp", "deferred", "hooks"];
+ const ORDER = ["deferred", "mcp", "skills", "hooks"];

Fresh session order is deferred → mcp → skills on v2.1.90.

3. False positive on quoted content

"Note:" file-change reminders embed file contents. If debug log text contains "deferred tools", the .includes() matcher triggers on the quoted content, corrupting messages[0].

- return isSystemReminder(text) && text.includes("deferred tools");
+ const SR = "<system-reminder>\n";
+ return typeof text === "string" && text.startsWith(SR + "The following deferred tools are now available");

Same startsWith for skills and MCP. Hooks keeps substring(0, 200).includes() since we never observed an actual hooks block in practice.

My Edit

<details>
<summary>Click to expand cache-fix-preload.mjs</summary>

// cache-fix-preload.mjs — Node.js fetch interceptor for Claude Code cache regression fixes.
//
// Fixes two bugs:
//
// Bug 1 (github.com/anthropics/claude-code/issues/34629):
//   On --resume, attachment blocks (hooks, skills, deferred-tools, MCP) land in
//   the LAST user message instead of the FIRST. This breaks the prompt cache
//   prefix match, causing ~20x cost increase. Fix: relocate them to messages[0].
//
// Bug 2 (github.com/anthropics/claude-code/issues/40524):
//   The cc_version fingerprint in the attribution header is computed from
//   messages[0] content INCLUDING meta/attachment blocks. When those blocks
//   change between turns (tool pool change, MCP reconnection, skill reload),
//   the fingerprint changes -> attribution header changes -> system prompt
//   bytes change -> cache bust within the same session. Fix: stabilize the
//   fingerprint by recomputing it from the real user message, and freeze the
//   attribution header's cc_version across turns.
//
// Based on community fix by @VictorSun92 / @jmarianski (issue #34629),
// enhanced with fingerprint stabilization from source analysis.
//
// Load via: NODE_OPTIONS="--import $HOME/.claude/cache-fix-preload.mjs"

import { createHash } from "node:crypto";

// --------------------------------------------------------------------------
// Fingerprint stabilization (Bug 2)
// --------------------------------------------------------------------------

// Must match src/utils/fingerprint.ts exactly.
const FINGERPRINT_SALT = "59cf53e54c78";
const FINGERPRINT_INDICES = [4, 7, 20];

/**
 * Recompute the 3-char hex fingerprint the same way the source does:
 *   SHA256(SALT + msg[4] + msg[7] + msg[20] + version)[:3]
 * but using the REAL user message text, not the first (possibly meta) message.
 */
function computeFingerprint(messageText, version) {
  const chars = FINGERPRINT_INDICES.map((i) => messageText[i] || "0").join("");
  const input = `${FINGERPRINT_SALT}${chars}${version}`;
  return createHash("sha256").update(input).digest("hex").slice(0, 3);
}

/**
 * Find the first REAL user message text (not a <system-reminder> meta block).
 * The original bug: extractFirstMessageText() grabs content from messages[0]
 * which may be a synthetic attachment message, not the actual user prompt.
 */
function extractRealUserMessageText(messages) {
  for (const msg of messages) {
    if (msg.role !== "user") continue;
    const content = msg.content;
    if (!Array.isArray(content)) {
      if (typeof content === "string" && !content.startsWith("<system-reminder>")) {
        return content;
      }
      continue;
    }
    for (const block of content) {
      if (block.type === "text" && typeof block.text === "string" && !block.text.startsWith("<system-reminder>")) {
        return block.text;
      }
    }
  }
  return "";
}

/**
 * Extract current cc_version from system prompt blocks and recompute with
 * stable fingerprint. Returns { oldVersion, newVersion, stableFingerprint }.
 */
function stabilizeFingerprint(system, messages) {
  if (!Array.isArray(system)) return null;

  const attrIdx = system.findIndex(
    (b) => b.type === "text" && typeof b.text === "string" && b.text.includes("x-anthropic-billing-header:")
  );
  if (attrIdx === -1) return null;

  const attrBlock = system[attrIdx];
  const versionMatch = attrBlock.text.match(/cc_version=([^;]+)/);
  if (!versionMatch) return null;

  const fullVersion = versionMatch[1];
  const dotParts = fullVersion.split(".");
  if (dotParts.length < 4) return null;

  const baseVersion = dotParts.slice(0, 3).join(".");
  const oldFingerprint = dotParts[3];

  const realText = extractRealUserMessageText(messages);
  const stableFingerprint = computeFingerprint(realText, baseVersion);

  if (stableFingerprint === oldFingerprint) return null;

  const newVersion = `${baseVersion}.${stableFingerprint}`;
  const newText = attrBlock.text.replace(
    `cc_version=${fullVersion}`,
    `cc_version=${newVersion}`
  );

  return { attrIdx, newText, oldFingerprint, stableFingerprint };
}

// --------------------------------------------------------------------------
// Resume message relocation (Bug 1)
// --------------------------------------------------------------------------

function isSystemReminder(text) {
  return typeof text === "string" && text.startsWith("<system-reminder>");
}
// FIX: Match block headers with startsWith to avoid false positives from
// quoted content (e.g. "Note:" file-change reminders embedding debug logs).
// Block size (tool count, MCP count, etc.) never affects the header position.
const SR = "<system-reminder>\n";
function isHooksBlock(text) {
  // Hooks block header varies; fall back to head-region check
  return isSystemReminder(text) && text.substring(0, 200).includes("hook success");
}
function isSkillsBlock(text) {
  return typeof text === "string" && text.startsWith(SR + "The following skills are available");
}
function isDeferredToolsBlock(text) {
  return typeof text === "string" && text.startsWith(SR + "The following deferred tools are now available");
}
function isMcpBlock(text) {
  return typeof text === "string" && text.startsWith(SR + "# MCP Server Instructions");
}
function isRelocatableBlock(text) {
  return (
    isHooksBlock(text) ||
    isSkillsBlock(text) ||
    isDeferredToolsBlock(text) ||
    isMcpBlock(text)
  );
}

function sortSkillsBlock(text) {
  const match = text.match(
    /^([\s\S]*?\n\n)(- [\s\S]+?)(\n<\/system-reminder>\s*)$/
  );
  if (!match) return text;
  const [, header, entriesText, footer] = match;
  const entries = entriesText.split(/\n(?=- )/);
  entries.sort();
  return header + entries.join("\n") + footer;
}

function stripSessionKnowledge(text) {
  return text.replace(
    /\n<session_knowledge[^>]*>[\s\S]*?<\/session_knowledge>/g,
    ""
  );
}

/**
 * On EVERY call, scan the entire message array for the LATEST relocatable
 * blocks and ensure they are in messages[0]. This matches fresh session
 * behavior where attachments are always prepended to messages[0].
 */
function normalizeResumeMessages(messages) {
  if (!Array.isArray(messages) || messages.length < 2) return messages;

  let firstUserIdx = -1;
  for (let i = 0; i < messages.length; i++) {
    if (messages[i].role === "user") {
      firstUserIdx = i;
      break;
    }
  }
  if (firstUserIdx === -1) return messages;

  const firstMsg = messages[firstUserIdx];
  if (!Array.isArray(firstMsg?.content)) return messages;

  // FIX: Check if ANY relocatable blocks are scattered outside first user msg.
  // The old check (firstAlreadyHas → skip) missed partial scatter where some
  // blocks stay in messages[0] but others drift to later messages (v2.1.89+).
  let hasScatteredBlocks = false;
  for (let i = firstUserIdx + 1; i < messages.length && !hasScatteredBlocks; i++) {
    const msg = messages[i];
    if (msg.role !== "user" || !Array.isArray(msg.content)) continue;
    for (const block of msg.content) {
      if (isRelocatableBlock(block.text || "")) {
        hasScatteredBlocks = true;
        break;
      }
    }
  }
  if (!hasScatteredBlocks) return messages;

  // Scan ALL user messages (including first) in reverse to collect the LATEST
  // version of each block type. This handles both full and partial scatter.
  const found = new Map();

  for (let i = messages.length - 1; i >= firstUserIdx; i--) {
    const msg = messages[i];
    if (msg.role !== "user" || !Array.isArray(msg.content)) continue;

    for (let j = msg.content.length - 1; j >= 0; j--) {
      const block = msg.content[j];
      const text = block.text || "";
      if (!isRelocatableBlock(text)) continue;

      let blockType;
      if (isSkillsBlock(text)) blockType = "skills";
      else if (isMcpBlock(text)) blockType = "mcp";
      else if (isDeferredToolsBlock(text)) blockType = "deferred";
      else if (isHooksBlock(text)) blockType = "hooks";
      else continue;

      if (!found.has(blockType)) {
        let fixedText = text;
        if (blockType === "hooks") fixedText = stripSessionKnowledge(text);
        if (blockType === "skills") fixedText = sortSkillsBlock(text);

        const { cache_control, ...rest } = block;
        found.set(blockType, { ...rest, text: fixedText });
      }
    }
  }

  if (found.size === 0) return messages;

  // Remove ALL relocatable blocks from ALL user messages (both first and later)
  const result = messages.map((msg) => {
    if (msg.role !== "user" || !Array.isArray(msg.content)) return msg;
    const filtered = msg.content.filter((b) => !isRelocatableBlock(b.text || ""));
    if (filtered.length === msg.content.length) return msg;
    return { ...msg, content: filtered };
  });

  // FIX: Order must match fresh session layout: deferred → mcp → skills → hooks
  const ORDER = ["deferred", "mcp", "skills", "hooks"];
  const toRelocate = ORDER.filter((t) => found.has(t)).map((t) => found.get(t));

  result[firstUserIdx] = {
    ...result[firstUserIdx],
    content: [...toRelocate, ...result[firstUserIdx].content],
  };

  return result;
}

// --------------------------------------------------------------------------
// Tool schema stabilization (Bug 2 secondary cause)
// --------------------------------------------------------------------------

function stabilizeToolOrder(tools) {
  if (!Array.isArray(tools) || tools.length === 0) return tools;
  return [...tools].sort((a, b) => {
    const nameA = a.name || "";
    const nameB = b.name || "";
    return nameA.localeCompare(nameB);
  });
}

// --------------------------------------------------------------------------
// Debug logging
// --------------------------------------------------------------------------

import { appendFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";

const DEBUG = process.env.CACHE_FIX_DEBUG === "1";
const LOG_PATH = join(homedir(), ".claude", "cache-fix-debug.log");

function debugLog(...args) {
  if (!DEBUG) return;
  const line = `[${new Date().toISOString()}] ${args.join(" ")}\n`;
  try { appendFileSync(LOG_PATH, line); } catch {}
}

// --------------------------------------------------------------------------
// Fetch interceptor
// --------------------------------------------------------------------------

const _origFetch = globalThis.fetch;

globalThis.fetch = async function (url, options) {
  const urlStr = typeof url === "string" ? url : url?.url || String(url);

  const isMessagesEndpoint =
    urlStr.includes("/v1/messages") &&
    !urlStr.includes("batches") &&
    !urlStr.includes("count_tokens");

  if (isMessagesEndpoint && options?.body && typeof options.body === "string") {
    try {
      const payload = JSON.parse(options.body);
      let modified = false;

      debugLog("--- API call to", urlStr);
      debugLog("message count:", payload.messages?.length);

      if (payload.messages) {
        if (DEBUG) {
          let firstUserIdx = -1, lastUserIdx = -1;
          for (let i = 0; i < payload.messages.length; i++) {
            if (payload.messages[i].role === "user") {
              if (firstUserIdx === -1) firstUserIdx = i;
              lastUserIdx = i;
            }
          }
          if (firstUserIdx !== -1) {
            const firstContent = payload.messages[firstUserIdx].content;
            const lastContent = payload.messages[lastUserIdx].content;
            debugLog("firstUserIdx:", firstUserIdx, "lastUserIdx:", lastUserIdx);
            debugLog("first user msg blocks:", Array.isArray(firstContent) ? firstContent.length : "string");
            if (Array.isArray(firstContent)) {
              for (const b of firstContent) {
                const t = (b.text || "").substring(0, 80);
                debugLog("  first[block]:", isRelocatableBlock(b.text) ? "RELOCATABLE" : "keep", JSON.stringify(t));
              }
            }
            if (firstUserIdx !== lastUserIdx) {
              debugLog("last user msg blocks:", Array.isArray(lastContent) ? lastContent.length : "string");
              if (Array.isArray(lastContent)) {
                for (const b of lastContent) {
                  const t = (b.text || "").substring(0, 80);
                  debugLog("  last[block]:", isRelocatableBlock(b.text) ? "RELOCATABLE" : "keep", JSON.stringify(t));
                }
              }
            } else {
              debugLog("single user message (fresh session)");
            }
          }
        }

        const normalized = normalizeResumeMessages(payload.messages);
        if (normalized !== payload.messages) {
          payload.messages = normalized;
          modified = true;
          debugLog("APPLIED: resume message relocation");
        } else {
          debugLog("SKIPPED: resume relocation (not a resume or already correct)");
        }
      }

      if (payload.tools) {
        const sorted = stabilizeToolOrder(payload.tools);
        const changed = sorted.some(
          (t, i) => t.name !== payload.tools[i]?.name
        );
        if (changed) {
          payload.tools = sorted;
          modified = true;
          debugLog("APPLIED: tool order stabilization");
        }
      }

      if (payload.system && payload.messages) {
        const fix = stabilizeFingerprint(payload.system, payload.messages);
        if (fix) {
          payload.system = [...payload.system];
          payload.system[fix.attrIdx] = {
            ...payload.system[fix.attrIdx],
            text: fix.newText,
          };
          modified = true;
          debugLog("APPLIED: fingerprint stabilized from", fix.oldFingerprint, "to", fix.stableFingerprint);
        }
      }

      if (modified) {
        options = { ...options, body: JSON.stringify(payload) };
        debugLog("Request body rewritten");
      }
    } catch (e) {
      debugLog("ERROR in interceptor:", e?.message);
    }
  }

  return _origFetch.apply(this, [url, options]);
};

</details>

Notes

  • Despite v2.1.90 changelog claiming the resume cache regression is fixed, debug observation (without preload) confirms skills blocks still scatter on resume and the first resume still fully misses the fresh session cache (cache_creation ≈ 7,400 on both fresh and first resume)
  • The SIY() function (source's extractFirstMessageText) picks the first type==="text" block from the first user message — when messages[0] starts with a <system-reminder> relocatable block, that block's text becomes the fingerprint input instead of the real user text, which is exactly why stabilizeFingerprint() is needed
  • hooks blocks never appeared in any test despite hooks being configured and firing (PostToolUse hooks with Edit/Write matcher across multiple sessions). The isHooksBlock matcher is retained as defensive code with a conservative substring(0, 200) check since we have no real sample to define a startsWith pattern
  • Fingerprint hardcoded values (FINGERPRINT_SALT = "59cf53e54c78", FINGERPRINT_INDICES = [4, 7, 20]) verified unchanged in v2.1.90 source (RIY, [4,7,20], SHA256 slice(0,3))
  • On resume, a "The following deferred tools are no longer available (their MCP server disconnected...)" system-reminder may appear alongside scattered blocks. The startsWith matcher correctly ignores it ("are no longer available""are now available"), whereas the old .includes("deferred tools") would have false-positive matched it — relocating a resume-only block into messages[0] would break the cache prefix since fresh sessions never contain it
cnighswonger · 3 months ago

These fixes have had a massive impact on stopping the traumatic token bleeds we were experiencing. Thanks to @jmarianski, @VictorSun92, and all who are testing this fix.

jmarianski · 3 months ago

@cnighswonger I've read your original message, couple hours ago wanted to point out that your findings aligned with mine. After tests right now I was able to ALSO confirm @VictorSun92 new findings, as if version 2.1.90 suddenly changed during the day.

I've made a script that was supposed to check the findings align, however... I can't, cause there are server issues that prevent identical requests from being cached :D And even discounting the tools swapping positions.

Anyway, here's the script. Supposedly it should work for both -p mode and regular /resume. However, currently it is broken 100% of time.

test_resume_cache.sh

Mind you, if you continue session further cache will somehow start working. No clue why, perhaps server cache needs a while to adapt to sessions being sent from the same computer?

cnighswonger · 3 months ago

@jmarianski Thanks for the analyzer script. It is quite helpful. Hopefully Anthropic will get the right fix in place before too much longer. In the meantime, I worked with Claude and Codex (GPT) to debug the "broken 100% of the time" issue. Here is what we discovered and the solution we arrived at which seems to test out on our end. The revised script is attached:

test_resume_cache.sh

Why the current script shows BROKEN

In print, interactive, and slash mode, each resume phase starts a new Claude Code process, sends one prompt, and exits. That means the phase summary is mostly judging the first call made by each resumed process.

That is important and useful data, but it does not show whether cache reads begin to recover on subsequent API calls after the resumed process is already active.

What multi mode adds

To measure that second effect, we added a multi mode to your script. Instead of sending one message and exiting, it sends multiple messages within the same resumed process and then inspects how cache_read and cache_creation change across those calls.

That gives us a way to separate:

  • first-call resume behavior
  • later within-process behavior after resume

What our run suggests

Using:

MSGS_PER_RESUME=2 ./test_resume_cache.sh multi npx 1

we saw:

    #        Phase  cache_read cache_create  input output     total
  ─────────────────────────────────────────────────────────────────
    0        Fresh      20,936        4,404      3     95    25,343
    1        Fresh      25,340          331      1     95    25,672
                                                       ── resume ──
    2    Resume #1      20,936        4,837      3     95    25,776
    3    Resume #1      20,936        5,277      1    210    26,214
    4    Resume #1      26,213          216      3     77    26,432

The first resumed call still looks cold, but later calls in the same resumed process show cache_read increasing and cache_creation dropping. That suggests some cache recovery can happen after the resumed process is already in flight, even when the transition summary still marks the resume boundary as BROKEN.

That's not intended to be a firm root-cause conclusion: only a suggestion.

Suggested interpretation

I think the safest takeaway is:

  • your script is correctly showing that the first call in a resumed process is still expensive in these runs
  • that same script can be extended to show that later calls within the resumed process may behave differently

One analyzer caveat

One thing I would keep in mind is that the current phase grouping assumes API calls divide fairly evenly across phases. That is reasonable for single-call modes, but it gets less precise in multi mode because one process can produce a variable number of API calls depending on tool use and turn shape.

The multi run is useful evidence that later within-process behavior may improve, but not proof of a complete explanation by itself.

Usage

./test_resume_cache.sh multi npx 2
MSGS_PER_RESUME=5 ./test_resume_cache.sh multi npx 1
./test_resume_cache.sh all npx 2

Note on the matcher fix

One update in the revised interceptor still looks especially important: switching the deferred-tools matcher away from a broad .includes("deferred tools") check. On resume, a "The following deferred tools are no longer available..." reminder can appear, and a broad substring match can treat that as a relocatable block when it is actually resume-only state.

Using a stricter header match avoids pulling that reminder into messages[0], which would make the fresh vs resumed prefixes diverge unnecessarily.

jmarianski · 3 months ago

Thats bonkers @cnighswonger ! However, the main point of my script was to catch if the resumption breaks cache, not if the next messages in resume chain can work. As I've seen they do work, so it wasn't part of my research. However it's great to have it like that.

cnighswonger · 3 months ago
However, the main point of my script was to catch if the resumption breaks cache, not if the next messages in resume chain can work.

@jmarianski Apologies for the over-focused response. 🤪

We are thinking that the cache entries from the fresh context either expire (TTL) or are scoped to that connection and simply gone on a resume. But that's a guess based on noticing that allowing a context to sit open and idle for some length of time and then turning it once results in a similar token bleed as a full exit/resume. We've not done any testing to try to determine the effective TTL of an idle context.

We also think that this "feature" may have always been present, but has now become amplified by the 1M token context "enhancement." Here is a bit of a convo with the Claude agent we have working this issue:

❯ Recently the context window was increased from 200K to 1M. Could that have effectively amplified the "cache entries either expire (TTL) or are scoped to that connection and simply gone" assuming that expiry/scope issue/feature has always existed? ● Good question. Yes, absolutely. If the TTL/connection-scoped behavior has always existed, the 200K→1M jump would make it dramatically more painful: At 200K context: A cold cache miss on resume recreates ~200K tokens. Noticeable but manageable — maybe 2-3% quota on Max plan. At 1M context: A cold cache miss recreates up to ~1M tokens. That's potentially 14%+ quota in a single turn, which is what we measured on your 600K sessions. The underlying behavior (cache not surviving cross-process) may have always been there, but at 200K nobody really noticed because: - Sessions were shorter, so there was less to re-cache - The absolute cost of a miss was 5x smaller - Sessions hit the context limit and got compacted before growing large enough to hurt The 1M window lets sessions accumulate massive context that stays — which is the whole point — but it also means the penalty for any cache miss scales proportionally. A bug that costs you $0.05 at 200K costs you $0.25 at 1M. It would also explain the timing of the complaints. Issues #34629 and #40524 are recent, but the scatter/fingerprint bugs may have existed for a while. They just didn't matter much until sessions could grow 5x larger and the per-miss cost crossed the pain threshold
weilhalt · 3 months ago

Update: I built a monitoring tool for this problem.

After dealing with the budget drain for weeks, I built BudMon — a real-time desktop dashboard that captures rate-limit headers from Claude Code API responses and visualizes quota utilization, burn rate, and costs.

What it shows:

  • 5h / 7d quota bars with warning/alarm thresholds
  • Burn rate (% per hour) and estimated time to exhaustion
  • Reserve: will you run out before reset?
  • Per-request and cumulative token costs (Opus/Sonnet/Haiku presets)
  • Cache ratio sparkline

It works by loading a read-only Node.js fetch interceptor via NODE_OPTIONS — captures response headers and SSE token usage, never modifies requests. No API keys needed, all data stays local.

Install: pip install budmon && budmon --setup

GPL-3.0, Python 3.10+, tkinter, no external dependencies.

This doesn't fix the underlying budget drain, but at least you can see what's happening in real-time instead of being surprised by "quota exhausted".

jmarianski · 3 months ago

@cnighswonger Just a reminder, there is no session per-se in CC, as all the requests are HTTP by definition. It can be maintained by some form of headers, cookies or simply hashing part of the body, but requests are stateless in nature. So no scoping by connection. Unless they also simulate connections by this one haiku request on subsequent reconnects... Either way, no way of knowing.

cnighswonger · 3 months ago
Just a reminder, there is no session per-se in CC, as all the requests are HTTP by definition. It can be maintained by some form of headers, cookies or simply hashing part of the body, but requests are stateless in nature. So no scoping by connection. Unless they also simulate connections by this one haiku request on subsequent reconnects... Either way, no way of knowing.

@jmarianski Right, but prompt caching is in play I think. (See here.) Our apps that interact with the API are very prompt-caching aware in an effort to minimize token burn. It appears that multiple bugs in Claude Code are breaking the prompt-caching. The doc referenced also states there is either a 5 min TTL or 1 hr TTL depending on tier. In practice we're not sure exactly what it is at this point. Maybe those numbers are correct.

From the doc it appears that there are two constraints on the prompt: organization and turn time. So cross-process cache sharing should work within the 5-minute (or 1-hour) TTL if the prefix is identical.

I may not be on the same page, so please feel free to let me know if that's the case. Our discussion has been a helpful learning process for me in many ways.

cnighswonger · 3 months ago

For kicks I had an agent run an analysis of the cache performance of an ongoing, lengthy conversation of another agent that is doing code work on the UI of an app we developed and maintain. All of our agents are running the interceptor described above with @jmarianski enhancements. Here are the results (using the previously referenced prompt-cache doc):

Overall: 98.2% cache hit ratio across 7,094 API calls. That's excellent. The pattern is unmistakable: 1. Every bust correlates with a time gap. Look at the gap values: - 78 min, 1014 min (17hr), 166 min, 91 min, 99 min, 118 min, 655 min (11hr), 417 min (7hr), 68 min, 768 min (13hr), 249 min... - Every single one is well past the 5-minute default TTL 2. Recovery is immediate. After every bust, the very next call shows cache_read jumping back to near-total and cache_creation dropping to near-zero. The prefix is stable — the interceptor is doing its job. It's purely a TTL expiry issue. 3. The baseline on busts is ~6,527 or ~8,788. That's the system prompt + tools on 1-hour TTL. The docs confirm 1h TTL blocks persist longer. Messages are on 5-minute TTL, so they expire first. 4. The early "busts" at calls 19-22 (gap=1-29s) are the initial session warmup — the context is growing rapidly as tool use adds blocks, so cache_creation is high. Not real busts. 5. No within-session busts after the interceptor is working. Between time gaps, the ratio is 99.9-100%. The interceptor completely eliminates within-process cache instability. Bottom line: Your cache behavior is healthy. The only cost is the unavoidable cold start when you come back after idle periods exceeding the 5-minute TTL. At 500k+ context, each cold start recreates ~490k tokens. With the docs confirming cache is org-scoped (not connection-scoped), a 1-hour TTL on messages would eliminate most of these — but Claude Code apparently uses 5-minute TTL for message blocks.
TigerKay1926 · 3 months ago

Confirming this issue — with data from prompt cache analysis.

Max 5x plan, Windows, Opus 4.6 (1M context), v2.1.92.

I tracked ephemeral_1h_input_tokens and ephemeral_5m_input_tokens across ~50 recent sessions. The 1-hour prompt cache TTL silently stopped working around April 1:

| Date | Version | 1H cache hits | 5M cache creates | Status |
|--------------|------------|----------------------|------------------|---------------------------|
| Mar 31 | v2.1.86 | 206/212 calls | 0 | ✅ Normal |
| Apr 1 03:36 | v2.1.86 | 206/212 calls | 0 | ✅ Last working session |
| Apr 1 10:57 | v2.1.86 | 0/14 calls | 14 | ❌ Broken (same version!) |
| Apr 1–4 | v2.1.86→92 | 0 across 39 sessions | All 5M | ❌ Never recovered |

Key finding: the version didn't change at the transition point. Both the last working session and the first broken session were on v2.1.86. This is a server-side change, not a client regression.

The 1H TTL appears to be gated server-side. It looks like the gating was modified around April 1, effectively removing users from the 1H cache tier. Without 1H TTL, every gap >5 minutes triggers a full cache rebuild of the system prompt (~160K tokens), at roughly 12.5x the cost of a cache read.

This directly explains the sudden quota burn many of us are experiencing — same workload, same version, but cache efficiency dropped dramatically overnight.

jmarianski · 3 months ago

@TigerKay1926 Have you checked your feature flags? If you proxy your request you will be able to do so. I'm currently nowhere near a working pc, so I can't check, but from my research the 1h cache is preserved (you will notice you can continue conversation without turning application off (after you've stopped speaking for at least 5 minutes) and you will still not get additional costs).

Additional costs do apply after you relaunch application and context needs to be rebuilt, even if less than 5 minutes has elapsed between calls. However I haven't tested since 2.1.90.

cnighswonger · 3 months ago

@TigerKay1926 This is the missing piece. Our cache performance analysis (posted above) showed 98.2% hit ratio with busts occurring exclusively on time gaps — we attributed it to the 5-minute ephemeral TTL being the operative constraint but couldn't explain why the 1-hour tier wasn't covering the system prompt prefix. Your data pinpointing the tengu_prompt_cache_1h_config flag change on April 1 answers that directly.

It also explains our earlier observation that the baseline on cold starts was ~6,500–8,800 tokens — that was the system prompt + tools that used to stay on the 1-hour TTL. Now they expire with everything else at 5 minutes.

For our use cases, we've been mitigating with:

• /compact before idle periods exceeding ~5 minutes
• Cron intervals ≤4 minutes for background agents to keep cache warm between fires

If the 1-hour tier is restored, most of the operational overhead disappears. If it isn't, the 5-minute window becomes a permanent constraint for anyone running long or periodic sessions.

cnighswonger · 3 months ago

@jmarianski @TigerKay1926

Follow-up after checking our own data more carefully.

@jmarianski is right — the 1-hour TTL is still active on our account. We checked the cache_creation breakdown across three active sessions and 100% of cache_creation_input_tokens are categorized as ephemeral_1h_input_tokens, zero ephemeral_5m. The usage object includes the breakdown:

"cache_creation": {
  "ephemeral_1h_input_tokens": 5600,
  "ephemeral_5m_input_tokens": 0
}

Our cold starts correlate with gaps >60–80 minutes — consistent with a 1-hour TTL, not 5 minutes:

| Gap | cr% | cr tokens | Notes |
| ------ | ----- | --------- | ------------------------------ |
| 153.6m | 91.3% | 196,374 | >1h — TTL expired |
| 76.8m | 69.4% | 42,518 | Just past 1h boundary |
| 164.5m | 89.4% | 157,540 | >1h |
| 18.3m | 44.7% | 17,041 | Process restart (power outage) |
| 168.3m | 40.7% | 14,437 | >1h |
| 630.5m | 43.3% | 16,049 | Overnight idle |
| 79.1m | 100% | 39,550 | Just past 1h boundary |

Every real bust is at 77+ minutes. The 18-minute outlier was a process restart, not TTL expiry.

So the picture may be this: @TigerKay1926's account was removed from the 1-hour tier via the tengu_prompt_cache_1h_config flag, but it hasn't been removed universally. This makes the impact uneven across users — some are on 1h TTL and experiencing normal cache behavior, others were downgraded to 5m and seeing the dramatic quota burn.

Our previous reply's mitigation advice (cron ≤4min, /compact before idle) applies specifically to the 5-minute TTL scenario. For those still on the 1-hour tier, the effective idle threshold before a cold start is ~60 minutes, which is far more manageable.

jmarianski · 3 months ago

FYI minified code also had hints that on "extra usage" TTL of cache is 5 minutes. Perhaps that's also where the distinction may come from between users.

cnighswonger · 3 months ago
FYI minified code also had hints that on "extra usage" TTL of cache is 5 minutes. Perhaps that's also where the distinction may come from between users.

Indeed... we are looking at that just now. It triggers an insane burn rate. More in a few.

cnighswonger · 3 months ago

New finding: exceeding 100% of the 5-hour quota triggers a TTL downgrade from 1h to 5m.

We observed this live today. While our account normally operates on the 1-hour TTL tier (all cache_creation tokens categorized as ephemeral_1h_input_tokens), when we crossed 100% of the 5-hour quota window, the API response immediately switched to reporting ephemeral_5m_input_tokens exclusively — no ephemeral_1h tokens at all.

This is a double penalty:

  1. Overage pricing — tokens cost significantly more beyond 100%
  2. TTL downgrade — cache expires after 5 minutes instead of 60, causing far more frequent cold starts and even more cache_creation burn

The transition happens at the exact quota boundary. Once the 5-hour window resets and utilization drops back below 100%, the 1h TTL returns.

We detect this dynamically by checking the cache_creation sub-object in the usage response. If you're on the 1h tier and suddenly seeing 5-minute cache behavior, check your anthropic-ratelimit-unified-5h-utilization header — you may have crossed into overage.

This makes quota management even more critical for Max plan users: hitting overage doesn't just cost more per token, it actively degrades your caching efficiency, creating a runaway feedback loop.

The $100 Extra Usage credit gesture was nice, but we didn't even feel it. It bought us about 30 min of usage. On our subscription, it buys us a month.

cnighswonger · 3 months ago

TLDR; Cache management matters and should be a user's top priority.

A few moments ago one of our agents busted cache and did not notice it. By the time we noticed it we went from 0% usage for the current 5hr limit to 70%. I think it was about three or four turns. When queried about the burn, the agent caught itself and compacted immediately. Burn rate returned to "normal."

cnighswonger · 3 months ago

Follow-up on the overage TTL downgrade — we found the mechanism, and confirmed it's server-enforced.

Anthropic's Extra Usage FAQ states that overage billing uses "standard API rates." And the prompt caching pricing table has a footnote: "Prompt caching pricing reflects 5-minute TTL."

So the TTL downgrade from 1h to 5m at the 100% quota boundary isn't a separate penalty — it's a side effect of being routed to the standard API billing path, which only supports 5-minute cache TTL. The 1-hour TTL is a subscription-tier feature that doesn't carry over into Extra Usage.

We tested whether client-side TTL injection could override this. Claude Code already sends {"type": "ephemeral", "ttl": "1h"} on all its cache_control blocks. At 99% quota, the API honors it — response shows ephemeral_1h_input_tokens only. At 100% quota, the API silently ignores the "ttl": "1h" request and returns ephemeral_5m_input_tokens only. The downgrade is server-side and not client-controllable.

This also reveals a pricing difference most users won't notice:

| TTL Tier | Cache Write Cost (Opus 4.6) | Multiplier |
| -------- | --------------------------- | ---------- |
| 5-minute | $6.25/MTok | 1.25x base |
| 1-hour | $10.00/MTok | 2.00x base |

So in-quota subscription users are paying 60% more per cache write token for the 1h TTL — but getting dramatically fewer cold starts. On Extra Usage, you pay the lower 5m write rate but write far more often because the cache expires 12x faster.

For a 200k token context, the math works out roughly:

  • In-quota (1h TTL): One cold start per hour at $10/MTok = $2.00/hr in cache writes
  • Extra Usage (5m TTL): One cold start every 5 min at $6.25/MTok = $75.00/hr in cache writes if you're idle between turns

The runaway feedback loop is now fully explained: overage → standard API path → 5m TTL → frequent cache rebuilds → more tokens burned → deeper into overage. There is no client-side mitigation for the TTL downgrade itself — the only defense is staying under 100% quota.

This once again stresses the need to understand Anthropic's caching mechanism as well as their billing policy in order to maximize cost effectiveness.

Its not even clear that Anthropic understands how it works in light of the massive Claude Code failures of the past month(s). They are probably bleeding cash at a rate that requires near real time price algo adjustments due to the massive COGS associated with AI operations at this point in history. Success can be unsupportable. Just sayin.

junaidtitan · 3 months ago

100% after 2 hours of light work means session bloat is inflating every API call. Cozempic v1.6.11's guard daemon keeps context lean with 4-tier pruning — 18 strategies strip progress ticks, stale tool results, thinking blocks, and other dead weight before each turn.

pip install cozempic && cozempic init

TigerKay1926 · 3 months ago

Update: 7 consecutive days, 344+ API calls, zero ephemeral_1h hits — Max 5x plan

Following up on my 2026-04-04 comment with fresh data after the weekly quota refresh.

@cnighswonger — thank you for isolating the overage-downgrade mechanism. Your finding that crossing 100% of the 5h quota routes requests to the standard API billing path (5m TTL only) is by far the clearest explanation posted in this thread, and the prompt-caching pricing footnote you cited seals it.

But my account presents a case the mechanism does not fully explain.

Data (Max 5x, Opus 4.6 1M context, CC v2.1.89-2.1.93, Windows)

| Date (UTC) | API calls | ephemeral_1h hits | ephemeral_5m hits | 5h quota state |
|---|---|---|---|---|
| 2026-04-01 (before 10:57 UTC) | 212 | 206 | 6 | in-quota |
| 2026-04-01 (after 10:57 UTC) | ~80 | 0 | all | in-quota |
| 2026-04-02 to 04-06 | ~500 | 0 | all | mostly in-quota |
| 2026-04-07 (weekly quota refresh day) | 344 | 0 | 344 | fresh 0% quota at window start |

Between 2026-04-01 and today, I have not observed a single non-zero ephemeral_1h_input_tokens value. Not once across ~900 calls. The 5m tier itself is working normally (38 KB+ cache hits inside 5-minute windows), so this is not a caching outage — it is specifically the 1h tier that is gone on this account.

2026-04-07 is the critical data point. If the overage-downgrade were the only factor, a fresh weekly quota window starting at 0% utilization should have restored the 1h TTL immediately. It did not. 344 calls into the new cycle, still 0/344.

Questions for Anthropic

  1. Does the Max 5x plan include the 1-hour prompt cache TTL at all? If 1h TTL is implicitly gated to higher plan tiers, that needs to be stated in the plan comparison docs. Prompt caching is currently marketed as a subscription benefit with zero disclosure that the 1h tier may depend on plan level. Quietly removing a documented feature from a paid plan is not acceptable.
  2. If Max 5x does include 1h TTL, what additional state — beyond 5h quota utilization — can pin an account to the 5m tier across multiple quota cycles? A sticky flag after one overage event? A multi-day cooldown? A configuration change that took effect around 2026-04-01?
  3. Why does the API silently accept {"type": "ephemeral", "ttl": "1h"} in cache_control blocks and then ignore it server-side, with no error, no warning header, no documented downgrade conditions? The downgrade mechanism had to be reverse-engineered from the usage object by users in this thread because no official documentation of it exists.
  4. What is the remediation path for an account stuck on 5m TTL despite the overage mechanism not applying? Support ticket? Plan upgrade? Wait indefinitely?

Cost impact on Max 5x

With a ~160k token system prompt being rebuilt every 5 minutes instead of every hour, a single active hour burns roughly 12x the cache_creation tokens the 1h tier would. On Max 5x, that is the difference between a usable workday and hitting the weekly cap within two days. The $100 Extra Usage credit that was issued to some affected users covered about 12 Opus turns on my account — that is a rounding error, not remediation.

This thread is now at 32 comments, multiple independent reverse-engineering efforts by users, and zero official responses since the first report on 2026-04-01. It has been six days. Please respond. /cc @anthropics maintainers.

cnighswonger · 3 months ago

Adding our data from another Max 5x account — our experience with the TTL transition has been different, which may point to more than one mechanism at play.

We've observed the 1h ↔ 5m transition in both directions, tied cleanly to the 5h quota boundary:

On 2026-04-04, we tested this directly. At 99% of the 5h quota, the API honored ttl: "1h" and returned ephemeral_1h_input_tokens. At 100%, it silently switched to ephemeral_5m_input_tokens only. When the 5h window rolled and utilization dropped back below 100%, the 1h TTL returned. We've crossed back and forth multiple times since then — the transition has been immediate and reversible every time on our account.

Session-level data across multi-day sim runs:

| Session | 5h Quota State | Cache Write TTL | Cache Hit Rate |
|---|---|---|---|
| Apr 3 (15h run) | Over 100% | All ephemeral_5m | 5.3% |
| Apr 5 (8h run) | Under 100% | All ephemeral_1h | 19.7% |

Same codebase, same account, same plan. The Apr 5 session on 1h TTL had 6 of 8 Sonnet hours with zero cache writes (pure cache reads), consistent with the 1h TTL working as intended. We've also validated our steady-state 1h behavior independently — cold starts correlate at the 77+ minute idle boundary, never at 5 minutes.

This suggests there may be more than one mechanism controlling TTL assignment. The quota-driven downgrade we've documented is one — but your Apr 7 data (0/344 ephemeral_1h on a fresh 0% weekly quota) clearly rules that out as the sole explanation for your account. Something else is holding your account on the 5m tier, independent of quota state. It could be an account-level flag, a configuration change that coincided with your Apr 1 cutoff, or a mechanism we haven't identified yet. The fact that our accounts behave differently under similar conditions makes the case that this isn't a single root cause.

A note on documentation: We checked the prompt caching docs, pricing page, service tiers, and Extra Usage FAQ. None of them document any conditions under which the 1h TTL is downgraded, restricted by plan, or otherwise unavailable. The API silently accepts {"type": "ephemeral", "ttl": "1h"} and ignores it server-side with no error or warning. Everything in this thread about the downgrade mechanism has been reverse-engineered from the usage response object by users.

likeahoss · 3 months ago

@claude @anthropic we're waiting, patiently. Downgraded my account until it's fixed. Issue some type of acknowledgement, please.

Camj78 · 3 months ago

Yeah this lines up with what a few people have been noticing lately.

Once you cross that usage threshold, it’s not just “more usage”, the system actually shifts how caching behaves (shorter TTL), so the same prompts stop benefiting from reuse and effectively get reprocessed more often.

That’s why it suddenly feels like things are burning through quota way faster even with similar workflows.

Have you noticed if it ramps up right after hitting 100%, or does it feel gradual?

cnighswonger · 3 months ago

Follow-up: confirmed bidirectional TTL transition on window reset (Apr 8)

One of our agents briefly crossed 100% Q5h today. Extra usage balance dropped $0.07. When the 5h window reset moments later, Q5h went to 0% and TTL immediately recovered to 1h — confirmed via our interceptor's new TTL tier detection (ephemeral_1h_input_tokens from the response usage object).

This appears to be the opposite of @TigerKay1926 Apr 7 experience where 344 calls on a fresh 0% quota produced zero ephemeral_1h hits. Same plan (Max 5x), same week. To this point, our account has always shown immediate bidirectional transitions at the quota boundary, whereas theirs has been stuck on 5m since Apr 1 regardless of quota state.

At this point the data strongly suggests there's account-level state beyond quota utilization controlling TTL assignment. Whether it's a flag, a config cohort, or a server-side routing decision, it's not something visible to us in the API response.

Vergil824 · 3 months ago

Guys, I just uninstalled the native installer and reinstalled using the npm package.

I also asked Claude Code to write a script to log cache activity. From what I can see, caching works properly in the npm version. I had already noticed cache issues in the native version before, so this seems consistent.

Also, another user wrote a script to enforce the 1-hour cache. You can run it with:

node claude-1h-cache-patch.js

Note: this only works with the npm version.

====================================================
POST https://api.anthropic.com/v1/messages?beta=true
====================================================
  [request] no cache_control blocks found
  [response] cache_creation_input_tokens=0  cache_read_input_tokens=0
  [response] ephemeral_1h_input_tokens=0  ephemeral_5m_input_tokens=0
  ==> no cache activity on this call
127.0.0.1:50461: POST https://api.anthropic.com/v1/messages?beta=true
              << 200 OK 560b
[15:24:31.740]

====================================================
POST https://api.anthropic.com/v1/messages?beta=true
====================================================
  [request] cache_control ttl values (count=2): ['1h']
  [response] cache_creation_input_tokens=10711  cache_read_input_tokens=15808
  [response] ephemeral_1h_input_tokens=10711  ephemeral_5m_input_tokens=0
  ==> 1h cache is ACTIVE (extended TTL in use)
127.0.0.1:50463: POST https://api.anthropic.com/v1/messages?beta=true
              << 200 OK 582b

claude-1h-cache-patch.js

cnighswonger · 3 months ago

@Vergil824 Good to see independent confirmation that npm works better than the standalone binary for caching. The standalone has Zig-level attestation that bypasses Node.js entirely, which means no client-side cache fixes are possible on that path.

Your 1h cache patch is doing similar work to our claude-code-cache-fix interceptor (npm install -g claude-code-cache-fix), which also enforces cache stability but additionally fixes block scatter on resume, fingerprint instability, tool reordering, and image carry-forward stripping. v1.5.1 also includes a cost report tool for quantifying the savings.

The cache log output you're showing is useful data — the progression from no cache activity on call 1 to 1h cache active on call 2 is exactly the expected behavior when the prefix stabilizes.

TigerKay1926 · 3 months ago

Further diagnostic data — account-level flag dump suggests non-empty allowlist with a scope mismatch

Following up on my earlier comments. After installing @cnighswonger's claude-code-cache-fix interceptor (npm: claude-code-cache-fix@1.5.1), I was able to log the feature flag state my account receives from the backend on first API call. Sharing the relevant portion in case it helps others diagnose:

"prompt_cache_1h_config": {
  "allowlist": [
    "repl_main_thread*",
    "sdk",
    "auto_mode"
  ]
}

My account still registers zero ephemeral_1h_input_tokens organically, despite the allowlist not being empty. I had previously assumed the allowlist was being pushed empty to my account, which would explain the extended dry spell. That hypothesis appears wrong — the allowlist is populated with three patterns.

A more consistent explanation is that the querySource / scope value Claude Code passes on interactive CLI sessions doesn't match any of these three patterns:

  • repl_main_thread* — prefix suggests SDK REPL contexts
  • sdk — literal SDK calls
  • auto_mode — likely non-interactive automation paths

If interactive CLI sessions route through a querySource value that isn't in the allowlist, the gating function returns false and the client never requests ttl: "1h" on the wire. From the outside this looks indistinguishable from a server-side downgrade in the usage object.

Questions for @anthropic:

  1. What querySource / scope value does Claude Code pass on interactive CLI sessions (i.e., claude invoked directly, not claude -p "...", not via SDK)?
  2. Is that value supposed to be in the 1h cache allowlist? If yes, the current allowlist shown above is missing it.
  3. If the patterns above are intentional, is there a reason interactive CLI sessions are excluded from 1h cache eligibility while SDK and automation modes are included?

This would also help explain the diverging reports in this thread from Max 5x users — workflows that happen to route through sdk or repl_main_thread* would match the allowlist, while interactive CLI users would fall outside it entirely.

Separately: thanks to @cnighswonger for the interceptor. The flag dump feature surfaces backend state that would otherwise be opaque, and end users should not need a third-party tool to understand why their cache is broken.

cnighswonger · 3 months ago

@TigerKay1926 Excellent diagnostic work — the allowlist discovery is a significant find.

We've confirmed the gating is entirely client-side. From the source (src/services/api/claude.ts), the should1hCacheTTL() function checks your querySource against the GrowthBook allowlist patterns. If it doesn't match, the client simply omits ttl: "1h" from the cache_control blocks — the request goes out as {"type": "ephemeral"} which the server defaults to 5m.

The server honors whatever TTL the client requests. So the fix is straightforward: inject ttl: "1h" on any cache_control block that has type: "ephemeral" but is missing the ttl field.

This is now live in v1.6.0 of our interceptor:

npm install -g claude-code-cache-fix@1.6.0

After updating, you should see ephemeral_1h_input_tokens > 0 in your cache stats. The debug log (CACHE_FIX_DEBUG=1) will show APPLIED: 1h TTL injected on N cache_control block(s) when the fix fires.

cc @Vergil824 @jmarianski @bilby91 — if you've been seeing 5m TTL despite being under quota, this may be the same issue. The interceptor now enforces 1h TTL regardless of the client-side gating.

cnighswonger · 3 months ago

Tagging @TigerKay1926 because I think this lands directly on your "stuck 5m TTL" observation.

We dumped the full tool list from Claude Code v2.1.101's outgoing API requests today during a cross-version regression investigation. The new ScheduleWakeup tool added in v2.1.101 has this in its description, verbatim:

## Picking delaySeconds The Anthropic prompt cache has a 5-minute TTL. Sleeping past 300 seconds means the next wake-up reads your full conversation context uncached — slower and more expensive. - Under 5 minutes (60s–270s): cache stays warm. - 5 minutes to 1 hour (300s–3600s): pay the cache miss. Right when there's no point checking sooner — waiting on something that takes minutes to change, or genuinely idle. Don't pick 300s. It's the worst-of-both: you pay the cache miss without amortizing it.

That's Anthropic's own product tooling confirming that the 5-minute TTL is the baseline — and building advice to its own sub-agents around that constraint. Your observation about being stuck on 5m isn't a mystery or a weird edge case: it's the default behavior the design is built around. The 1-hour TTL tier exists but is opt-in via should1hCacheTTL() + the GrowthBook allowlist you identified. If your querySource isn't in the allowlist, you get the 5m default.

This matches exactly what you've been reporting. Wanted to make sure you saw it confirmed from the other side.

A fuller writeup, including a cross-version measurement table (v2.1.81 → v2.1.83 → v2.1.90 → v2.1.101) and the release-timing argument that the March 23 regression is server-side rather than client-side, is at https://veritassuperaitsolutions.com/5-minute-baseline-tools-array/ — the ScheduleWakeup quote is one datum in a broader investigation we ran today. If you're interested, the per-version prefix size table there shows exactly where the 5m/1h gating starts mattering.

Thanks for the original GrowthBook allowlist analysis. It holds up completely against what we measured today, and it was the lead that made this investigation possible.