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 intoanomalyco:devfrom
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
906a317
Compare
906a317 to
c499424
Compare
|
CI failure seems pre-existing — same |
c499424 to
176c069
Compare
|
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
f08aa45
Compare
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
7984393
Compare
| ` 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.
|
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? |
|
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. |
|
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. |
|
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. |
…m PR anomalyco#14743 - Add OPENCODE_EXPERIMENTAL_CACHE_STABILIZATION and OPENCODE_EXPERIMENTAL_CACHE_1H_TTL flags - Split system prompt into 2 blocks (stable/dynamic) for better cache reuse - Freeze date and instructions behind OPENCODE_EXPERIMENTAL_CACHE_STABILIZATION flag - Remove Instance.directory from bash tool schema for cross-repo cache hits - Sort skill tools alphabetically for deterministic ordering - Add extended TTL support for first system cache marker - Add cache audit display in TUI sidebar behind OPENCODE_CACHE_AUDIT env var - Fix llama-server compatibility: join system blocks for non-Anthropic providers - Update tests for all changed functionality Co-authored-by: chand1012 <[email protected]>
|
When will this PR make it into a release? We are seeing lower cache hit rates (Anthropic) across users using the same repo with a standard workflow based on opencode. -> higher token costs |
|
Even more important now to get it into release with the general rollout of 1M Context windows for Max subscribers. The price remained as if it was 200K window, so it's up to caching to cut costs. |
|
Would love to see this get in as well. Caching is much less efficient in OpenCode with Claude models. We are pushing internal users to OpenCode for better general model supports, but the caching issue is a blocker. |
|
we are looking at it |
|
I think most of these changes prolly make sense, but it seems like the primary 2 things that are gained:
In my experience #2 prolly won't have much impact for most ppl but we may as well do that We actually resolved some of the ordering things in separate pr, ill look at the rest of this and then we will ship a cleaned up version |
7984393 to
d7849ca
Compare
|
@fkroener The system split can now be disabled per-provider via config. For your llama.cpp setup, add to your {
"provider": {
"<your-llama-provider-id>": {
"options": {
"splitSystemPrompt": false
}
}
}
}This builds a single system message (pre-PR behavior) while still benefiting from the other prefix stability fixes (tool schema, skill ordering). |
|
@rekram1-node Thanks for the feedback. Updates since your comment:
|
…NCODE_CACHE_AUDIT
d7849ca to
b57aa98
Compare
… for independent caching
…OPENCODE_EXPERIMENTAL_CACHE_1H_TTL flag
b57aa98 to
2e02781
Compare
|
+1, was creating a draft PR for this and bot directed me to this. It's not just for Anthropic, this busts cache for every single LLM implementation that uses prefixing to preserve model output quality (all of them?) |
|
I do hope this gets merged in, it's useful overall, and while support was removed for Anthropic Plus/Max plans, it would still be good for API as well as all the people who are just going to use a plugin to provide more Claude support. |
|
Yeah today Ill merge this in, I am gonna split it up into separate commits/prs and resolve the conflicts but ill add the author of the PR as a coauthor on the commits so he gets credit for helping out. |
…o#14973 (agent loop fix) PR anomalyco#14743 — fix(cache): improve Anthropic prompt cache hit rate - Split system prompt into stable (global) + dynamic (project) blocks - Remove cwd from bash tool schema (was busting cache per-repo) - Freeze date under OPENCODE_EXPERIMENTAL_CACHE_STABILIZATION flag - Add optional 1h TTL on first system block (OPENCODE_EXPERIMENTAL_CACHE_1H_TTL) - Add OPENCODE_CACHE_AUDIT logging for per-call cache accounting - Track global vs project skill scope for stable cache prefix - Add splitSystemPrompt provider option to opt out PR anomalyco#14973 — fix(core): prevent agent loop stopping after tool calls - Check lastAssistantMsg.parts for tool type before exiting loop - Fixes OpenAI-compatible providers (Gemini, LiteLLM) returning finish_reason 'stop' instead of 'tool_calls' when tools were called ci: add FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 to upstream-sync workflow build: relax bun version check to minor-level for local builds
AndresCdo
left a comment
There was a problem hiding this comment.
Excellent work on this PR. The benchmarks are compelling — going from 0% → 97.6% cross-repo cache hit is a massive improvement. I've reviewed the diff in detail and cross-referenced with related work (#5422, #5224, #14065). Here's my feedback:
Strengths
- System prompt split is the right architectural fix. Separating stable (provider prompt + global instructions) from dynamic (env + project) content addresses the root cause of cache invalidation.
- Bash tool schema cwd removal is a clean fix. The model already gets cwd from the environment block, so this is redundant and harmful to cache stability.
- Skill scope classification (global vs project) is a valuable addition that extends beyond caching — it improves prompt organization generally.
- splitSystemPrompt provider option is a good escape hatch for providers that reject multiple system messages.
- Cache audit logging (OPENCODE_CACHE_AUDIT) is excellent for observability and debugging.
Concerns
1. Module-level singletons lack invalidation
session/system.ts and session/instruction.ts use process-lifetime singletons (cachedDate, cached) with no invalidation path. This is fine for the CACHE_STABILIZATION flag's intent, but consider:
- Tests that change the date or instructions between runs will get stale data
- If a user edits their global AGENTS.md mid-session, the cached version won't reflect it
- Multi-instance scenarios (multiple project directories) share the same cache
Suggestion: Add a simple invalidation mechanism, even if it's just clearCache() exported for tests.
2. systemSplit calculation is fragile
In session/prompt.ts, systemSplit = instructions.global.length + (skills.global ? 1 : 0). This assumes the system array ordering is fixed. If any future change reorders this array, the split point will be wrong and cache markers will land on the wrong content.
Suggestion: Consider making the split explicit rather than positional. Pass { stable: string[], dynamic: string[] } instead of { system: string[], systemSplit: number }.
3. MCP tools are still a cache breaker
You correctly note this in 'What this doesn't fix'. A mitigation: sort MCP tools by name (deterministic ordering) and place them after the stable system prefix. This won't eliminate the cache miss but will ensure consistency across turns.
4. 1h TTL as env var vs config
OPENCODE_EXPERIMENTAL_CACHE_1H_TTL is behind an env var. Given the strong benchmarks, this could be a config option to align with #16848 and #244.
Relationship to #5422
PR #5422 takes a different approach (ProviderConfig with 874 lines of provider-specific defaults). This PR is more surgical (243 lines, focused fixes). I'd recommend merging this first as the foundation, then layering #5422's provider-specific defaults on top if needed.
Verdict
This is a well-scoped, well-tested PR that addresses a real performance problem. My concerns are mostly about long-term maintainability rather than correctness — none should block merging. Would love to see the systemSplit fragility addressed before merge.
|
@rekram1-node did you manage to get this split up and merged? |
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