Fix the cache key source and provenance; stop writing conversation-identity headers - #4
Open
hayate wants to merge 19 commits into
Open
Fix the cache key source and provenance; stop writing conversation-identity headers#4hayate wants to merge 19 commits into
hayate wants to merge 19 commits into
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 servestarted from a third directory, then asked it for two separate projects: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 keyandrea@host:/home/andrea.PluginInputalready carriesdirectoryandworktree.2. Only the camelCase spelling is written, so deepinfra and cerebras are unaffected.
Core picks the field name per provider:
3. The key is written unconditionally. That overwrites an explicit
providerOptionsvalue or another plugin's, and ignoressetCacheKey: false.4. Two precedence levels can't be reached in practice.
getUserHostDirectoryKey()returnsnullonly ifhostname()orprocess.cwd()throws, so levels 4 and 5 never run.alreadyHashedis 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_idandsession_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-affinitykeys a WebSocket connection pool with mutable per-conversation state:With a per-project value, two concurrent sessions in one project share one socket - the second sees
busyand silently drops to the slower HTTP path. Worse,D.fallbackis sticky and set onMESSAGE_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-affinityandX-Session-Idderived 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-idwhile core writesX-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
sessionIDinstead. 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 fromPluginInput, and applied only when the field still holds core's session-ID default:That's exact provenance and it inherits core's entire provider table without duplicating it -
setCacheKey: falsemeans 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:worktreedefault,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 anythingopted in with
setCacheKey: true.Nothing is applied for Anthropic, DeepSeek, and every other
@ai-sdk/openai-compatibleprovider - which is most of the catalog. Anthropiccaches via
cache_controlbreakpoints; DeepSeek's context caching isautomatic 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
promptCacheKeyfor these providers, and the openai-compatible SDK forwardsunrecognised options into the request body verbatim, so a literal
"promptCacheKey"field went out on the wire where no OpenAI-style API readsit. 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: trueis not a way to force thison for DeepSeek. Core routes that flag to the camelCase branch, so on an
openai-compatible provider it produces the same inert
"promptCacheKey"bodyfield - 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.
cache_controlbreakpoints on content blocks and ignores a cache key entirely. Nothing in the API surface tells you that.user@host:/absolute/path, so it is obfuscation rather than a security property. Reworded, not removed.Also included
package.jsonso 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:
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
internalsproperty, andtest/unit/export-shape.test.mjsreproduces 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
mainunderdocs/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