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`

Đang mở
#93,295 0 bình luận 0 reaction 0 người được giao Xem trên GitHub
area:core bug has repro platform:macos
Ngôn ngữ chính
Python
Star
145k
Fork
23.1k
Chỉ số merge pull request
Chỉ số pull request đang chờ

Mô tả

### 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).

Hướng dẫn đóng góp

Chưa lập chỉ mục được hướng dẫn đóng góp cho kho mã nguồn này

Hướng nghiên cứu

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.

Do mô hình lập chỉ mục viết ra từ nội dung của issue.

Đánh giá

Công nghệ
javascript
Lĩnh vực
api, cli, documentation
Loại issue
Lỗi
Độ khó
4/5
Thời gian dự kiến
3-5 ngày
Mức độ hoạt động
Sôi nổi
Độ rõ ràng
Khá rõ ràng
Mức phù hợp với người mới
48/100

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.