Prompt Cache Warmer
OpenCode v2 plugin that keeps provider prompt caches warm by replaying the last assembled request.
0
0
20.0
Multi-signal model
2 days ago
2026-10-18
Install and configure
opencode.jsonWrites to this project's opencode.json — applies to this repository only.
opencode.json
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["@mxalbert/opencode-prompt-cache-warmer@1.0.0"]
}Writes to ~/.config/opencode/opencode.json — applies to every project.
~/.config/opencode/opencode.json
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["@mxalbert/opencode-prompt-cache-warmer@1.0.0"]
}If you want to modify the plugin locally, install it into the project and reference the local path.
shell
pnpm add -D @mxalbert/opencode-prompt-cache-warmerOpenCode loads npm dependencies through its embedded runtime on startup and caches them locally — no manual global install needed.
OpenCode v2 plugin that keeps a provider's prompt cache warm by replaying the last assembled request instead of sending a fresh, unrelated keep-alive prompt.
Why
Providers cache a request's prefix for a few minutes. OpenCode's built-in
warming ("warming": true) sends its keep-alive through session.generate,
which only fires the generate session hook. Plugins that contribute system
parts or messages on context are therefore missing from the warm request, so
it shares no cacheable prefix with real turns and warms nothing useful.
This plugin observes the real request as the host assembles it — after every configured plugin that mutates the request fields has had its turn — and replays the captured hook-visible prompt fields with the keep-alive appended as a final user message. The warm request then contains the same prompt prefix as the real turn, which is the most a warm request can hit.
Replay covers the four fields the hook exposes: system, messages,
options, and tool descriptions/schemas. Everything else stays derived from
the warm request itself — toolChoice, transport selection (a warm request is
always HTTP, never the primary's WebSocket), and any model.request hook
adjustments. Those do not affect the cached prefix.
Install
{
"plugins": ["@mxalbert/opencode-prompt-cache-warmer"],
"warming": false
}
Set "warming": false. This plugin replaces the built-in warmer; leaving both
on warms every session twice.
The plugin must be last in plugins
Hook callbacks run in registration order, and activation is serial: built-in request-shaping plugins, then SDK and instance plugins, then configured packages in config order, then a trailing group of built-in config plugins that derive their contributions from configuration (instructions, agents, commands, providers, skills, and similar).
So last-in-config makes this plugin last among configured plugins, which is
the part that matters: it sees everything the other configured plugins
contributed to the request. The trailing group can still register after it, but
none of those plugins register a context hook, so the captured snapshot is
complete.
The guarantee is "last among configured plugins," not "last overall." If it is not last, the captured snapshot is incomplete and the warm request misses part of the cache — degraded, never corrupt: the real turn is unaffected, the warm request is simply less useful.
Options
{
"plugins": [
{
"package": "@mxalbert/opencode-prompt-cache-warmer",
"options": {
"enabled": true,
"interval": "4 minutes",
"duration": "30 minutes",
"models": ["!opencode-go/deepseek-*"],
"prompt": "This is a keep-alive request. Do not perform any work or use tools. Reply with exactly: OK"
}
}
]
}
| Option | Default | Description |
|---|---|---|
prompt |
see above | Text of the appended keep-alive message. |
interval |
4m |
Idle time before a warm request; also the re-arm delay between warms. |
duration |
30m |
How long after the last real request warming continues before it stops. |
models |
all models | Which models to warm. Absent or empty means every model. |
enabled |
true |
Set false to load the plugin without warming anything. |
interval and duration accept milliseconds as a number or a string with a
unit: "90s", "4m", "4 minutes", "1 hour". A non-finite, non-positive,
overflowing, or over-long value disables the plugin and logs a warning.
interval is capped at 2,147,483,647 ms (about 24.8 days) because larger
delays are clamped by the runtime.
models
Some providers hold a prompt cache for hours, so those models gain little from warming. Exclude them:
{
"models": ["!opencode-go/deepseek-*", "!openai/*"]
}
Providers with a short cache TTL are the opposite case — Anthropic's cache
typically expires in minutes, which is exactly what warming exists to prevent.
To warm only a select set, use plain patterns instead of a leading !:
{
"models": ["anthropic/*"]
}
A value is a pattern or an array of patterns. * matches any run of characters
and ? matches one; matching is case-insensitive and a dot is literal. Each
pattern is tested against three forms of the active model:
- the full key,
providerID/modelID#variant(for exampleanthropic/claude-haiku#high) - the provider-qualified id,
providerID/modelID - the bare model id
A leading ! marks an exclusion. Exclusions are checked first and always win.
If any plain pattern is present, a model must match one to be warmed; with no
plain patterns, every non-excluded model is warmed. An empty list is the same as
omitting the option. A malformed value — a non-string entry, a blank string, or
a bare ! — disables the plugin and logs a warning, the same as an invalid
interval.
The filter is checked when a request is assembled, so a session that switches to a skipped model stops scheduling further warms, and switching back resumes after the next real request. A warm request that was already dispatched cannot be recalled; it completes against the model it was sent for.
Behavior
- Capture — on every
contexthook run, the assembledsystem,messages,options,tools,agentand model are recorded for that session, and the session's idle timer restarts. Subagent sessions (parentIDset) and sessions whose model themodelsfilter excludes are skipped, and each session's status is cached in a bounded session-status cache. - Warm — after
intervalof inactivity, if the last real request is still withinduration, the plugin callssession.generatewith the keep-alive prompt. Afinally-balanced in-flight counter lets thegeneratehook recognize its own request, and the request must also end with the configured keep-alive prompt, so a concurrentgeneratecall from another plugin on the same session is passed through untouched. The one case this cannot separate is a foreign concurrentgeneratewhose prompt is byte-identical to the configured keep-alive: the plugin cannot tell it apart and will replay it. - Replay — for its own warm request only, the plugin restores the captured
system,optionsand tool descriptions/schemas, setsmessagesto the captured history plus the keep-alive, and skips this entirely if the snapshot is missing, expired, or was captured for a different model or agent. Tools are restored onto the objects the host already supplied, because the host resolves each tool by object identity; a tool whose key acontexthook renamed cannot be matched back and is left alone. - Invalidate — the snapshot is dropped on any event that can change the selected transcript: compaction start/end/failure, revert stage/clear/commit, agent or model selection, instruction updates, message content updates, skill activation, foreground shell completion, synthetic messages, and session deletion, moves and forks. Background shell results never reach the model, so they do not cancel warming.
Coverage
Because it replays the last assembled request, this works with any plugin
whose injections are visible in the context event — regardless of which hooks
that plugin registers. That covers the common case of a plugin that appends
system parts or messages on context only.
It does not cover cases where the warm request's own assembly differs in a way
the context event cannot express: a context hook that renames tool keys, a
hook that runs after generate (such as model.request), or transport
selection. In those cases the warm request still works, it just shares a
smaller prefix.
It does not help the turn after the next real one beyond the shared prefix. Each warm request extends the provider's cache window for the same prefix; it adds no new context of its own.
Development
bun install
bun run build # dist/server/index.js and dist/index.js
bun test
bun run typecheck
The state machine lives in src/warmer.ts, which has no host imports and is
unit tested directly.
License
MIT
Similar plugins
Cache Compact
opencode-cache-compact
Cache-friendly context compaction for OpenCode (V1 and V2): append a summary in place, then cut what the model sees down to it.
Zhihu Websearch
opencode-zhihu-websearch
Zhihu Web Search as an OpenCode websearch provider
Vigia
opencode-vigia
OpenCode plugin that helps web developers find, prove, and fix vulnerabilities with open weights models