Skip to content

feat(opencode): capture cache-diagnostics and usage cache accounting per request - #159

Open
iceteaSA wants to merge 1 commit into
cortexkit:mainfrom
iceteaSA:feat/cache-diagnostics-capture
Open

feat(opencode): capture cache-diagnostics and usage cache accounting per request#159
iceteaSA wants to merge 1 commit into
cortexkit:mainfrom
iceteaSA:feat/cache-diagnostics-capture

Conversation

@iceteaSA

@iceteaSA iceteaSA commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Closes #157.

The plugin has sent cache-diagnosis-2026-04-07 in its beta list on every request without ever enabling the feature: the beta requires a request-body opt-in (diagnostics.previous_message_id), and nothing sent it or read the response. This PR turns the dormant channel on, measure-only.

What it does

Request side: eligible requests (OAuth routes whose beta set already includes the diagnosis beta) gain "diagnostics": {"previous_message_id": <captured id | null>} inside the existing fail-closed rewriteRequestBody pipeline. API-key fallback routes are untouched. The id sent is only ever one captured from a prior Anthropic response — a bounded per-session tracker holds it. That distinction matters: opencode mints its own msg_01…-shaped message ids, and sending one of those fails silently as previous_message_not_found, which the API also uses for benign fingerprint expiry. The tracker asserts identity (send-what-was-captured), never format.

Response side: the existing SSE wrapper exposes message_start.message through a new typed callback (same pattern as the current fallback-outcome callback; emitted bytes untouched). Per valid eligible response, one machine-parseable record is emitted through the existing logger:

MC-CACHE-DIAG {"v":2,"source":"turn","synthetic":false,"account_id":"main","betas_hash":"8c99bbb1c40c469a","session_id":"ses_…","ts_ms_received":…,"model":"claude-fable-5","is_subagent":false,"ttl_sent":"1h","cache_read":407901,"cache_creation":294,"input_tokens":2,"ephemeral_5m_tokens":0,"ephemeral_1h_tokens":294,"message_id":"msg_…","previous_message_id":null,"diag_state":"server_null"}

Record schema v:2 (the README table is the contract, settled with its first external consumer):

  • Verbatim usage + diagnostics: diag_stateabsent | server_null | pending | populated (always a string, never JSON null), miss_reason / cache_missed_input_tokens only when populated. No derived hit/miss flags, no normalization.
  • Attribution fields: source (open set; known values turn, prewarm_cachekeep) with synthetic as the closed-form machinery/traffic split (published mapping, synthetic wins on conflict, mismatch logs a warn), account_id (opaque persisted identifier — cache entries are account-scoped and sticky routing migrates sessions, so consumer timelines key on (account_id, prefix)), betas_hash (xxh64 of the sorted sent beta list — unavailable fires on beta-set changes), and requested_model present only when the request-body model differs from the served model (labels recovery/fallback switches).
  • Side-channel: first sight of an unseen betas_hash per process emits one MC-CACHE-DIAG-BETAS {"hash","betas":[…]} resolution line. The trailing space in the MC-CACHE-DIAG delimiter is load-bearing (it is what keeps the two line types distinct); grep -c "MC-CACHE-DIAG" without it over-counts.

Dumps: when dumping is enabled, responses get an artifact (status, id, model, usage, diagnostics — never content blocks), and CacheKeep prewarm requests are dumped too, tagged -prewarm-cachekeep- so traffic censuses can exclude keepalive noise. Prewarms also send the opt-in and emit records (source: "prewarm_cachekeep") — the server's TTL clock runs from the last write by any caller, so an unrecorded prewarm would be an invisible clock reset.

Canary: previous_message_not_found on a short-gap turn where an id was actually sent is logged at warn — at short gaps the fingerprint can't plausibly have expired, so it means the id capture broke. This makes the one silent failure mode of the feature self-detecting.

Bounded buffering: every per-chunk stateful consumer in the response wrapper is bounded at 8 MiB with drain-before-cap semantics — complete SSE frames (including message_start and fallback boundaries) are always processed before overflow passthrough engages, so the cap can't drop an id capture or leak an internal fallback block.

Verified against the live API

Chained smoke on a real cacheable prefix (~24.8K tokens):

1. opt-in, no predecessor      -> diagnostics: null   (write=24831)
2. identical + captured id     -> diagnostics: null   (read=24831, hit)
3. forced system change + id   -> {"cache_miss_reason":{"type":"system_changed",
                                    "cache_missed_input_tokens":23963}}

Two behaviors worth knowing that the docs understate: the comparison only engages on cacheable requests (null means "not compared" unless cache activity is nonzero), and on multi-turn agent traffic a populated *_changed routinely coexists with a full prefix hit — consumers must classify hit-first (cache_read is a fact; miss_reason is an interpretation). Both documented in the README.

Gates

bun run test 1084 pass / 0 fail · bun run typecheck clean · bun run lint clean · e2e 26/27 — the one failure is the pre-existing tool-prefix.test.ts stale-Opus-bridge case, which fails identically on a clean b1d8f8c worktree (verified side-by-side; unrelated to this diff).

Pi behavior is unchanged (all new core params optional; call sites read to confirm, not just typechecked).

Relation to #155

Same file (cachekeep.ts), no overlap: this branch deliberately carries none of #155's error-containment scaffolding, so the two merge independently in either order.

Tests were written red-first throughout; the concurrency tests bind different predecessors across interleaved in-flight sessions specifically so a mutable-current-request regression cannot pass.

Greptile Summary

This PR enables per-request Anthropic cache diagnostics and cache-usage accounting while preserving response bytes and bounding response-processing buffers.

  • Adds request opt-in and bounded per-session predecessor-message tracking for eligible OAuth and CacheKeep requests.
  • Emits versioned diagnostics and beta-resolution records with account, session, model, cache, and request attribution.
  • Adds content-free response dump artifacts and identifies CacheKeep prewarm traffic separately.
  • Bounds and resynchronizes the SSE fallback, finish, error, diagnostics, and non-streaming response consumers after oversized input.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
packages/opencode/src/cache-diagnostics.ts Implements bounded predecessor tracking and the versioned cache-diagnostics record contract.
packages/opencode/src/index.ts Integrates diagnostics opt-in, response capture, attribution, logging, dumping, and CacheKeep accounting into request routing.
packages/opencode/src/transform.ts Adds typed response observation and bounded JSON/SSE consumers with delimiter-aware recovery after overflow.
packages/opencode/src/server-fallback.ts Bounds fallback-frame buffering and restores rewriting after oversized frames, including split-delimiter handling.
packages/core/src/cachekeep.ts Adds prewarm body preparation, response observation, diagnostics-compatible response parsing, and tagged dump artifacts.
packages/core/src/dump.ts Adds sanitized response artifacts and CacheKeep-specific dump tagging while retaining bounded dump sweeping.

Sequence Diagram

sequenceDiagram
  participant OC as OpenCode
  participant Plugin as Auth plugin
  participant Anthropic
  participant Logger
  OC->>Plugin: Anthropic request
  Plugin->>Plugin: Resolve captured predecessor
  Plugin->>Plugin: Add diagnostics opt-in and sign body
  Plugin->>Anthropic: Eligible OAuth request
  Anthropic-->>Plugin: JSON or SSE response
  Plugin->>Plugin: Capture message_start/message response
  Plugin->>Plugin: Update bounded session tracker
  Plugin->>Logger: Emit MC-CACHE-DIAG record
  Plugin-->>OC: Forward rewritten bytes
Loading

Reviews (17): Last reviewed commit: "feat(opencode): capture cache-diagnostic..." | Re-trigger Greptile

Comment thread packages/opencode/src/transform.ts

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 5 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/opencode/README.md

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed

Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.

Re-trigger cubic

Comment thread packages/core/src/cachekeep.ts
Comment thread packages/opencode/src/cache-diagnostics.ts
Comment thread packages/opencode/src/transform.ts
Comment thread packages/opencode/src/index.ts
Comment thread packages/opencode/src/tests/index.test.ts Outdated
Comment thread packages/core/src/dump.ts

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 3 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/opencode/src/cache-diagnostics.ts
@iceteaSA

Copy link
Copy Markdown
Contributor Author

Triaged all bot findings against the code; 42ec968 fixes the three that held up.

Fixed in 42ec968:

  • Non-streaming diagnostics buffering is now capped at 8 MiB (NON_STREAMING_DIAGNOSTICS_MAX_BYTES, matching the sanitize-memo cap). On overflow: no record, pass-through untouched. Covers greptile's and cubic's buffer findings.
  • Prewarm test asserted its flag after a 20 ms sleep instead of after the awaited start promise — reordered (cubic, index.test.ts).
  • dumpRequest returned a non-null handle when the dump write failed, letting a later .response.json orphan appear without companion body/meta — now returns null on write failure (cubic, dump.ts).

Refuted, with the code path:

  • "Prewarm header refresh strips the diagnosis beta" (cubic P1): the refresh rebuilds headers through setOAuthHeadersselectClaudeCodeBetas, and all three beta sets (FULL_AGENT/STRUCTURED_OUTPUT/BASE) carry cache-diagnosis-2026-04-07 at base. The merge afterwards only adds the extended-TTL beta. Prewarm requests keep the beta on the wire.
  • "Pending state skips cache_missed_input_tokens validation" (cubic P2): that field lives inside cache_miss_reason; in the pending state the reason is null, so there is no field to validate. The populated path validates it and rejects non-numbers.
  • "README inconsistent on the token source" (cubic P3): README table says diagnostics.cache_miss_reason.cache_missed_input_tokens; the classifier reads reason.cache_missed_input_tokens. Consistent at head.

Declined:

  • Merge user-supplied diagnostics instead of overwriting (cubic P2): overwrite is deliberate. The chain's integrity depends on only response-captured ids entering previous_message_id; merging would let a caller-supplied id through the identity assertion, and the failure mode of a foreign id is silent (previous_message_not_found reads as benign expiry).
  • Bump v for the server_null rename (cubic P3): the rename landed pre-release in the same PR that introduces v:1; no consumer ever pinned the old value. First released contract is v:1 with server_null.

Comment thread packages/opencode/src/transform.ts

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 5 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/opencode/src/transform.ts
Comment thread packages/core/src/tests/dump.test.ts Outdated
@iceteaSA

Copy link
Copy Markdown
Contributor Author

Second round was right on both counts — 42ec968 was incomplete, corrected in 7b45b0f.

  • The 8 MiB cap bounded responseText but json-mode chunks still flowed into updateSseDiagnostics, whose line buffer never drains without an SSE boundary — the full body accumulated there anyway. Fixed at the call site: json mode no longer feeds the SSE diagnostics state at all (a JSON body has no SSE events; the json path delivers its message from the end-of-stream parse, which doesn't touch that state). Over-cap test now asserts ssePendingChars: 0.
  • The orphan-dump test was exercising mkdir failure, not write failure, and asserting in a directory where the orphan couldn't appear. Rewritten against a chmod-restricted real directory (skipped when running as root), red-green proven against the dump.ts null-handle fix.

@iceteaSA
iceteaSA force-pushed the feat/cache-diagnostics-capture branch from 7b45b0f to fc2073b Compare August 20, 2026 05:57
Comment thread packages/opencode/src/transform.ts Outdated
Comment thread packages/opencode/src/transform.ts

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 3 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/opencode/src/server-fallback.ts Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 3 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/opencode/src/transform.ts Outdated
Comment thread packages/opencode/src/transform.ts Outdated
@iceteaSA
iceteaSA force-pushed the feat/cache-diagnostics-capture branch 3 times, most recently from 3b9f126 to 57171fb Compare August 20, 2026 17:33
@iceteaSA

Copy link
Copy Markdown
Contributor Author

Greptile's summary was right — updateSseFinishState was the last consumer still checking its cap before draining, so an over-cap chunk carrying a complete terminal frame could discard the message_delta/stop_reason and skip completion/content-filter classification. Fixed at head (57171fb, amended into the single commit): same drain-first shape as the other three consumers — append → drain complete frames (terminal detection included) → cap only the incomplete tail. Red-proven with refusal-terminal and end_turn-terminal frames ahead of over-cap tails.

All four per-chunk consumers now share identical drain-before-cap semantics. If the repetition bothers anyone at merge time, the structural follow-up is one shared bounded-drain helper — happy to do that as a separate PR rather than grow this one.

Comment thread packages/opencode/src/server-fallback.ts Outdated
@iceteaSA
iceteaSA force-pushed the feat/cache-diagnostics-capture branch from 57171fb to a8eea4b Compare August 20, 2026 17:49
Comment thread packages/opencode/src/transform.ts
Comment thread packages/opencode/src/server-fallback.ts
@iceteaSA
iceteaSA force-pushed the feat/cache-diagnostics-capture branch from a8eea4b to 5f3d05e Compare August 20, 2026 18:05
Comment thread packages/opencode/src/transform.ts Outdated
@iceteaSA
iceteaSA force-pushed the feat/cache-diagnostics-capture branch from 5f3d05e to c1b7101 Compare August 20, 2026 18:22
iceteaSA added a commit to iceteaSA/anthropic-auth that referenced this pull request Aug 20, 2026
…synthetic turn

Adds an OpenCode-only /claude-start command: queue one synthetic
one-token turn through the current session's normal model, agent,
variant, quota, routing, cache, relay, signing, and response pipeline,
so a resumed session's prompt cache is re-warmed and its TTL clock
refreshed without an operator turn.

The synthetic prompt ([lane start] - automated cache warm; no response
needed.) is injected via the session SDK with synthetic:true and the
session's context resolved from message history; OpenCode assembles the
request exactly as a real turn, so the warm is byte-exact by
construction. The fetch layer correlates the request by its synthetic
message id (exact-match, one-shot, session-scoped, bounded) and shapes
it fail-closed: max_tokens 1, thinking stripped, streaming kept. The
terminal max_tokens stop is rewritten to end_turn for the correlated
turn only, so the session records a clean micro-turn. CacheKeep adopts
the session afterward; dumps tag start requests -start- on direct and
relay paths; diagnostics records carry source:start with synthetic:true.
API-key routes are unreachable by all of this. /claude-start off is
persisted (and logged to the command audit trail); automatic is
reserved and replies honestly that it is not yet wired.

Verified live on a ~460K-token session: start read=459,602 write=419;
next real turn read=460,021 = start read + write exactly - shaping does
not fork the cache key. ~$0.48 per start vs ~$9.26 for a cold rewrite.

Stacked on the cache-diagnostics capture branch (cortexkit#159).
…per request

The plugin has sent cache-diagnosis-2026-04-07 in its beta list on every
request without enabling the feature: the beta requires a request-body
opt-in (diagnostics.previous_message_id), and nothing sent it or read
the response. Turn the dormant channel on, measure-only.

Request side: eligible OAuth requests gain the diagnostics opt-in inside
the fail-closed rewriteRequestBody pipeline. The id sent is only ever
one captured from a prior Anthropic response (bounded per-session
tracker) - opencode mints its own msg_01-shaped ids for every provider,
and a foreign id fails silently as previous_message_not_found.

Response side: the existing SSE wrapper exposes message_start.message
through a typed callback; per valid eligible response one MC-CACHE-DIAG
single-line JSON record (schema v:2) is emitted via the logger with
verbatim usage, TTL-bucket accounting, diag_state
(absent|server_null|pending|populated, always a string), populated
cache_miss_reason.type, and attribution fields: source (open set) +
synthetic (closed machinery/traffic split with published mapping),
account_id (opaque persisted identifier; consumer timelines key on
(account_id, prefix) because sticky routing migrates sessions across
account-scoped caches), betas_hash (xxh64 of the sorted sent beta list,
resolved by a once-per-hash MC-CACHE-DIAG-BETAS side-channel line), and
requested_model present only on request/served divergence.

Dumps: responses gain artifacts (status/id/model/usage/diagnostics,
never content); CacheKeep prewarms are dumped tagged -prewarm-cachekeep-
and emit records through the same chain, since any write resets the
server TTL clock.

Canary: short-gap previous_message_not_found with a genuinely-sent id
logs a warn - the id capture broke, not a fingerprint expiry.

Every per-chunk stateful consumer in the response wrapper is bounded at
8 MiB with drain-before-cap semantics; complete frames are processed
before overflow passthrough engages.

Verified live: null(write=24831) -> null(read=24831, hit) ->
system_changed(cache_missed_input_tokens=23963) on a forced system
change against a warm prefix. Comparison engages only on cacheable
requests; consumers classify hit-first (documented in README, which is
the record contract).

Closes cortexkit#157
@iceteaSA
iceteaSA force-pushed the feat/cache-diagnostics-capture branch from c1b7101 to b5d77c3 Compare August 20, 2026 20:18
iceteaSA added a commit to iceteaSA/anthropic-auth that referenced this pull request Aug 20, 2026
…synthetic turn

Adds an OpenCode-only /claude-start command: queue one synthetic
one-token turn through the current session's normal model, agent,
variant, quota, routing, cache, relay, signing, and response pipeline,
so a resumed session's prompt cache is re-warmed and its TTL clock
refreshed without an operator turn.

The synthetic prompt ([lane start] - automated cache warm; no response
needed.) is injected via the session SDK with synthetic:true and the
session's context resolved from message history; OpenCode assembles the
request exactly as a real turn, so the warm is byte-exact by
construction. The fetch layer correlates the request by its synthetic
message id (exact-match, one-shot, session-scoped, bounded) and shapes
it fail-closed: max_tokens 1, thinking stripped, streaming kept. The
terminal max_tokens stop is rewritten to end_turn for the correlated
turn only, so the session records a clean micro-turn. CacheKeep adopts
the session afterward; dumps tag start requests -start- on direct and
relay paths; diagnostics records carry source:start with synthetic:true.
API-key routes are unreachable by all of this. /claude-start off is
persisted (and logged to the command audit trail); automatic is
reserved and replies honestly that it is not yet wired.

Verified live on a ~460K-token session: start read=459,602 write=419;
next real turn read=460,021 = start read + write exactly - shaping does
not fork the cache key. ~$0.48 per start vs ~$9.26 for a cold rewrite.

Stacked on the cache-diagnostics capture branch (cortexkit#159).
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.

[BUG] cache-diagnosis beta is requested on every call but the response is never read

1 participant