Skip to content

Token usage reports prompt-cache writes as cache reads (claude-code) and drops them (codex), so spend is underpriced #3464

Description

@albrand

What happens

Token usage cannot be priced correctly, because prompt-cache writes are reported as cache reads (claude-code) or dropped (codex). Anthropic prices a cache write at 1.25x base input and a cache read at 0.1x, so the two need to be kept apart.

Measured on one operator's claude-code sessions over 3 days: cache writes were 1.5% of the tokens bb reports as cachedInputTokens, but 16% of what those tokens cost. Any spend view built on thread/tokenUsage/updated underprices claude-code by that much, and shows ~100% "cached".

Why

  • claude-code: plugins/provider-claude-code/src/sdk-extraction.ts:334: const cachedInputTokens = cacheReadTokens + cacheCreationTokens;. The write/read split from the SDK is lost here.
  • codex: the app-server's TokenUsageBreakdown carries cacheWriteInputTokens (see the generated schema/v2/TokenUsageBreakdown.ts), but plugins/provider-codex/src/delta-translation.ts:1232 copies every field except it, and schemas.ts doesn't declare it.
  • threadEventTokenUsageBreakdownSchema in packages/domain/src/provider-event.ts has no field for it.

Fix (patch below, against main 8d32c61). Additive and non-breaking

  • New optional cacheWriteInputTokens on the shared breakdown. Existing fields keep their meaning, so totalTokens and every current consumer are unchanged.
  • claude-code sets it to cache_creation_input_tokens (the cache-write share of cachedInputTokens).
  • codex declares it in its schema and passes it through for total and last.
  • addTokenUsage sums it into claude-code's running total. It stays absent until some request reports it, so a provider that never reports it isn't shown as writing zero.

Tests

codex delta-translation.test.ts 75/75; claude-code 348/348; provider-bridge-protocol 238/238; domain 201/201. Typecheck green for all four packages.

Related: nothing documents whether inputTokens includes cached tokens. codex's does and claude-code's doesn't, which consumers must currently infer. That isn't changed here.

Patch
diff --git a/packages/domain/src/provider-event.ts b/packages/domain/src/provider-event.ts
index b98325c7..891130e8 100644
--- a/packages/domain/src/provider-event.ts
+++ b/packages/domain/src/provider-event.ts
@@ -292,6 +292,14 @@ export const threadEventTokenUsageBreakdownSchema = z.object({
   totalTokens: z.number(),
   inputTokens: z.number(),
   cachedInputTokens: z.number(),
+  /**
+   * The part of the prompt written to the prompt cache on this request, when
+   * the provider reports it. Cache writes and cache reads are priced very
+   * differently (Anthropic: 1.25x vs 0.1x base input), so a consumer pricing
+   * usage needs them apart. For claude-code this is the cache-write share of
+   * `cachedInputTokens`; codex reports it separately.
+   */
+  cacheWriteInputTokens: z.number().optional(),
   outputTokens: z.number(),
   reasoningOutputTokens: z.number(),
 });
diff --git a/packages/provider-bridge-protocol/src/bridge-kit/adapter-utils.ts b/packages/provider-bridge-protocol/src/bridge-kit/adapter-utils.ts
index 89769ba4..a72c665b 100644
--- a/packages/provider-bridge-protocol/src/bridge-kit/adapter-utils.ts
+++ b/packages/provider-bridge-protocol/src/bridge-kit/adapter-utils.ts
@@ -361,6 +361,16 @@ export function addTokenUsage(
     totalTokens: total.totalTokens + last.totalTokens,
     inputTokens: total.inputTokens + last.inputTokens,
     cachedInputTokens: total.cachedInputTokens + last.cachedInputTokens,
+    // Optional on the wire: stay absent until some request reports it, so a
+    // provider that never does is not shown as writing zero.
+    ...(total.cacheWriteInputTokens === undefined &&
+    last.cacheWriteInputTokens === undefined
+      ? {}
+      : {
+          cacheWriteInputTokens:
+            (total.cacheWriteInputTokens ?? 0) +
+            (last.cacheWriteInputTokens ?? 0),
+        }),
     outputTokens: total.outputTokens + last.outputTokens,
     reasoningOutputTokens:
       total.reasoningOutputTokens + last.reasoningOutputTokens,
diff --git a/plugins/provider-claude-code/src/sdk-extraction.ts b/plugins/provider-claude-code/src/sdk-extraction.ts
index 8f58a8a4..6c7c1aa1 100644
--- a/plugins/provider-claude-code/src/sdk-extraction.ts
+++ b/plugins/provider-claude-code/src/sdk-extraction.ts
@@ -337,6 +337,7 @@ function toTokenUsageBreakdown(
     totalTokens: inputTokens + outputTokens + cachedInputTokens,
     inputTokens,
     cachedInputTokens,
+    cacheWriteInputTokens: cacheCreationTokens,
     outputTokens,
     reasoningOutputTokens: 0,
   };
diff --git a/plugins/provider-codex/src/delta-translation.ts b/plugins/provider-codex/src/delta-translation.ts
index 6be48511..fd88271e 100644
--- a/plugins/provider-codex/src/delta-translation.ts
+++ b/plugins/provider-codex/src/delta-translation.ts
@@ -1230,6 +1230,7 @@ export function translateCodexEventToDeltas(
             totalTokens: tokenUsage.total.totalTokens,
             inputTokens: tokenUsage.total.inputTokens,
             cachedInputTokens: tokenUsage.total.cachedInputTokens,
+            cacheWriteInputTokens: tokenUsage.total.cacheWriteInputTokens,
             outputTokens: tokenUsage.total.outputTokens,
             reasoningOutputTokens: tokenUsage.total.reasoningOutputTokens,
           },
@@ -1237,6 +1238,7 @@ export function translateCodexEventToDeltas(
             totalTokens: tokenUsage.last.totalTokens,
             inputTokens: tokenUsage.last.inputTokens,
             cachedInputTokens: tokenUsage.last.cachedInputTokens,
+            cacheWriteInputTokens: tokenUsage.last.cacheWriteInputTokens,
             outputTokens: tokenUsage.last.outputTokens,
             reasoningOutputTokens: tokenUsage.last.reasoningOutputTokens,
           },
diff --git a/plugins/provider-codex/src/schemas.ts b/plugins/provider-codex/src/schemas.ts
index add605da..6ceae54e 100644
--- a/plugins/provider-codex/src/schemas.ts
+++ b/plugins/provider-codex/src/schemas.ts
@@ -559,6 +559,9 @@ const codexTokenUsageBreakdownSchema = z
     totalTokens: z.number(),
     inputTokens: z.number(),
     cachedInputTokens: z.number(),
+    // In the app-server's TokenUsageBreakdown; optional so an older codex that
+    // omits it still parses.
+    cacheWriteInputTokens: z.number().optional(),
     outputTokens: z.number(),
     reasoningOutputTokens: z.number(),
   })

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    confirmed-reproBug reproduced again from a clean trusted checkout; see linked reportprovider-claude-codeBuilt-in plugin: provider-claude-codeprovider-codexBuilt-in plugin: provider-codexprovidersCross-provider bridges, models, login

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions