Uh oh!
There was an error while loading. Please reload this page.
fix(cache): improve Anthropic prompt cache hit rate with system split and tool stability - #14743
fix(cache): improve Anthropic prompt cache hit rate with system split and tool stability#14743bhagirathsinh-vaghela wants to merge 6 commits into
Conversation
The following comment was made by an LLM, it may be inaccurate: Potential related PRs found:
Note: PR #14203 appears to be the most directly related, as it's specifically about the system prompt splitting strategy that is a key component of PR #14743's improvements. |
Thanks for updating your PR! It now meets our contributing guidelines. 👍 |
Reviewer's guide — supplementary context not covered in the PR description. Uses same terminology (S1/S2, M1/M2) defined there. AI SDK cache marker mechanicsRef: Anthropic prompt caching docs | Anthropic engineers' caching best practices (Feb 19 2026): Thariq Shihipar, R. Lance Martin Max 4 Key subtlety: before this PR, OpenCode had a single system block. M1 covered it, but M2 was unused — it fell through to conversation. The system split (commit 3) is what activates both markers, letting S1 (stable) cache independently from S2 (dynamic). Since M1 covers the tool block too (tools hash before system in Anthropic's ordering), any tool instability (commits 4–5) completely invalidates M1 — the entire cached prefix up to that marker is lost. Related open PRsSeveral open PRs address parts of this (#5422, #14203, #10380, #11492). This PR addresses the root causes directly. Update (post-rebase, Mar 21 2026)
|
b67a66a to
906a317Compare906a317 to
c499424CompareCI failure seems pre-existing — same |
c499424 to
176c069CompareShanePresley
commented
Feb 23, 2026
I pulled this into my fork and it's working beautifully. Unfortunately I only found this after getting a huge bill from Anthropic. Thanks OpenCode! |
@bhagirathsinh-vaghela could you check this with SLMs like Qwen3 or Nemotron or Kimi-Linear or GPT-OSS? Or providers using the OpenAI-compatible APIs (e.g. OpenRouter)? Bonus ask: would Speculative Decoding work with this fork? I am looking at this from the lens of vLLM-MLX and MLX-OpenAI-Server (for non-MLX there is vLLM). |
176c069 to
f08aa45Compare
The fixes are provider/model-agnostic — they stabilize the request prefix so it is byte-for-byte identical across calls. Any provider with server-side prefix caching benefits automatically. See my reviewer's guide comment above for the full breakdown of each fix. The specific model behind the provider does not matter — the changes are purely at the request layer. You can verify with any provider using E2E failures — pre-existing upstream issue, since fixed. CI is green now. Speculative decoding — orthogonal. This PR only changes what is sent in the request, not how the server processes it. |
f08aa45 to
7984393Compare| ` Is directory a git repo: ${project.vcs === "git" ? "yes" : "no"}`, | ||
| ` Platform: ${process.platform}`, | ||
| ` Today's date: ${new Date().toDateString()}`, | ||
| ` Today's date: ${date.toDateString()}`, |
There was a problem hiding this comment.
Would it make sense to change the wording here, to hint to the LLM that this isn't a live updating value? Otherwise it might make some weird choices elsewhere for long lived conversations. E.g.
| ` Today's date: ${date.toDateString()}`, | |
| ` Session started at: ${date.toDateString()}`, |
There was a problem hiding this comment.
Good point — this is better to show when the date is frozen. I'm keeping Today's date in this PR for now since it's what all OpenCode users expect(at least by experience even if they are not aware), but I'm not against the change if maintainers agree.
Separately, I've been experimenting locally with a progressive disclosure approach — making the env block fully static, instructing the model to fetch cwd, date, platform, etc. via tool calls when needed. Eliminates the block 2 cache write entirely at the cost of an occasional extra round-trip.
Interesting finding in this approach: completely removing the env block tended to result in models not bothering to fetch the info at all and assume things which is non deterministic. A static block with explicit "figure out when needed" instructions worked much better, at least with Anthropic models.
There was a problem hiding this comment.
Separately, I've been experimenting locally with a progressive disclosure approach — making the env block fully static, instructing the model to fetch cwd, date, platform, etc. via tool calls when needed. [...] A static block with explicit "figure out when needed" instructions worked much better, at least with Anthropic models.
Hmm! I'll have to give that a shot when I patch from this PR later; I'm running locally against one of the Qwen3.5 models, so it'll be interesting data to see how they respond.
fkroener
commented
Mar 8, 2026
Looking forward to seeing less prompt re-processing with opencode. Unfortunately it seems currently this patchset breaks llama.cpp support:
Tested with and without the new autoparser. Maybe I'm using it wrong? |
fkroener
commented
Mar 9, 2026
So, after partially reverting fix(cache): split system prompt into 2 blocks for independent caching, or rather naively ensuring llama.cpp gets just one system prompt (revert.patch) opencode now flies with this patchset using a llama.cpp endpoint (openai api though). No more "erased invalidated context checkpoint" for all checkpoints and reprocessing of the entire context seemingly whenever I send a new query. Checkpoint reuse happens usually at around 99 %, sometimes drops to 93 % - lowest was in the 70 % with > 60k tokens. Much appreciated! Wonder whether the split system message is something @pwilkin would be willing to support or whether it should be guarded to only be sent to Antrophic endpoints. |
pwilkin
commented
Mar 9, 2026
Any chance the system message could be moved to the top of the messages list? We could possibly do this for the Anthropic API, but technically the system prompt should be the first message. |
fkroener
commented
Mar 9, 2026
Thanks @pwilkin. Given this is actually coming from the model template (Qwen 3.5) and not the parser: this should probably best be handled on OpenCode's end. |
@rekram1-node@thdxr Meanwhile, cache TTL mapping, auto-placement of cache markers, and breakpoint management just landed in This PR has merge conflicts that I'm not resolving until I know whether external contributions are actually being considered here. Is this going to be merged, or should I stop spending time on it? |
martinffx
commented
May 11, 2026
|
marcusquinn
commented
May 11, 2026
I rebased this patchset onto current
What changed during the rebase:
Verification:
I also checked the latest discussion before posting. The recent comments are mostly about whether this PR is still relevant while aidevops.sh v3.15.29 plugin for OpenCode v1.14.48 with gpt-5.5 spent 9h 15m and 656,830 tokens on this with the user in an interactive session. |
marcusquinn
commented
May 11, 2026
Created a linked PR for the rebased/conflict-resolved branch: It references this PR as aidevops.sh v3.15.29 plugin for OpenCode v1.14.48 with gpt-5.5 spent 9h 23m and 678,875 tokens on this with the user in an interactive session. |
That makes sense but I think it's heavily dependent on your usage patterns. I haven't tried automating pings like that but not all of my sessions are running in tmux so I would need a different solution for that. I usually have 5-10 long-running sessions that will last the whole day (or more) and context-switching/testing consistently takes longer than 5m. In that case the 1h cache is completely worth it in my opinion. I'll still use the 5m cache for short/active sessions though. I've been tracking my cost for a few weeks and I've greatly reduced costs thanks to your patches + 1h cache for everything with an additional patch in my fork. I would suggest making |
I've tried to break it up into 4 seperate PR's to make it easier to review and merge
|
spacepluk
commented
May 13, 2026
In case it helps, here are my patches to use 1h for all the cache breakpoints and another one that implements my SYSONLY suggestion above |
FYI I had to disable the system split for Gemini because it was ignoring the system prompt completely. {
"provider": {
"google": {
"options": {
"splitSystemPrompt": false
}
}
}
} |
@martinffx all your PRs have been closed by github bots. Aren't we both losing money and wasting energy by not taking proper care of prompt caching? |
Arrowyi
commented
Aug 11, 2026
@rekram1-node@thdxr — production evidence that the cache problem is worse than this PR assumes I filed #40790 with production data from 5 Claude Opus 5 Max sessions: $24.45 out of $38.35 (63.8%) was cache writes. The worst session ( Why this matters for #14743: This PR's premise is "Same-session caching already works." My data contradicts that. All 9 busts happened within the same session, triggered by the known tool-call prompt loop bug (#24841): the loop reloads messages from DB, and tool part states change from What #14743 would fix and what it wouldn't:
In my case, even with this PR merged, I'd still see ~$0.50 busts (conversation portion) instead of $1.63 (full bust). Better, but not solved. Two complementary fixes are needed:
I've verified this with database queries anyone can run: -- Verify busts happen exclusively after tool-calls:
WITH msg_list AS (
SELECT json_extract(data, '$.finish') as finish,
json_extract(data, '$.tokens.cache.read') as cache_r,
json_extract(data, '$.tokens.cache.write') as cache_w,
ROW_NUMBER() OVER (ORDER BY json_extract(data, '$.time.created')) as rn
FROM message WHERE session_id ='<your-session-id>'AND json_extract(data, '$.role') ='assistant'
)
SELECTm1.rn, m1.cache_r, m1.cache_w, m2.finishas prev_finish
FROM msg_list m1 LEFT JOIN msg_list m2 ONm1.rn=m2.rn+1WHEREm1.cache_r=0ANDm1.cache_w>0;Bottom line: This PR has been sitting for 3 months with 14+ participants and multiple rebases. marcusquinn's rebased branch ( Happy to provide more data or help move this forward however I can. |
TomLucidor
commented
Aug 11, 2026
@Arrowyi could you replicate this with other model providers? Wondering if Kimi/GLM/DeepSeek/MiMo would have similar problems, or if it is server-side |


Issue for this PR
Closes#5416, #5224
Related: #14065, #5422, #14203
Type of change
What does this PR do?
Fixes cross-repo and cross-session Anthropic prompt cache misses. Same-session caching already works (AI SDK places markers correctly). This PR fixes the cases where the prefix changes between repos, sessions, or process restarts — causing full cache writes on every first prompt.
Anthropic hashes tools → system → messages in prefix order. Any change to an earlier block invalidates everything after it. OpenCode has several sources of unnecessary prefix changes.
Terminology (1-indexed): S1/S2 = system block 1/2. M1/M2 = cache marker on S1/S2.
Always-active fixes:
System prompt is a single block — dynamic content (env, project AGENTS.md) invalidates the stable provider prompt. Split into 2 blocks: stable (provider prompt + global AGENTS.md) first, dynamic (env + project) second.
Bash tool schema includes
Instance.directory— changes per-repo, invalidating tool hash. Removed; model gets cwd from the environment block.Skill tool ordering is nondeterministic —
Object.values()on glob results. Sorted by name.Opt-in fixes (behind env var flags):
Date and instructions change between turns —
OPENCODE_EXPERIMENTAL_CACHE_STABILIZATION=1freezes date and caches instruction file reads for the process lifetime.Extended cache TTL —
OPENCODE_EXPERIMENTAL_CACHE_1H_TTL=1sets 1h TTL on M1 (2x write cost vs 1.25x for default 5-min). Useful for sessions with idle gaps.Commits:
OPENCODE_CACHE_AUDITOPENCODE_EXPERIMENTAL_CACHE_STABILIZATIONOPENCODE_EXPERIMENTAL_CACHE_1H_TTLWhat this doesn't fix:
Impact beyond Anthropic: The prefix stability fixes also benefit providers with automatic prefix caching (OpenAI, DeepSeek, Gemini, xAI, Groq) — no markers needed, just a stable prefix.
How did you verify your code works?
OPENCODE_CACHE_AUDIT=1logs[CACHE]hit/miss per LLM call. Tested with Claude Sonnet 4.6 on Anthropic direct API,bun dev, Feb 23 2026.Cross-repo (different folder, within 5-min TTL — the key improvement):
BEFORE (no fixes):
AFTER (system split + tool stability):
The first prompt in a new repo goes from 0% → 97.6% cache hit. S1 (tools + provider prompt + global AGENTS.md) is reused across repos. These numbers are based on my setup — S1 is ~17,345 tokens, mostly tool definitions (~12k tokens), with provider prompt (~2k) and global AGENTS.md (~2.8k) making up the rest. Your numbers will differ based on your tool set (MCP servers, skills) and global AGENTS.md size, but the cross-repo miss is eliminated regardless.
Only block 2 (env with different cwd = 428 tokens) is a cache write on the first prompt in a new repo.
To reproduce:
Screenshots / recordings
N/A — no UI changes.
Updates (post-rebase, Mar 21 2026)
devSystemPrompt.skills()which puts skill descriptions in the system prompt. Global skills (from~/.config/opencode/skills/) are now placed in S1 (stable) and project skills in S2 (dynamic), so global skills don't cause cache writes on cross-repo switch. On my setup, cross-repo cache hit improved from 87% → 97.7%.splitSystemPromptprovider config option. Providers that reject multiple system messages (e.g. llama.cpp with Qwen templates) can setprovider.<id>.options.splitSystemPrompt: falseto get single-block behavior.Updated commit table:
OPENCODE_CACHE_AUDITOPENCODE_EXPERIMENTAL_CACHE_STABILIZATIONOPENCODE_EXPERIMENTAL_CACHE_1H_TTLsplitSystemPromptprovider option to opt out of splitprovider.<id>.options.splitSystemPrompt: falseChecklist