Skip to content

fix: propagate CodeWhisperer cache token usage instead of hardcoding zero - #129

Open
q8247990 wants to merge 1 commit into
tickernelz:masterfrom
q8247990:fix/cache-token-usage
Open

fix: propagate CodeWhisperer cache token usage instead of hardcoding zero#129
q8247990 wants to merge 1 commit into
tickernelz:masterfrom
q8247990:fix/cache-token-usage

Conversation

@q8247990

@q8247990 q8247990 commented Sep 3, 2026

Copy link
Copy Markdown

Problem

The SDK streaming path (src/plugin/streaming/sdk-stream-transformer.ts, lines 315-316) and the SDK non-streaming path (src/core/request/response-handler.ts, lines 171-174 / 197-201) emitted cache_creation_input_tokens: 0 and cache_read_input_tokens: 0 as hardcoded literals, regardless of what the backend actually returned. The streaming loop already captured contextUsagePercentage from event.metadataEvent, so adding the cache fields alongside it is the natural minimal extension.

The CodeWhisperer streaming SDK's TokenUsage type carries optional cacheReadInputTokens and cacheWriteInputTokens fields — confirmed in @aws/codewhisperer-streaming-client dist-types/models/models_0.d.ts (TokenUsage.cacheReadInputTokens?: number | undefined, cacheWriteInputTokens?: number | undefined) and the corresponding JSON schema in dist-es/schemas/schemas_0.js (cacheReadInputTokens, cacheWriteInputTokens). The SDK writes these values into MetadataEvent.tokenUsage on the wire; we just weren't reading them.

Field mapping:

  • cacheReadInputTokensusage.cache_read_input_tokens
  • cacheWriteInputTokensusage.cache_creation_input_tokens

Impact

OpenCode's cost and context accounting can never reflect cache reads or writes. A 24-hour / 806-request session against a sonnet-class model showed cache.read = 0 on every single request, even when the backend's prompt prefix was clearly stable — the numbers on the wire were nonzero, the values we emitted were always zero. Users have no signal to tell whether the backend actually hit its cache.

Fix

Three changed spots in source, one new test file:

  1. src/plugin/streaming/sdk-stream-transformer.ts — capture cacheReadInputTokens / cacheWriteInputTokens from event.metadataEvent.tokenUsage alongside the existing contextUsagePercentage capture; replace the hardcoded zeros in the final message_delta usage emission with the captured values.
  2. src/core/request/response-handler.ts (handleSdkNonStreaming) — read the same two fields from event.metadataEvent.tokenUsage while it already reads inputTokens/outputTokens; include them in the OpenAI-shaped usage object on the response.
  3. src/plugin/streaming/openai-converter.ts — propagate cache_creation_input_tokens / cache_read_input_tokens through the message_delta → OpenAI chunk conversion (previously dropped silently).

The non-SDK paths (raw HTTP event stream in src/plugin/streaming/stream-transformer.ts and ResponseHandler.handleNonStreaming) deliberately remain at 0 — that wire format only exposes contextUsagePercentage, not per-request token usage. Those sites have a brief comment explaining why, to prevent future contributors from re-introducing the same fix attempt on a path that simply cannot carry the data.

This is pure observability. No request payload is changed, no cachePoint / clientCacheConfig is added, no billing behavior is modified. We only report what the backend already returned on the existing event.

Tests

New file: src/__tests__/cache-token-usage.test.ts — 8 tests covering:

  • SDK streaming: metadataEvent.tokenUsage carries non-zero cache fields → emitted usage reflects them (800 / 1200)
  • SDK streaming: metadataEvent.tokenUsage omits cache fields → zeros (no crash)
  • SDK streaming: metadataEvent has no tokenUsage at all → zeros
  • SDK streaming: no metadataEvent ever arrives → zeros
  • SDK non-streaming: same three coverage cases via ResponseHandler.handleSdkSuccess(streaming=false) reading the JSON body
  • Non-SDK (raw HTTP) streaming: pins the current zero behavior so the deliberate non-fix doesn't silently regress

Run results (bun test):

181 pass
0 fail
572 expect() calls
Ran 181 tests across 20 files.

Prettier check (prettier --check 'src/**/*.ts'): all files use Prettier code style.
TypeScript (tsc --noEmit): clean.
Build (npm run build): clean — tsc -p tsconfig.build.json && node scripts/fix-esm-imports.mjs finishes with fix-esm-imports: patched 40/114 files in dist/.

Out of scope

The opus-5 200K-vs-1M context window issue (where getContextWindowSize returns 200K but the model actually has 1M) is a separate problem tracked upstream — related PR #121 is already working on window discovery. This PR intentionally does not touch getContextWindowSize, the model registry, or any context-window logic. It also does not introduce cachePoint / clientCacheConfig on outgoing requests; that is a billing-behavior change that belongs in a separate discussion.

…zero

The SDK streaming and non-streaming response paths emitted
cache_creation_input_tokens and cache_read_input_tokens as hardcoded
zero literals, even though the SDK's TokenUsage type carries
optional cacheReadInputTokens and cacheWriteInputTokens fields
(see @aws/codewhisperer-streaming-client dist-types/models/models_0.d.ts).

Capture the cache fields from MetadataEvent.tokenUsage when present
in transformSdkStream and ResponseHandler.handleSdkNonStreaming, and
propagate them through convertToOpenAI's message_delta usage emission.
The non-SDK paths (raw HTTP event stream) cannot report cache tokens
because that wire format only exposes contextUsagePercentage; those
sites are left at 0 with a comment explaining why.

Pure observability: no request payload is changed, no cachePoint is
added, no billing behavior is modified.
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