Skip to content

fix(ai): keep Anthropic cache hits after messages with many blocks - #61690

Open
swf-cmd wants to merge 4 commits into
anomalyco:devfrom
swf-cmd:cache-tail-breakpoint
Open

swf-cmd wants to merge 4 commits into
anomalyco:devfrom
swf-cmd:cache-tail-breakpoint

Conversation

@swf-cmd

@swf-cmd swf-cmd commented Nov 4, 2026 •

Copy link
Copy Markdown

Issue for this PR

Closes #61689

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

On Anthropic's own route, auto now also marks the message before the newest assistant reply. The previous request ended there and wrote its tail entry there, so the new request reads that entry directly, however many blocks the reply and the new message add. Before, a prompt with 20+ blocks (10 attached images, say) left the only conversation breakpoint outside the 20-block lookback, and the whole conversation was written again.

The slot comes from the first system part, which only adds the base prompt to the tools prefix. It still gets a breakpoint whenever one is left over, as on a session's first request, so cross-session reuse doesn't change. Anthropic-compatible routes keep the current placement, since they may bill breakpoints differently.

Two smaller fixes in the same code, for every route:

  • Message hints only go on blocks a protocol sends a marker on: non-blank text, media and client tool results. A reply with only thinking and tool_use blocks no longer takes a slot whose marker is dropped when lowering. A wrapped system update takes the last hint on any of its parts, so a trailing blank part doesn't drop the tail breakpoint either.
  • Anthropic rejects a ttl: "1h" marker after a 5m one with a 400. Anthropic Messages, Bedrock Converse and OpenAI Chat now raise each marker to the longest lifetime after it once the body is built, in the order the provider reads them. Only the finished body has that order: on models that take native system messages, Anthropic Messages sends a system update after the user turn that follows it. Raising never moves the last 1h marker, and the prefix up to it is written at 1h anyway, so this costs nothing. It also repairs manual hints placed out of order.

Breakpoints are filled in the order tools, last system part, conversation tail, previous tail, first system part. A tail longer than the budget keeps its newest messages; before, the oldest message in the tail took the last slot and the newest content stayed uncached.

The first commit marked the second newest message instead. That is usually the reply the model just generated: in a tool loop its hint landed on a tool_use block and was never sent, and with parallel tool calls it marked another result of the same step. The second commit fixes that and raised auto lifetimes before a manual hint in request order. The third commit moves lifetime ordering to the finished body. Request order had missed a shorter manual hint before an auto marker, manual tool hints, and a system update moved after the next user turn, and each of these still sent a 1h marker after a 5m one.

Mixed lifetimes need manual hints. OpenCode itself doesn't set any (Core passes one request-level TTL), but plugins and direct @opencode/ai callers can.

How did you verify your code works?

  • bun test (2059 pass), bun typecheck and bun run typecheck:tsc in packages/ai; oxlint and prettier on the changed files.
  • Tests cover the Anthropic placement, the first request keeping the first system breakpoint, a 25-block prompt after a reply, parallel tool results, a reply without markable blocks leaving its slot, auto hints before a manual 1h hint, a manual 5m hint before 1h markers, a manual tool hint, a system update moved after the next user turn, a held update that is lowered after the reply it goes out before, a wrapped update with a blank last part, OpenRouter and Bedrock markers before a manual 1h hint, and the newest-first fill order. 4 of the 6 tests added in the third commit fail on the second; the other 2 cover what moved out of the cache policy.
  • A throwaway property test compiled 12,000 random requests: 4,000 shapes (tools with namespaces, system parts, system updates, tool calls, manual lifetimes, every cache policy) through Anthropic Messages on Sonnet 4.5, Opus 4.8 or Opus 5.5, OpenRouter and Bedrock Converse. On the second commit, 2,860 bodies sent a longer lifetime after a shorter one; on the third, none did. With lifetimes ignored, the bodies match the second commit except where a hint on a multi-part system update is now sent.
  • Compiling an 801-message request takes the same time within noise (about 5.9 ms against 5.6 ms).
  • The long-tool-turn cassette pins the single-tail placement it was recorded with. I can re-record it with the new default if you prefer.
  • Simulated a session through the real request builder: the step with 10 attached images writes ~14k tokens instead of ~56k. The later commits read the same entry, so the number holds. None of this was measured against the live API.

Screenshots / recordings

Not a UI change.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

🤖 Generated with Claude Code

https://claude.ai/code/session_01X7arpnFR8AZSgGeYEyZcfh

https://claude.ai/code/session_01X7arpmRb395Ye7ukWMUdSp


Added by the opencode bot (AI-generated).

Related: #61282

Anthropic looks back at most 20 block positions from each breakpoint for an
earlier cache entry. When one new message holds 20 or more blocks, such as a
prompt with about ten attached images or twenty text files, the tail
breakpoint no longer reaches the previous request's entry and the whole
conversation is written to the cache again.

On Anthropic's own route, auto now also marks the second newest message,
which is where the previous request's tail breakpoint sat. The slot comes from
the first system part, which only adds the base prompt to the tools prefix; it
keeps its breakpoint whenever one is left over, as on a Session's first
request, so cross-session reuse is unchanged.

Breakpoints are now filled in the order tools, last system part, newest
messages, first system part, and a tail longer than the budget keeps its
newest messages. Previously the oldest message in the tail took the last
slot, leaving the newest content uncached.

The long tool turn cassette pins the single-tail placement it was recorded
with until it is re-recorded.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01X7arppEkkX1wg1Yy1nq7tw

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The change looks sound. I copied the new cache-policy.test.ts into the base checkout and ran it: 4 tests fail there, including the 25-block case where only the newest message is marked and the previous entry is out of reach. All of them pass on this branch. The packages/ai tests closest to the change and typecheck:tsc pass too. I could not check it against the live Anthropic API, so the ~56k → ~14k write saving isn't confirmed.

Public API: no schema, config or SDK changes. The only behaviour change is where the default auto policy places breakpoints on the anthropic-messages route, plus the newest-first fill order when an explicit tail runs out of breakpoints. Existing configs keep working.

Nothing blocks a merge. One optional point, inline: Vertex and Bedrock Mantle use Anthropic's caching too, so they probably miss the previous entry in the same way. Also, the recorded test is now pinned to the old one-message setting, so the new Anthropic default isn't covered by a recorded test until it is re-recorded.

Related: #61282

Comment thread packages/ai/src/cache-policy.ts Resolved
The second newest message is usually the reply the model just generated, not
the previous request's tail. In a tool loop that reply often holds only
thinking and tool_use blocks, which never carry cache_control, so its hint
spent a slot, was dropped when lowering, and pushed out the first system
part's breakpoint. With parallel tool calls, the second newest message was
another result from the same step.

Auto on Anthropic's route now marks the message before the newest assistant
reply instead. The previous request put its tail breakpoint there, so the new
request reads that entry directly, however many blocks the reply and the new
message add.

Message hints now go only on blocks a protocol can send a marker on:
non-blank text, media and client tool results. A message without one keeps
its slot.

Anthropic rejects a ttl="1h" marker after a 5m one. An auto hint now takes the
longest lifetime of any manual hint after it, so a manual 1h hint on the
newest message no longer turns the request into a 400. The prefix up to that
hint is written at 1h anyway, so this costs nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01X7arpnFR8AZSgGeYEyZcfh

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at 1f45968a3. I ran the #61689 reproduction (a mock Anthropic endpoint, two plain prompts, then one prompt with 10 attached images) on both checkouts. On the base, the only conversation breakpoint lands 21 blocks after the previous tail. With this PR, the previous tail (block 2) is marked again, so the entry is read directly. cache-policy, effort-updates and the provider tests pass (1506), and typecheck:tsc passes. I didn't measure real cache usage, since there are no credentials.

The public API is unchanged. Only where the default auto policy places breakpoints changes, plus the internal lifetime upgrade. No schema, config or SDK changes.

Earlier points: leaving out Vertex, Bedrock Mantle and Converse for now is fine given your reasons, so that's resolved. Keeping the cassette pinned to the single-tail setting is fine too. In the new commit, marking the message before the newest reply is correct for tool loops and parallel tool results. Skipping tool-call, reasoning and blank text matches what the Anthropic lowering can actually mark. The lifetime upgrade fixes auto markers placed before a manual 1h one. Nothing blocking.

Related: #61282

The previous commit raised an auto hint to the longest manual hint after it in
request order, but requests could still send a 1h marker after a 5m one, which
Anthropic rejects with a 400:

- A shorter manual hint before an auto hint: `cache: { ttl: "1h" }` with a
  manual 5m hint on an earlier message sent the tail and the previous tail at
  1h after it.
- Manual tool hints, which only counted against the budget.
- System updates on models that take native system messages. Anthropic
  Messages sends an update after the user turn that follows it, so a 1h hint on
  the update went out after the 5m marker the cache policy had placed on that
  turn.

Request order can't settle this, so the cache policy gives every auto hint the
requested lifetime again, and Anthropic Messages, Bedrock Converse and OpenAI
Chat raise each marker to the longest lifetime after it once the body is
built. Raising never moves the last 1h marker, so it costs nothing, and it also
repairs manual hints the caller placed out of order.

A wrapped system update kept only its last part's hint, so the tail breakpoint
the cache policy puts on its last non-blank part was dropped when a blank part
followed. The wrapped block now takes the last hint on any of its parts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01X7arpmRb395Ye7ukWMUdSp

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed the new commit 98a6ec9b8. The #61689 reproduction (mock Anthropic endpoint, two prompts, then 10 attached images) still marks the previous tail at block 2 next to the new tail at block 23. The full packages/ai suite passes (2059), and so does typecheck:tsc. Nothing was measured against the live API.

The public API is unchanged: the only changes are where auto places breakpoints and how cache lifetimes are ordered in the request body. No schema, config or SDK changes.

Ordering lifetimes once the body is built looks right. Every marker it changes in place is a fresh object on each route (Anthropic, the shared cacheControl() used by OpenRouter, Venice, Alibaba and DigitalOcean, and Bedrock cache points), so nothing shared is changed. Bedrock models without 1h support never emit a 1h marker for it to raise. Wrapped system updates taking the last hint on any part is also correct.

One note for maintainers rather than a blocker: the lifetime ordering now touches three protocols and only affects callers that set manual hints with mixed lifetimes. That goes beyond #61689 and could ship separately if preferred. Nothing blocking.

Resolves the conflict with anomalyco#60971 in the long-tool-turn cassette test: it keeps
the pinned single-tail cache policy and takes the flattened maxTokens and
temperature.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01X7arpmRb395Ye7ukWMUdSp

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new commit only merges dev. The PR's own changes are the same as at 98a6ec9b8, and the recorded Anthropic cache test now sits alongside the top-level maxTokens and temperature fields that came from dev. On the merged head, the full packages/ai suite passes (2060), typecheck:tsc passes, and the #61689 reproduction still marks the previous tail (block 2) next to the new tail (block 23). The public API is unchanged. No earlier threads are open, and there is nothing new to raise.

@swf-cmd

swf-cmd commented Nov 6, 2026

Copy link
Copy Markdown
Author

Checked the current head, 23d8535e633d520cc5816e39f83adb51dce8de96: the previous review thread is resolved, the standards/compliance checks pass, and the PR is mergeable. The four PR workflows are still waiting for maintainer approval (action_required): check, check-test, check-nix, and check-bot-scope.

Could a maintainer approve these runs so CI can validate the current head? I don't have Actions write access on this repository.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Anthropic: one prompt with 20+ content blocks misses the previous prompt cache entry

2 participants