anthropics / anthropics/claude-code

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

Aberta
#93,295 0 comentários 0 reações 0 responsáveis Ver no GitHub
area:core bug has repro platform:macos
Linguagem predominante
Python
Estrelas
145k
Forks
23.1k
Métricas de merge de PRs
Métricas de PR pendentes

Descrição

### 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`:
```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:
```json
"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):

```js
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:

```js
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`):
```js
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

```js
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](https://code.claude.com/docs/en/env-vars) — `| DISABLE_PROMPT_CACHING_HAIKU | Set to 1 to disable prompt caching for Haiku models |`
- [model-config](https://code.claude.com/docs/en/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](https://code.claude.com/docs/en/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:

```js
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).

Guia de contribuição

Nenhum guia de contribuição indexado para este repositório

Direção de pesquisa

Start from the prompt-caching gate described as RJe in the bundled JS under ~/.local/share/claude/versions/2.1.267 and the docs pages for env-vars, model-config, and prompt-caching. Reproduce with DISABLE_PROMPT_CACHING_HAIKU=1 and claude -p --model haiku --output-format json. Done means the Haiku flag behavior, startup warning, and docs agree, with cache_creation_input_tokens matching the intended contract.

Escrita pelo modelo de indexação a partir do texto da issue.

Avaliação

Stack de tecnologia
javascript
Domínio
api, cli, documentation
Tipo de issue
Bug
Dificuldade
4/5
Tempo estimado
3-5 dias
Status de atividade
Ativa
Clareza
Razoavelmente clara
Facilidade para iniciantes
48/100

Receba novas issues na sua caixa de entrada

Um resumo curto de issues do GitHub para quem está começando.