Skip to content

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

Open

Description

@swf-cmd

Description

Anthropic looks back at most 20 block positions from each cache breakpoint for an earlier cache entry (runs of tool_use or tool_result count as one): https://platform.claude.com/docs/en/build-with-claude/prompt-caching

The auto cache policy marks only the newest message. If one new message has 20+ blocks, for example 10 attached image files (each adds an "Attached file:" text block plus the image) or about 20 text files, that breakpoint can't reach the entry the previous request wrote. The whole conversation is then written to the cache again (1h writes cost 2x input) instead of being read.

Meanwhile the breakpoint on the first system part only covers the base prompt (~440 tokens) beyond the tools breakpoint.

I found this by reading cache-policy.ts against the docs and checked it with a simulation that runs the real request builder. I haven't captured it from a live session.

Plugins

None

OpenCode version

dev (8568952)

Steps to reproduce

  1. Use a Claude model through the anthropic provider.
  2. After a few steps, send one prompt with 10 attached image files.
  3. Expected: the conversation before the new prompt is read from the cache. Per the lookback rule it is written again instead; in the simulation that step writes about 56k tokens instead of about 14k.

Screenshot

No response

Operating System

macOS

Terminal

Desktop app

Activity

  1. opencodejr commented on Nov 4, 2026

    @opencodejr
    Contributor

    Thanks for the detailed analysis. I could reproduce the breakpoint placement you describe on the latest dev (93f30ca). I used a mock Anthropic endpoint, since there are no credentials here, so I checked where the breakpoints land in the request rather than real cache usage numbers.

    Steps: point the anthropic provider at a mock that records request bodies, send two plain prompts in a session, then send one prompt with 10 local images attached by file:// path (the way the desktop app attaches them).

    In that last request, each image becomes an Attached file: … text block plus the image. The only conversation cache_control lands on the last text block, 21 block positions after the previous request's tail breakpoint, with no breakpoint in between. That is just past Anthropic's 20-block lookback, so going by the documented rule the previous entry can't be read. Also, the final image comes after that breakpoint, so it isn't covered by it.

    opencode run -f doesn't trigger this: it sends images without the Attached file: text and puts text files inline in the prompt.

    The script below reproduces it without credentials. It exits 1 and prints the gap when the breakpoint falls outside the lookback.

    Related: #38725 (added the rolling tail breakpoint for this same lookback limit, but only for tool loops), #58801 (logging why a step missed the cache).

    Reproduction script (repro.ts)
    // Reproduces anomalyco/opencode#61689: with the default "auto" cache policy, one user message carrying 10 local
    // image files (each lowered to an "Attached file: <path>" text block plus the image) puts the only conversation
    // cache breakpoint more than 20 block positions after the previous request's breakpoint, which is beyond
    // Anthropic's documented 20-block lookback for cache hits.
    //
    // Run: bun repro.ts            (uses `opencode` from PATH)
    //      OC_DEV=/path/to/opencode-checkout bun repro.ts   (runs the CLI from source)
    // No credentials needed: the anthropic provider is pointed at an in-process mock that records request bodies.
    import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"
    import { tmpdir } from "node:os"
    import path from "node:path"
    import { deflateSync, crc32 } from "node:zlib"
    
    const bodies: any[] = []
    const ev = (t: string, d: unknown) => `event: ${t}\ndata: ${JSON.stringify(d)}\n\n`
    const mock = Bun.serve({
      port: 0,
      async fetch(req) {
        if (!new URL(req.url).pathname.endsWith("/messages")) return new Response("{}", { status: 404 })
        const body = await req.json()
        bodies.push(body)
        return new Response(
          ev("message_start", { type: "message_start", message: { id: "m", type: "message", role: "assistant", model: body.model, content: [], stop_reason: null, usage: { input_tokens: 1, output_tokens: 1 } } }) +
            ev("content_block_start", { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }) +
            ev("content_block_delta", { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "ok" } }) +
            ev("content_block_stop", { type: "content_block_stop", index: 0 }) +
            ev("message_delta", { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } }) +
            ev("message_stop", { type: "message_stop" }),
          { headers: { "content-type": "text/event-stream" } },
        )
      },
    })
    
    // Isolated project + XDG dirs, 10 tiny PNGs.
    const root = mkdtempSync(path.join(tmpdir(), "oc-61689-"))
    const proj = path.join(root, "proj")
    mkdirSync(proj)
    writeFileSync(path.join(proj, "opencode.json"), JSON.stringify({ providers: { anthropic: { settings: { baseURL: `http://127.0.0.1:${mock.port}/v1`, apiKey: "test" } } } }))
    const chunk = (t: string, d: Buffer) => {
      const len = Buffer.alloc(4); len.writeUInt32BE(d.length)
      const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(Buffer.concat([Buffer.from(t), d])) >>> 0)
      return Buffer.concat([len, Buffer.from(t), d, crc])
    }
    for (let i = 0; i < 10; i++) {
      const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(4, 0); ihdr.writeUInt32BE(4, 4); ihdr.set([8, 6, 0, 0, 0], 8)
      const raw = Buffer.concat(Array.from({ length: 4 }, () => Buffer.from([0, ...Array(4).fill([i * 20, 100, 200, 255]).flat()])))
      writeFileSync(path.join(proj, `img${i}.png`), Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk("IHDR", ihdr), chunk("IDAT", deflateSync(raw)), chunk("IEND", Buffer.alloc(0))]))
    }
    
    const port = 4196
    const env = { ...process.env, OPENCODE_SERVER_PASSWORD: "pw", XDG_DATA_HOME: `${root}/data`, XDG_CONFIG_HOME: `${root}/config`, XDG_STATE_HOME: `${root}/state`, XDG_CACHE_HOME: `${root}/cache` }
    const cmd = process.env.OC_DEV
      ? ["bun", "--conditions=browser", `--preload=${process.env.OC_DEV}/packages/tui/node_modules/@opentui/solid/scripts/preload.js`, `${process.env.OC_DEV}/packages/cli/src/index.ts`]
      : ["opencode"]
    const server = Bun.spawn([...cmd, "serve", "--port", String(port)], { cwd: proj, env, stdout: "pipe", stderr: "inherit" })
    const base = `http://127.0.0.1:${port}`
    const auth = { authorization: "Basic " + btoa("opencode:pw"), "content-type": "application/json" }
    for (let i = 0; ; i++) {
      if (await fetch(`${base}/api/session`, { headers: auth }).then((r) => r.ok, () => false)) break
      if (i > 120) throw new Error("server did not start")
      await Bun.sleep(500)
    }
    const post = (p: string, b: unknown) => fetch(base + p, { method: "POST", headers: auth, body: JSON.stringify(b) }).then((r) => r.json() as any)
    const waitFor = async (n: number) => { for (let i = 0; i < 60 && bodies.filter((b) => b.tools?.length).length < n; i++) await Bun.sleep(250); await Bun.sleep(500) }
    
    const session = (await post("/api/session", { model: { providerID: "anthropic", id: "claude-sonnet-4-5" } })).data.id
    await post(`/api/session/${session}/prompt`, { text: "first question" })
    await waitFor(1)
    await post(`/api/session/${session}/prompt`, { text: "second question" })
    await waitFor(2)
    await post(`/api/session/${session}/prompt`, {
      text: "describe these images",
      files: Array.from({ length: 10 }, (_, i) => ({ uri: `file://${proj}/img${i}.png`, name: `img${i}.png` })),
    })
    await waitFor(3)
    server.kill(); mock.stop(true)
    
    // Block positions of the messages array; runs of tool_use / tool_result count as one (Anthropic's rule).
    const positions = (body: any) => {
      const all = body.messages.flatMap((m: any) => (typeof m.content === "string" ? [{ type: "text" }] : m.content))
      return all.filter((c: any, i: number) => !(i > 0 && (c.type === "tool_use" || c.type === "tool_result") && all[i - 1].type === c.type))
    }
    const main = bodies.filter((b) => b.tools?.length)
    if (main.length < 3) throw new Error(`expected 3 agent requests, got ${main.length} (of ${bodies.length})`)
    main.forEach((b, i) => console.log(`request ${i}:`, positions(b).map((c: any) => c.type + (c.cache_control ? "*" : "")).join(" ")))
    const lastBp = (b: any) => positions(b).findLastIndex((c: any) => c.cache_control)
    const [prev, next] = main.slice(-2)
    const distance = lastBp(next) - lastBp(prev)
    console.log(`previous tail breakpoint at position ${lastBp(prev)}, new tail breakpoint at ${lastBp(next)}, distance ${distance}`)
    console.log(distance > 20 ? "BUG: no breakpoint within 20 blocks of the previous cache entry -> Anthropic cannot read it" : "OK: within lookback")
    process.exit(distance > 20 ? 1 : 0)

    Run it: bun repro.ts

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions