[BUG] `DISABLE_PROMPT_CACHING_HAIKU` is silently ignored when Haiku is the main model — undocumented main-model guard, absent on `_SONNET`/`_OPUS`

Status Fixed / completed
Reported on v2.1.267
Maintainer reply None cached
Activity 0 comments · opened Sep 10, 2026 · closed Sep 24, 2026

Preflight

  • [x] I searched existing issues — DISABLE_PROMPT_CACHING_HAIKU appears in exactly 6 issues repo-wide (#7095, #8632, #42309, #48082, #48090, #91091) and none reports this behavior.
  • [x] Single bug report.
  • [x] Reproduced on the latest version at time of filing (2.1.267), and on 2.1.247.

Summary

DISABLE_PROMPT_CACHING_HAIKU is documented on three official pages as disabling prompt caching "for Haiku models". In practice it is a no-op whenever Haiku is the selected main model — the flag is silently skipped and the request still writes a prompt cache. DISABLE_PROMPT_CACHING_SONNET and DISABLE_PROMPT_CACHING_OPUS carry no equivalent exemption, so the four documented per-model flags do not behave as one mechanism.

Because the guard fires only when Haiku is the main model, and the main conversation is exactly the bucket that receives the 1-hour TTL by default on a subscription, the failure mode is structurally guaranteed to produce the most expensive possible cache write.

Environment

  • Claude Code 2.1.247 and 2.1.267 (both reproduce)
  • Native installer, macOS 26.6.2, arm64
  • First-party API (no Bedrock/Vertex, ANTHROPIC_BASE_URL unset)
  • Claude subscription

Reproduction

~/.claude/settings.json:

{ "env": { "DISABLE_PROMPT_CACHING_HAIKU": "1" } }

Then /model → Haiku 4.5, and send one short prompt.

Interactive transcript usage from the session that first surfaced this, v2.1.247:

"model": "claude-haiku-4-5-20251001",
"cache_creation_input_tokens": 41576,
"cache_creation": { "ephemeral_1h_input_tokens": 41576, "ephemeral_5m_input_tokens": 0 },
"cache_read_input_tokens": 0

I can't prove after the fact that the env var was set in that particular session, so the load-bearing
evidence is this non-interactive repro plus a control, both run fresh on 2.1.267 with
DISABLE_PROMPT_CACHING_HAIKU=1 confirmed present in process.env:

| # | Command | cache_creation_input_tokens | cache_read_input_tokens | input_tokens |
|---|---|---:|---:|---:|
| B | claude -p --model haiku --output-format json "…" | 8102 (ephemeral_1h) | 14074 | 10 |
| A | DISABLE_PROMPT_CACHING=1 claude -p --model haiku --output-format json "…" | 0 | 0 | 22186 |

Control A shows the env plumbing is fine — the global flag disables caching completely. Only the _HAIKU branch fails to fire.

Expected vs actual

  • Expected (per docs): with DISABLE_PROMPT_CACHING_HAIKU=1, requests to a Haiku model carry no cache_control, so cache_creation_input_tokens == 0.
  • Actual: caching is fully active, including a 1-hour cache write.

Root cause

From the bundled JS in ~/.local/share/claude/versions/2.1.267 (offset 168137155):

function RJe(e){                                       // e = request model id
  if(Ie(process.env.DISABLE_PROMPT_CACHING)) return !1;
  if(Ie(process.env.DISABLE_PROMPT_CACHING_HAIKU) && Mnt()){
    let n = rg();                                      // resolved small-fast / Haiku model
    if(n !== et() && e === n) return !1;               // <-- et() = resolved MAIN model
  }
  if(Ie(process.env.DISABLE_PROMPT_CACHING_SONNET)){ let n=Rp(); if(e===n) return !1 }
  if(Ie(process.env.DISABLE_PROMPT_CACHING_OPUS)){  let n=Wl(); if(e===n) return !1 }
  if(Ie(process.env.DISABLE_PROMPT_CACHING_FABLE)){ if(E1(e)||sM(e)) return !1 }
  if(a.DISABLE_PROMPT_CACHING_MYTHOS){ if(Unt(e)) return !1 }
  return !0;
}

RJe is the gate on the main request path — both request builders read
p.enablePromptCaching ?? RJe(...) (offsets 168161354, 168170458).

The HAIKU branch alone carries two extra conditions:

function Mnt(){                                         // TRUE on a plain first-party install (confirmed, see below)
  if(a.ANTHROPIC_SMALL_FAST_MODEL !== void 0) return !0;
  let e = He(),                                         // "firstParty"
      n = e === "firstParty" && (go() || fmr()) || jO(e);   // go() -> true when ANTHROPIC_BASE_URL is unset
  return a.ANTHROPIC_DEFAULT_HAIKU_MODEL !== void 0 || n;
}
function rg(){ /* -> q8() -> claude-haiku-4-5-20251001 */ }
function et(){ let e = um(); if(e != null) return kt(e); return ul(); }   // -> the /model selection

Mnt() returning true is confirmed statically, not assumed: the chunk containing Mnt
(160444341–161393292) imports He, go, jO from chunk-xsncbnja.js and defines none of them locally;
that He() is the provider resolver returning "firstParty", and go() returns true when
ANTHROPIC_BASE_URL is unset. So the n !== et() guard is the sole cause — the branch is live, not dead.

When Haiku is the main model, rg() === et(), so n !== et() is false, the branch never reaches
return !1, and caching stays on. No combination of ANTHROPIC_SMALL_FAST_MODEL /
ANTHROPIC_DEFAULT_HAIKU_MODEL can satisfy the guard, because n !== et() and e === n are mutually
exclusive for a main-model request.

Identical shape in 2.1.247 (H6e):

if(zr(process.env.DISABLE_PROMPT_CACHING_HAIKU)&&gNe()){let t=Oc();if(t!==Xo()&&e===t)return!1}

The startup warning asserts the opposite

function yet(){ return ["DISABLE_PROMPT_CACHING","DISABLE_PROMPT_CACHING_HAIKU",
                        "DISABLE_PROMPT_CACHING_OPUS","DISABLE_PROMPT_CACHING_SONNET",
                        "DISABLE_PROMPT_CACHING_FABLE"].filter(w => Ie(process.env[w])) }

var LXt = { id:"prompt-caching-disabled", tier:"warning", type:"warning",
  isActive: () => yet().length > 0,
  render: () => ["Prompt caching off (", yet().join(", "),
                 "), requests will be slower and cost more", " · unset it to re-enable"] };

yet() filters on env-var presence only — no rg()/et() comparison, no Mnt(). So the warning gate and the cache
gate disagree by construction: the banner activates on flag presence while RJe() returns true and writes
a 1-hour cache. (I verified the code paths; I did not capture a screenshot of the banner.) That
divergence is a defect on its own, whichever semantics are intended.

Docs contradiction

All four per-model rows are worded identically, scoped by tier, with no role qualifier anywhere:

  • env-vars — | DISABLE_PROMPT_CACHING_HAIKU | Set to 1 to disable prompt caching for Haiku models |
  • model-config — under "disable prompt caching globally or for specific model tiers": | DISABLE_PROMPT_CACHING_HAIKU | Set to 1 to disable prompt caching for Haiku models only |
  • prompt-caching — | DISABLE_PROMPT_CACHING_HAIKU | Disable for Haiku only |

Two further points from the docs' own text:

  • prompt-caching says disabling is "useful when debugging caching behavior with a specific model or provider" — the guard defeats exactly that use case for Haiku.
  • prompt-caching says these can go "in the env block of managed settings" to set org-wide policy. An administrator doing so gets silent non-compliance on every machine where a developer selects Haiku.

The docs demonstrably have vocabulary for role scoping — ANTHROPIC_DEFAULT_HAIKU_MODEL is described as "background functionality", the deprecated ANTHROPIC_SMALL_FAST_MODEL as a "Haiku-class model for background tasks" — and deliberately do not apply it to these four rows. Selecting haiku is itself a documented first-class main-model choice (model-config: "haiku | Uses the fast and efficient Haiku model for simple tasks").

No release note has ever named _HAIKU/_SONNET/_OPUS/_FABLE. DISABLE_PROMPT_CACHING appears exactly once in the whole CHANGELOG (390 versions, back to 0.2.21), at 2.1.108: "Added a warning at startup when prompt caching is disabled via DISABLE_PROMPT_CACHING* environment variables". So the docs site is the sole public specification of these flags' semantics.

Cost impact

Per prompt-caching's TTL table, the main conversation gets a one-hour TTL by default on a subscription within included usage, and 1-hour writes are "billed at a higher rate". Since the guard misfires only for main-model requests, the ignored flag and the expensive TTL always coincide.

For the single interactive turn above, Claude Code's own cost-state estimate was $0.088957, of which the
41,576-token 1-hour write accounts for roughly 93% — with cache_read_input_tokens: 0, i.e. the cache was
never read because the session ended. (On a subscription within included usage this is Claude Code's estimate,
not an invoice; please confirm current per-MTok rates against the live pricing page.)

On "working as intended"

I want to pre-empt this, because the guard looks deliberate. In #7095 a collaborator wrote:

v1.0.113 added a few env vars that should resolve this for you - DISABLE_PROMPT_CACHING_HAIKU, DISABLE_PROMPT_CACHING_SONNET, DISABLE_PROMPT_CACHING_OPUS. This allows you to keep prompt caching enabled for your main loop model, while disabling for your unsupported Haiku version specifically.

n !== et() faithfully implements that sentence. But the scenario there was main = Sonnet with an unsupported Haiku 3 in the small/fast slot — a case a plain tier-scoped flag satisfies identically, since the main model isn't Haiku. It says nothing about a user deliberately selecting Haiku as the main model, which the linked thread never contemplates, and /model haiku is a documented first-class option today.

Additional data point, offered as corroboration rather than proof: a third-party decompiled mirror of v2.1.88 (chauncygu/collection-claude-code-source-code, src/services/api/claude.ts L333-356) shows the gate with no main-model guard and all branches structurally identical:

if (isEnvTruthy(process.env.DISABLE_PROMPT_CACHING_HAIKU)) {
  const smallFastModel = getSmallFastModel();
  if (model === smallFastModel) return false;
}

If accurate, the flag did fire for Haiku-as-main at 2.1.88, and the guard was added later without an announcement.

Requested resolution

Either of these closes it; the current state is inconsistent whichever way you go.

  1. Honor the flag by tier, as documented — drop the n !== et() guard so _HAIKU matches _SONNET/_OPUS. If the guard exists for the real Bedrock/Vertex case where the Haiku slot collapses onto the main model by fallback, key it on that fallback condition rather than on model-id equality.
  2. Or keep the guard and fix the contract — correct all three doc pages to state the main-model exemption, and make the startup warning suppress or qualify itself when the flag is being skipped.

Side notes

  • DISABLE_PROMPT_CACHING_MYTHOS is honored by the gate (reading a.DISABLE_PROMPT_CACHING_MYTHOS, not process.env) but appears in no doc table and is missing from yet(), so setting it produces no startup warning.
  • Mnt() is a second undocumented condition on the HAIKU branch alone; on provider configurations where it returns false, _HAIKU would no-op for a different reason.
  • Related but distinct: #48090 (docs omit the startup warning; same doc rows), #8632 (DISABLE_PROMPT_CACHING → 401).

View original on GitHub ↗