From 5e2db685476b1af660f67bcfb53c5c9dbd9234d9 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sun, 18 Oct 2026 03:13:49 -0700 Subject: [PATCH 1/3] fix: stop billing cached Anthropic/Bedrock input tokens as uncached With `ai` 6 and `@ai-sdk/anthropic` 3, `usage.inputTokens` is the inclusive total and `usage.inputTokenDetails` splits it into no-cache, cache-read and cache-write tokens. `Session.getUsage` still assumed Anthropic and Bedrock exclude cached tokens from `inputTokens`, so every cached step was charged the full input again at the uncached rate on top of the cache read/write charges. On cache-heavy agent sessions the reported cost was about 6x the real cost, and `tokens.input` / `tokens.inputTotal` were inflated the same way. - Use `inputTokenDetails` (no-cache, cache read, cache write) when the SDK reports it; keep the legacy path for the pre-v6 usage shape. - Regression tests with the exact v6 usage shape for Anthropic and Bedrock, including cache write taken from the details when provider metadata lacks it. Co-Authored-By: Claude Opus 5.5 --- packages/opencode/src/session/index.ts | 15 ++++-- .../test/session/session-getusage.test.ts | 46 +++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index aee9ca8898..f9235c9c07 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -872,21 +872,30 @@ export namespace Session { const outputTokens = safe(input.usage.outputTokens ?? 0) const reasoningTokens = safe(input.usage.reasoningTokens ?? 0) - const cacheReadInputTokens = safe(input.usage.cachedInputTokens ?? 0) + // altimate_change start — upstream_fix: AI SDK v6 reports `inputTokenDetails`, where + // `inputTokens` includes cached tokens for every provider (Anthropic and Bedrock too). + // Without this, cached input was also billed at the uncached rate (~6x on cached sessions). + const details = (input.usage as { inputTokenDetails?: Record }).inputTokenDetails + const reportsDetails = typeof details?.noCacheTokens === "number" + const cacheReadInputTokens = safe((reportsDetails ? details?.cacheReadTokens : undefined) ?? input.usage.cachedInputTokens ?? 0) const cacheWriteInputTokens = safe( - (input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ?? + ((reportsDetails ? details?.cacheWriteTokens : undefined) ?? + input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ?? // @ts-expect-error input.metadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ?? // @ts-expect-error input.metadata?.["venice"]?.["usage"]?.["cacheCreationInputTokens"] ?? 0) as number, ) + // altimate_change end // OpenRouter provides inputTokens as the total count of input tokens (including cached). // AFAIK other providers (OpenRouter/OpenAI/Gemini etc.) do it the same way e.g. vercel/ai#8794 (comment) // Anthropic does it differently though - inputTokens doesn't include cached tokens. // It looks like Altimate Code's cost calculation assumes all providers return inputTokens the same way Anthropic does (I'm guessing getUsage logic was originally implemented with anthropic), so it's causing incorrect cost calculation for OpenRouter and others. - const excludesCachedTokens = !!(input.metadata?.["anthropic"] || input.metadata?.["bedrock"]) + // altimate_change start — upstream_fix: only the pre-v6 usage shape excludes cached tokens + const excludesCachedTokens = !reportsDetails && !!(input.metadata?.["anthropic"] || input.metadata?.["bedrock"]) + // altimate_change end // altimate_change start — clamp at zero so inconsistent provider counts // (inputTokens < cachedInputTokens) don't produce negative cost. // Without the Math.max(0, ...), `tokens.input * costInfo.input` becomes diff --git a/packages/opencode/test/session/session-getusage.test.ts b/packages/opencode/test/session/session-getusage.test.ts index 26fe7be8d3..dafcfb66a8 100644 --- a/packages/opencode/test/session/session-getusage.test.ts +++ b/packages/opencode/test/session/session-getusage.test.ts @@ -123,6 +123,52 @@ describe("Session.getUsage — OpenAI-style (inclusive input)", () => { }) }) +describe("Session.getUsage — AI SDK v6 usage (inclusive input with inputTokenDetails)", () => { + // Shape @ai-sdk/anthropic 3.x + ai 6 deliver for one cached agent step. + const opusPricing = { input: 4, output: 20, cache: { read: 0.2, write: 5 } } + const v6Step = { + inputTokens: 31_767, + inputTokenDetails: { noCacheTokens: 4, cacheReadTokens: 29_128, cacheWriteTokens: 2_635 }, + outputTokens: 256, + cachedInputTokens: 29_128, + } + + test("Anthropic: cached tokens are not also billed as uncached input", () => { + const result = Session.getUsage({ + model: { ...fakeModel("@ai-sdk/anthropic"), cost: opusPricing }, + usage: v6Step as any, + metadata: { anthropic: { cacheCreationInputTokens: 2_635 } } as any, + }) + expect(result.tokens.input).toBe(4) + expect(result.tokens.cache.read).toBe(29_128) + expect(result.tokens.cache.write).toBe(2_635) + expect(result.tokens.inputTotal).toBe(31_767) + expect(result.tokens.total).toBe(31_767 + 256) + // 4*4 + 256*20 + 29128*0.2 + 2635*5 per million; the old path charged all 31,767 at $4/M (≈ $0.151). + expect(result.cost).toBeCloseTo(0.0241366, 7) + }) + + test("Bedrock: same inclusive shape is split the same way", () => { + const result = Session.getUsage({ + model: fakeModel("@ai-sdk/amazon-bedrock"), + usage: v6Step as any, + metadata: { bedrock: { usage: { cacheWriteInputTokens: 2_635 } } } as any, + }) + expect(result.tokens.input).toBe(4) + expect(result.tokens.inputTotal).toBe(31_767) + }) + + test("cache write comes from inputTokenDetails when provider metadata lacks it", () => { + const result = Session.getUsage({ + model: fakeModel("@ai-sdk/anthropic"), + usage: v6Step as any, + metadata: { anthropic: {} } as any, + }) + expect(result.tokens.cache.write).toBe(2_635) + expect(result.tokens.input).toBe(4) + }) +}) + describe("Session.getUsage — provider edge cases", () => { test("@ai-sdk/google-vertex/anthropic uses Anthropic-style accounting (per total branch)", () => { // The `total` computation at session/index.ts:828 includes this NPM as From aa24e4d7bb56a867cebba3a959bc139a74b8f1ac Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sun, 18 Oct 2026 03:21:45 -0700 Subject: [PATCH 2/3] fix: apply the cache split to learning-flow usage too `accountUsage` rebuilt the usage object without `inputTokenDetails`, so the learning bootstrap and review-import flows still billed the inclusive input count as uncached and double-counted cache reads in their token totals. - Pass `inputTokenDetails` (now typed with `noCacheTokens`) through to `Session.getUsage`. - Tests: learning-flow cost on the v6 shape, a full cache hit, and the OpenAI v6 shape without cache writes. - Update the telemetry comment that described the old Anthropic assumption. Co-Authored-By: Claude Opus 5.5 --- .../opencode/src/altimate/learn/reflect.ts | 2 +- packages/opencode/src/altimate/learn/usage.ts | 21 +++++++++++-------- .../opencode/src/altimate/telemetry/index.ts | 10 ++++----- .../test/altimate/learn/reflect.test.ts | 13 ++++++++++++ .../test/session/session-getusage.test.ts | 20 ++++++++++++++++++ 5 files changed, 51 insertions(+), 15 deletions(-) diff --git a/packages/opencode/src/altimate/learn/reflect.ts b/packages/opencode/src/altimate/learn/reflect.ts index f004e5f364..536b52aea4 100644 --- a/packages/opencode/src/altimate/learn/reflect.ts +++ b/packages/opencode/src/altimate/learn/reflect.ts @@ -56,7 +56,7 @@ export interface GenerateUsage { totalTokens?: number reasoningTokens?: number cachedInputTokens?: number - inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number } + inputTokenDetails?: { noCacheTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number } outputTokenDetails?: { reasoningTokens?: number } providerMetadata?: ProviderMetadata estimatedCost?: number diff --git a/packages/opencode/src/altimate/learn/usage.ts b/packages/opencode/src/altimate/learn/usage.ts index 267b7c6848..6517863b70 100644 --- a/packages/opencode/src/altimate/learn/usage.ts +++ b/packages/opencode/src/altimate/learn/usage.ts @@ -14,18 +14,21 @@ export type UsageModel = { cost?: Provider.Model["cost"]; api?: Pick { // Loading the session lazily avoids a cycle through the learning hooks during startup. const { Session } = await import("@/session") + const sdkUsage = { + // Accounted callbacks retain original counts so import estimates can fill missing values + // without normalizing Anthropic's cache counters a second time. + inputTokens: usage.rawUsage?.inputTokens ?? usage.inputTokens, + outputTokens: usage.rawUsage?.outputTokens ?? usage.outputTokens, + totalTokens: usage.totalTokens, + reasoningTokens: usage.reasoningTokens ?? usage.outputTokenDetails?.reasoningTokens, + cachedInputTokens: usage.cachedInputTokens ?? usage.inputTokenDetails?.cacheReadTokens, + // The SDK's cache split; without it an inclusive input count is billed as uncached. + inputTokenDetails: usage.inputTokenDetails, + } const accounted = Session.getUsage({ // Session accounting only reads cost metadata and the provider's SDK name. model: { ...model, api: model.api ?? { npm: "" } } as Provider.Model, - usage: { - // Accounted callbacks retain original counts so import estimates can fill missing values - // without normalizing Anthropic's cache counters a second time. - inputTokens: usage.rawUsage?.inputTokens ?? usage.inputTokens, - outputTokens: usage.rawUsage?.outputTokens ?? usage.outputTokens, - totalTokens: usage.totalTokens, - reasoningTokens: usage.reasoningTokens ?? usage.outputTokenDetails?.reasoningTokens, - cachedInputTokens: usage.cachedInputTokens ?? usage.inputTokenDetails?.cacheReadTokens, - }, + usage: sdkUsage, metadata, }) return { diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index b28a8db1ba..172453bdb8 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -275,11 +275,11 @@ export namespace Telemetry { // // SEMANTICS (read this before writing dashboard queries): // tokens_input = UNCACHED input tokens. Equal to 0 on a full cache hit. - // Normalized across providers: Anthropic (and Bedrock - // Anthropic) return this directly; non-Anthropic - // providers return the inclusive total and Session.getUsage - // subtracts cache_read (and cache_write where present) - // to derive the uncached portion. cache_write in + // Normalized across providers: the AI SDK reports the + // inclusive total with a no-cache/cache-read/cache-write + // split, which Session.getUsage uses (older SDK usage + // without the split falls back to subtracting cache + // tokens, except Anthropic/Bedrock). cache_write in // particular is only populated for Anthropic / Bedrock / // Venice metadata paths — OpenAI / OpenRouter don't // surface a "cache write" concept today, so the diff --git a/packages/opencode/test/altimate/learn/reflect.test.ts b/packages/opencode/test/altimate/learn/reflect.test.ts index 2fd68cab8a..ffbd16c129 100644 --- a/packages/opencode/test/altimate/learn/reflect.test.ts +++ b/packages/opencode/test/altimate/learn/reflect.test.ts @@ -249,6 +249,19 @@ describe("reflect (stubbed model)", () => { }) }) + test("splits the SDK's inclusive input count by its cache details instead of billing it as uncached", async () => { + const model = { cost: { input: 4, output: 20, cache: { read: 0.2, write: 5 } } } + const accounted = await accountUsage(model, { + inputTokens: 31_767, + outputTokens: 256, + cachedInputTokens: 29_128, + inputTokenDetails: { noCacheTokens: 4, cacheReadTokens: 29_128, cacheWriteTokens: 2_635 }, + providerMetadata: { anthropic: { cacheCreationInputTokens: 2_635 } }, + }) + expect(accounted.inputTokens).toBe(31_767) + expect(accounted.estimatedCost).toBeCloseTo(0.0241366, 7) + }) + test("uses session metadata cache pricing and the active session's handling of provider metadata", async () => { const model = { cost: { input: 2, output: 8, cache: { read: 0.25, write: 3 } } } const usage = { inputTokens: 1_000, outputTokens: 200, cachedInputTokens: 300, reasoningTokens: 40 } diff --git a/packages/opencode/test/session/session-getusage.test.ts b/packages/opencode/test/session/session-getusage.test.ts index dafcfb66a8..f49110fd33 100644 --- a/packages/opencode/test/session/session-getusage.test.ts +++ b/packages/opencode/test/session/session-getusage.test.ts @@ -158,6 +158,26 @@ describe("Session.getUsage — AI SDK v6 usage (inclusive input with inputTokenD expect(result.tokens.inputTotal).toBe(31_767) }) + test("full cache hit reports zero uncached input", () => { + const result = Session.getUsage({ + model: fakeModel("@ai-sdk/anthropic"), + usage: { inputTokens: 8_000, inputTokenDetails: { noCacheTokens: 0, cacheReadTokens: 8_000, cacheWriteTokens: 0 }, outputTokens: 10 } as any, + metadata: { anthropic: { cacheCreationInputTokens: 0 } } as any, + }) + expect(result.tokens.input).toBe(0) + expect(result.tokens.inputTotal).toBe(8_000) + }) + + test("OpenAI v6 shape without cache write matches the legacy subtraction", () => { + const result = Session.getUsage({ + model: fakeModel("@ai-sdk/openai"), + usage: { inputTokens: 5_000, inputTokenDetails: { noCacheTokens: 3_000, cacheReadTokens: 2_000 }, outputTokens: 10, totalTokens: 5_010 } as any, + }) + expect(result.tokens.input).toBe(3_000) + expect(result.tokens.cache.write).toBe(0) + expect(result.tokens.inputTotal).toBe(5_000) + }) + test("cache write comes from inputTokenDetails when provider metadata lacks it", () => { const result = Session.getUsage({ model: fakeModel("@ai-sdk/anthropic"), From 3267982c749e5eaf879b496eebcd8b4f8d8c954b Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sun, 18 Oct 2026 03:37:46 -0700 Subject: [PATCH 3/3] fix: count cache writes toward the over-200K price tier With cache writes no longer folded into `tokens.input`, the long-context tier check missed requests that crossed 200K mostly through cache writes. The threshold now sums uncached input, cache reads and cache writes. Co-Authored-By: Claude Opus 5.5 --- packages/opencode/src/session/index.ts | 4 +++- .../opencode/test/session/session-getusage.test.ts | 11 +++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index f9235c9c07..dbd7f43d9e 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -938,7 +938,9 @@ export namespace Session { } const costInfo = - input.model.cost?.experimentalOver200K && tokens.input + tokens.cache.read > 200_000 + // altimate_change start — upstream_fix: the long-context tier counts all prompt tokens, cache writes included + input.model.cost?.experimentalOver200K && tokens.input + tokens.cache.read + tokens.cache.write > 200_000 + // altimate_change end ? input.model.cost.experimentalOver200K : input.model.cost return { diff --git a/packages/opencode/test/session/session-getusage.test.ts b/packages/opencode/test/session/session-getusage.test.ts index f49110fd33..5e2569c7d9 100644 --- a/packages/opencode/test/session/session-getusage.test.ts +++ b/packages/opencode/test/session/session-getusage.test.ts @@ -158,6 +158,17 @@ describe("Session.getUsage — AI SDK v6 usage (inclusive input with inputTokenD expect(result.tokens.inputTotal).toBe(31_767) }) + test("cache writes count toward the over-200K price tier", () => { + const tiered = { input: 1, output: 1, cache: { read: 0, write: 1 }, experimentalOver200K: { input: 2, output: 2, cache: { read: 0, write: 2 } } } + const result = Session.getUsage({ + model: { ...fakeModel("@ai-sdk/anthropic"), cost: tiered }, + usage: { inputTokens: 210_000, inputTokenDetails: { noCacheTokens: 10_000, cacheReadTokens: 0, cacheWriteTokens: 200_000 }, outputTokens: 0 } as any, + metadata: { anthropic: { cacheCreationInputTokens: 200_000 } } as any, + }) + // 10K uncached + 200K cache write at the over-200K rates of $2/M each. + expect(result.cost).toBeCloseTo(0.42, 10) + }) + test("full cache hit reports zero uncached input", () => { const result = Session.getUsage({ model: fakeModel("@ai-sdk/anthropic"),