Skip to content

Fix the cache key source and provenance; stop writing conversation-identity headers - #4

Open
hayate wants to merge 19 commits into
JackDrogon:mainfrom
hayate:rework-cache-key-and-headers
Open

Fix the cache key source and provenance; stop writing conversation-identity headers#4
hayate wants to merge 19 commits into
JackDrogon:mainfrom
hayate:rework-cache-key-and-headers

Conversation

@hayate

@hayate hayate commented Aug 30, 2026

Copy link
Copy Markdown

Hi - thanks for building this. I forked it because I wanted it, and the core insight is yours: spotting that opencode derives the prompt cache key from the session ID, so every new session starts cold even when the prefix is byte-identical, is the part that actually required noticing something. Pinning it per project is the right fix. Everything in this PR is execution detail on top of that diagnosis.

I then went further than I meant to while making it work in a multi-project setup, and this ended up much bigger than a normal drive-by PR. It also removes a feature your README advertises, which I know is a lot to ask from a stranger. So I've tried to make every claim independently checkable, and I'm genuinely happy to cut this down - if the size is the problem, say the word and I'll send just the process.cwd() fix as a small standalone PR and we can take the rest separately, or not at all.

Everything below was verified against opencode 1.18.25 (~/.opencode/bin/opencode), not inferred.

What I hit while running it across several projects

1. The cache key comes from process.cwd(), but the plugin factory is per-project.

I loaded a probe plugin into one opencode serve started from a third directory, then asked it for two separate projects:

--- invocation 1 ---            --- invocation 2 ---
  directory: .../probe            directory: .../probe2
  worktree:  .../probe            worktree:  .../probe2
  cwd:       /home/andrea         cwd:       /home/andrea

The factory runs once per project with correct per-project values, while process.cwd() stays the server's launch directory. Both projects therefore get the identical key andrea@host:/home/andrea. PluginInput already carries directory and worktree.

2. Only the camelCase spelling is written, so deepinfra and cerebras are unaffected.

Core picks the field name per provider:

if ($.providerOptions?.setCacheKey !== false) {
  if (npm === "@ai-sdk/deepinfra" || npm === "@ai-sdk/cerebras")  Z.prompt_cache_key = $.sessionID;
  else if (npm === "@ai-sdk/openai" || azure || xai || mistral || venice || setCacheKey === true)
                                                                  Z.promptCacheKey  = $.sessionID;
}

3. The key is written unconditionally. That overwrites an explicit providerOptions value or another plugin's, and ignores setCacheKey: false.

4. Two precedence levels can't be reached in practice. getUserHostDirectoryKey() returns null only if hostname() or process.cwd() throws, so levels 4 and 5 never run. alreadyHashed is only set in level 4, so the digest-detection branch never fires either. Easy to miss - the chain reads correctly, it's just that level 3 always succeeds.

The part I expect you'll want to push back on

The plugin stops writing x-session-id, conversation_id and session_id.

Those names identify a conversation, and a project-stable value is the wrong thing to put in them. The concrete cost is on opencode's own OpenAI/Codex path, where x-session-affinity keys a WebSocket connection pool with mutable per-conversation state:

let N = A["x-session-affinity"] ?? A["session-id"];
let V = `${N}:conversation`;
let D = Q.get(V) ?? { lastUsedAt: Date.now(), busy: false, fallback: false, streamFailures: 0 };
if (D.fallback) return Z(H, O);
if (D.busy)     return Z(H, O);
D.busy = true;
D.socket = await NA(D, ...);

With a per-project value, two concurrent sessions in one project share one socket - the second sees busy and silently drops to the slower HTTP path. Worse, D.fallback is sticky and set on MESSAGE_TOO_BIG, so one oversized message in any session disables the fast path for every session in that project.

I want to be fair about the limits of that evidence: this pool is on opencode's built-in OpenAI/Codex path, not universal, and it says nothing about Azure, xAI, Mistral, or a third-party relay. It's an existence proof that the cost is real, not the whole argument. The general argument is the semantic mismatch plus redundancy - core already sends x-session-affinity and X-Session-Id derived from the real session ID, so the plugin's versions duplicate core wherever they're understood at all.

There's also a plain bug here independent of the design question: the plugin writes x-session-id while core writes X-Session-Id. In a JS object spread those are distinct keys, so both survive into the request and only collapse at the HTTP layer.

I considered keeping the headers and populating them from the per-request sessionID instead. I didn't, because that's exactly what core already emits. If you'd rather keep them for gateway compatibility, I'd suggest an opt-in flag and a major-version note; happy to rework it that way.

What replaces it

The key is sha256("<user>@<host>:<worktree>"), resolved once per plugin instance from PluginInput, and applied only when the field still holds core's session-ID default:

if (current === sessionID || current === stripSesPrefix(sessionID)) replace(field);

That's exact provenance and it inherits core's entire provider table without duplicating it - setCacheKey: false means core writes nothing so we write nothing, and deepinfra gets its snake_case spelling automatically. It also leaves a deliberate operator or plugin value alone.

Scope is configurable (OPENCODE_CONTEXT_CACHE_SCOPE: worktree default, directory, session), because for providers that treat this as a cache lookup key rather than a routing hint - DeepInfra documents it that way and suggests per-session - a whole-worktree key may be too broad.

Where this plugin does not apply

Adding this after a review comment on my fork: the scope is narrower than the
original README's "works with ALL providers", and it is worth stating plainly
rather than leaving someone to discover it from a warning.

Core seeds a cache key field only for a fixed set of provider SDKs, so that is
exactly the set this plugin can affect:

Applied for: OpenAI, Azure, xAI, Mistral, Venice (promptCacheKey),
DeepInfra, Cerebras (prompt_cache_key), opencode's own provider, and anything
opted in with setCacheKey: true.

Nothing is applied for Anthropic, DeepSeek, and every other
@ai-sdk/openai-compatible provider - which is most of the catalog. Anthropic
caches via cache_control breakpoints; DeepSeek's context caching is
automatic and prefix-based,
"enabled by default for all users, allowing them to benefit without needing to
modify their code". Neither has a cache key parameter, so there is no correct
value to write.

That was already true of the current version - it wrote camelCase
promptCacheKey for these providers, and the openai-compatible SDK forwards
unrecognised options into the request body verbatim, so a literal
"promptCacheKey" field went out on the wire where no OpenAI-style API reads
it. The difference is that this PR does not pretend otherwise.

I originally had the plugin warn once per provider in that case. I have since
made it silent and pushed that here, because the warning fired on a
perfectly normal DeepSeek setup, read like a breakage, and is the fastest way to
train someone to ignore the same channel when it reports something real. The
four warnings that remain all mean the plugin wanted to act on a provider that
does support the field and could not: a renamed shape, a missing sessionID,
a foreign value, an empty field. The unsupported-provider case is in the debug
log as reason=no-fields, and the README now carries the full list above.

One trap documented alongside it: setCacheKey: true is not a way to force this
on for DeepSeek. Core routes that flag to the camelCase branch, so on an
openai-compatible provider it produces the same inert "promptCacheKey" body
field - no benefit, and a 400 from any endpoint that validates strictly.

Documentation changes, flagged rather than slipped in

I narrowed three README statements. To be clear about why: all three were reasonable from what you could observe from the outside, and I only know otherwise because I spent an evening reading a compiled Bun binary, which is not a reasonable bar for writing a README.

  • "Works with ALL providers" - true of the plugin's intent, but the mechanism is OpenAI-family only: Anthropic caches via cache_control breakpoints on content blocks and ignores a cache key entirely. Nothing in the API surface tells you that.
  • "SHA256 ... for privacy" - hashing genuinely does keep your local paths off the wire, which is worth doing and worth saying. I only narrowed it because the pre-image is user@host:/absolute/path, so it is obfuscation rather than a security property. Reworded, not removed.
  • The 97.99% figure - kept, and I have no reason to doubt it. I labelled it as a single uncontrolled observation because that is what it is, and it protects you from someone measuring a different workload and calling it a regression.

Also included

package.json so it installs by npm identifier; the debug log moved out of the plugin directory (the README's own ./plugins/... example writes it into the user's repo, and there's no .gitignore); always-on deduplicated operator warnings; explicit overrides bounded to what a provider accepts; 89 unit tests and 10 opt-in integration tests; CI on Node 20 and 22. Zero dependencies, and it's still a single self-contained file so the copy-one-file install still works.

One thing worth knowing regardless of this PR

opencode refuses a plugin module that exports anything other than functions, and it calls every distinct exported function as a plugin factory:

function Gy(x){ if (typeof x === "function") return x;
                if (!x || typeof x !== "object" || !("server" in x)) return;
                if (typeof x.server !== "function") return;  return x.server }
function Wy(m){ for (const x of Object.values(m)) { ...
                  if (!Gy(x)) throw TypeError("Plugin export is not a function"); ... } }

The current version is fine because its three exports are the same function object, which the loader dedupes. I hit this the hard way when I exported the helpers for testability: the plugin became completely unloadable while the whole test suite stayed green. Helpers now hang off the factory as an internals property, and test/unit/export-shape.test.mjs reproduces the loader's check so it can't regress.

Verification

npm test (90), npm run test:integration (10, skips automatically without an opencode binary), plus a manual load against 1.18.25. The integration suite doubles as a compatibility gate: it asserts the opencode behaviours this design depends on against the binary, so an upgrade that renames one fails loudly instead of leaving the plugin quietly inert.

The full design document and implementation plan, including what each review round found and what got rejected, live on my fork's main under docs/superpowers/. I deliberately kept them out of this PR - that's my process, not something your tree should carry - but they're there if you want more depth than this description gives.

Last thing: this is your project and your call. If the answer is "just the process.cwd() fix, thanks", that's a completely fine outcome and I'll happily send that as a two-line PR instead. I'd rather the bug got fixed than that my version of it got merged.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Knq5gs2QViXBJnSKQYqDih

hayate added 18 commits August 30, 2026 18:21
Records the verified opencode core behavior this change depends on:
the session-ID-derived prompt cache key, the per-provider option name
split, and the x-session-affinity WebSocket connection pool that makes
sharing conversation identity across sessions unsafe.
Replaces the field-presence check with an exact provenance test against
sessionID, bounds explicit overrides, separates operator warnings from the
debug log, softens two overclaims, and rewrites the test plan around
hook-level and integration coverage. Records what was cleared and what was
declined so a later round does not re-derive it.
Six TDD tasks: resolution, provenance, logging, factory, an opt-in
opencode compatibility gate, and CI plus README.
Records the second Codex round: the tri-state result could not represent
the states the error table distinguishes, scope=session did not opt out,
and three findings were folded in as prose without code.
Replaces the process.cwd() key with one derived from PluginInput, bounds
explicit overrides to what a provider will accept, and makes scope=session
a hard opt-out. The plugin loads and is inert; applying the key follows.

Adds a pretest guard because node --test silently ignores a named file
that does not exist and still exits 0, which would let a renamed suite
vanish from CI unnoticed.
Field presence does not prove provenance; matching opencode's session ID
does. Reports applied and foreign fields separately so a mixed conflict
is visible rather than silently half-applied.
Compatibility failures must not depend on the operator having already
enabled debug logging. The sink is wrapped so a failing stderr cannot
escape into the request path.
Wires provenance-checked application into chat.params, warns once per
provider on a missing or foreign field, and keeps debug-only states out
of the operator channel.
Asserts the two facts this design rests on - one plugin instance per
project, and worktree as the VCS root - against the real binary, and
checks the resolver turns them into distinct keys. Skips when no binary
is installed, so CI stays green.
Documents the header removal as a breaking change with migration
guidance, drops the all-providers and privacy claims, and labels the
cache hit figure as a single uncontrolled run.
opencode walks Object.values(module) and throws 'Plugin export is not a
function' on any non-function export, then invokes every distinct
function export as a plugin factory. Exporting the helpers for
testability made the plugin unloadable while all 57 tests stayed green;
a real opencode run surfaced it.

Helpers now hang off the factory as a frozen  property, and
export-shape.test.mjs reproduces the loader check so this cannot regress.
Silent-failure review found the hook could still throw: every use of the
provider label is a template literal, so a non-string providerID threw on
ToString, and the catch handler repeated the mistake. homedir() was the one
syscall of three left unguarded, and as a default parameter it ran on every
createLogger call, so a container without HOME failed the plugin load
outright rather than degrading to inert.

Also inverts the warning policy. invalid-options and missing-session were
debug-only, so four separate upstream renames would leave the plugin
permanently inert with no signal at default settings; a test asserted that
silence. They now warn accurately, an unset field is reported as empty
rather than as someone else's key, warnings mirror into the debug log, and
the error dedup key includes the error so a second unrelated failure is not
suppressed forever.
The factory and resolveCacheKey each assembled the scope input, so a third
source would have needed updating in two places.
Records opencode's plugin export contract, which no review caught and only
a real run surfaced, and corrects the error table: invalid-options and
missing-session are operator warnings, not debug-only.
The fix wave added warnings for upstream shape changes, empty fields,
placeholder identity and startup failures; the troubleshooting section
still described only two.
warnOnce reached the debug log through 'this', so a destructured
'const { warnOnce } = logger' would have thrown. Field detection used
'in', which sees the prototype chain, while the replacement spread copies
only own properties.
…t gate

Six mutations survived the previous suite: config options never reached
the resolver, the override-redaction assertion was structurally incapable
of failing, no test enabled debug through the factory, the hook's early
return was unverified, and the root/nested test passed against a dead
hook. All six now die.

Adds test/integration/opencode-contract.test.mjs, which asserts the
opencode facts this design rests on against the binary itself, so the
snippets the spec hand-copied are now executable and an upgrade that
renames one fails loudly instead of leaving the plugin quietly inert.

Also isolates the integration suite from a developer's global git config,
realpaths temp roots for macOS, and polls for probe records instead of
sleeping.
The spec and implementation plan are this fork's development record, not
something upstream's tree needs to carry. Everything an outside reader
needs - the evidence for each defect, the alternatives considered and
rejected, and the known limitation - is in the pull request description.

They remain on the fork's main branch for anyone who wants the full
design and review history.
Core seeds a prompt cache key only for a fixed set of provider SDKs.
Anthropic caches by cache_control breakpoint, and every
@ai-sdk/openai-compatible provider - DeepSeek among them - has no cache
key in its API at all, so for those there is nothing correct to write.

Warning about it fired on a routine configuration, read as a possible
breakage, and is the fastest way to teach an operator to ignore the
channel that also carries the states which do mean something: a renamed
field, a missing sessionID, a foreign value on a provider that does
support one. Those stay loud. This one moves to the debug log, where it
keeps the explanation for anyone actually asking why no key was applied.

The provider-label fallback test asserted against this warning, so it now
observes the foreign-key path instead - a silent channel would have left
that behaviour with no coverage at all.

README gains a Provider support section naming both lists explicitly,
including why setCacheKey: true is the wrong lever for an
openai-compatible provider: core routes it to the camelCase spelling and
the SDK forwards unrecognised options into the body verbatim, so the wire
gets "promptCacheKey", which no OpenAI-style API reads.
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.

1 participant