From a23e2bd5aa396024de9f682fb52a252e6194a445 Mon Sep 17 00:00:00 2001 From: Anand Gupta Date: Wed, 7 Oct 2026 11:21:36 -0700 Subject: [PATCH 1/8] fix(bedrock): keep Converse streams non-empty in the release build The release build bundles with the `browser` resolution condition. Under it `@smithy/core/serde` resolves to its browser build, where `fromString` and `fromArrayBuffer` are `Symbol.for("node-only")` placeholders. `@smithy/util-buffer-from` re-exports them and `@smithy/util-utf8` calls them while `@ai-sdk/amazon-bedrock` decodes `application/vnd.amazon.eventstream`; the TypeError is swallowed, so every streamed response ended with finish "other", zero tokens and no text. Add a build plugin that redirects only `@smithy/core/serde` as imported from `@smithy/util-buffer-from` to the package's Node build. All other imports keep their resolution. A network-free test bundles a probe with the build's conditions and a Bun target and decodes a synthetic event-stream body. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/opencode/script/build.ts | 9 +- .../script/smithy-node-serde-plugin.ts | 33 +++++++ .../script/bedrock-eventstream-build.test.ts | 89 +++++++++++++++++++ .../test/script/bedrock-eventstream-bundle.ts | 20 +++++ .../test/script/bedrock-eventstream-frames.ts | 38 ++++++++ .../test/script/bedrock-eventstream-probe.ts | 40 +++++++++ 6 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/script/smithy-node-serde-plugin.ts create mode 100644 packages/opencode/test/script/bedrock-eventstream-build.test.ts create mode 100644 packages/opencode/test/script/bedrock-eventstream-bundle.ts create mode 100644 packages/opencode/test/script/bedrock-eventstream-frames.ts create mode 100644 packages/opencode/test/script/bedrock-eventstream-probe.ts diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index b253306912..c8e937bd83 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -18,6 +18,9 @@ process.chdir(dir) import { Script } from "@opencode-ai/script" import pkg from "../package.json" import { walkInputs } from "./stamp-inputs" +// altimate_change start — upstream_fix: Bedrock event-stream decoding under the browser condition +import { smithyNodeSerdePlugin } from "./smithy-node-serde-plugin" +// altimate_change end import { assertUsableCatalog, catalogDiagnosticOrigin, formatCatalogSummary } from "./models-catalog" import { FreeTierUrl } from "../src/altimate/free/url" @@ -599,7 +602,11 @@ for (const item of targets) { await Bun.build({ conditions: ["browser"], tsconfig: "./tsconfig.json", - plugins: [solidPlugin, altimateCoreResolverPlugin], + // altimate_change start — upstream_fix: smithyNodeSerdePlugin keeps Bedrock Converse streams non-empty + // (see script/smithy-node-serde-plugin.ts); it only affects `@smithy/core/serde` as imported by + // `@smithy/util-buffer-from`. + plugins: [solidPlugin, altimateCoreResolverPlugin, smithyNodeSerdePlugin()], + // altimate_change end sourcemap: "external", // IMPORTANT: Without code splitting, Bun inlines dynamic import() targets // into the main chunk. Any external require() in those targets will fail diff --git a/packages/opencode/script/smithy-node-serde-plugin.ts b/packages/opencode/script/smithy-node-serde-plugin.ts new file mode 100644 index 0000000000..6f243cad50 --- /dev/null +++ b/packages/opencode/script/smithy-node-serde-plugin.ts @@ -0,0 +1,33 @@ +// Bun.build plugin shared by script/build.ts and its regression test. +// +// The release build bundles with `conditions: ["browser"]` (needed by the TUI +// dependencies). Under that condition `@smithy/core/serde` resolves to +// `index.browser.js`, where the Node-only exports `fromString` and +// `fromArrayBuffer` are placeholder symbols (`Symbol.for("node-only")`). +// `@smithy/util-buffer-from` re-exports exactly those two names and +// `@smithy/util-utf8` calls them while `@ai-sdk/amazon-bedrock` decodes the +// `application/vnd.amazon.eventstream` body. The call throws a TypeError, which +// the SDK's decoder swallows, so every Bedrock Converse stream ended with zero +// events: finish "other", no text, no usage, no error. +// +// This plugin redirects only that one import (`@smithy/core/serde` requested +// from inside `@smithy/util-buffer-from`) to the package's Node build, which +// is the file a source run already uses. Every other importer of +// `@smithy/core/serde` and every other package keeps its resolution. +import path from "node:path" +import type { BunPlugin } from "bun" + +const IMPORTER = /[\\/]@smithy[\\/]util-buffer-from[\\/]/ + +export function smithyNodeSerdePlugin(): BunPlugin { + return { + name: "smithy-util-buffer-from-node-serde", + setup(build) { + build.onResolve({ filter: /^@smithy\/core\/serde$/ }, (args) => { + if (!IMPORTER.test(args.importer)) return undefined + // Resolve from the importer's directory with Bun's default (non-browser) conditions. + return { path: Bun.resolveSync("@smithy/core/serde", path.dirname(args.importer)) } + }) + }, + } +} diff --git a/packages/opencode/test/script/bedrock-eventstream-build.test.ts b/packages/opencode/test/script/bedrock-eventstream-build.test.ts new file mode 100644 index 0000000000..bb71b428ad --- /dev/null +++ b/packages/opencode/test/script/bedrock-eventstream-build.test.ts @@ -0,0 +1,89 @@ +import { describe, test, expect } from "bun:test" +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { smithyNodeSerdePlugin } from "../../script/smithy-node-serde-plugin" + +/** + * Regression: Bedrock Converse streams came back empty (finish "other", no text, no usage, no error) + * in the built binary. script/build.ts bundles with `conditions: ["browser"]` for a Bun target; under + * that combination `@smithy/core/serde` exports placeholder `fromString`/`fromArrayBuffer`, the event + * stream decoder throws on its first frame and the SDK swallows the error. + * + * These tests bundle a probe that decodes a synthetic `application/vnd.amazon.eventstream` body through + * the real @ai-sdk/amazon-bedrock provider, using the same `conditions` and a Bun target as the release + * build, then run the bundle. No network or credentials are involved. + */ + +const bundler = path.join(import.meta.dir, "bedrock-eventstream-bundle.ts") + +async function bundleAndRun(mode: "plugin" | "none") { + const outdir = fs.mkdtempSync(path.join(os.tmpdir(), "bedrock-eventstream-")) + try { + const build = Bun.spawn([process.execPath, bundler, outdir, mode], { stdout: "pipe", stderr: "pipe" }) + const buildErr = await new Response(build.stderr).text() + expect(await build.exited, buildErr).toBe(0) + const run = Bun.spawn([process.execPath, path.join(outdir, "bedrock-eventstream-probe.js")], { + stdout: "pipe", + stderr: "pipe", + }) + const out = await new Response(run.stdout).text() + await run.exited + return JSON.parse(out.trim().split("\n").pop()!) + } finally { + fs.rmSync(outdir, { recursive: true, force: true }) + } +} + +describe("Bedrock event-stream decoding in the release build's resolution conditions", () => { + test("with the build plugin the stream yields text, finish reason and token counts", async () => { + const result = await bundleAndRun("plugin") + expect(result).toEqual({ text: "hello", finish: { unified: "stop", raw: "end_turn" }, input: 4, output: 2 }) + }, 60_000) + + test("without the plugin the same bundle is empty (documents the defect the plugin prevents)", async () => { + const result = await bundleAndRun("none") + expect(result.text).toBe("") + expect(result.finish.unified).toBe("other") + }, 60_000) +}) + +describe("smithyNodeSerdePlugin scope", () => { + function resolver() { + let cb!: (args: { importer: string }) => unknown + smithyNodeSerdePlugin().setup({ onResolve: (_o: unknown, f: unknown) => (cb = f as typeof cb) } as any) + return cb + } + + test("redirects @smithy/core/serde only for @smithy/util-buffer-from importers", () => { + const resolve = resolver() + const bufferFrom = require.resolve("@smithy/util-buffer-from/package.json", { + paths: [ + path.dirname( + require.resolve("@smithy/util-utf8/package.json", { + paths: [path.dirname(require.resolve("@ai-sdk/amazon-bedrock/package.json"))], + }), + ), + ], + }) + const hit = resolve({ importer: path.join(path.dirname(bufferFrom), "dist-es", "index.js") }) as { path: string } + expect(hit.path).toContain("serde") + expect(hit.path).not.toContain("browser") + }) + + test("leaves every other importer untouched", () => { + const resolve = resolver() + expect(resolve({ importer: "/x/node_modules/@smithy/util-utf8/dist-es/fromUtf8.js" })).toBeUndefined() + expect(resolve({ importer: "/x/node_modules/@smithy/eventstream-codec/dist-es/index.js" })).toBeUndefined() + expect(resolve({ importer: "/x/src/index.ts" })).toBeUndefined() + }) +}) + +describe("script/build.ts wiring", () => { + test("the release Bun.build call registers smithyNodeSerdePlugin alongside the browser condition", () => { + const src = fs.readFileSync(path.join(import.meta.dir, "../../script/build.ts"), "utf8") + const call = src.slice(src.indexOf("await Bun.build({")) + expect(call).toContain('conditions: ["browser"]') + expect(call.slice(0, call.indexOf("entrypoints:"))).toContain("smithyNodeSerdePlugin()") + }) +}) diff --git a/packages/opencode/test/script/bedrock-eventstream-bundle.ts b/packages/opencode/test/script/bedrock-eventstream-bundle.ts new file mode 100644 index 0000000000..1a6eaa3aaa --- /dev/null +++ b/packages/opencode/test/script/bedrock-eventstream-bundle.ts @@ -0,0 +1,20 @@ +// Usage: bun bedrock-eventstream-bundle.ts +// Bundles the probe with the release build's resolution conditions. Runs in its own process because +// Bun.build does not tolerate a second differently-configured build over the same graph in-process. +import path from "node:path" +import { smithyNodeSerdePlugin } from "../../script/smithy-node-serde-plugin" + +const [outdir, mode] = process.argv.slice(2) +const root = path.resolve(import.meta.dir, "../..") +const built = await Bun.build({ + entrypoints: [path.join(import.meta.dir, "bedrock-eventstream-probe.ts")], + outdir, + conditions: ["browser"], + target: "bun", + tsconfig: path.join(root, "tsconfig.json"), + plugins: mode === "plugin" ? [smithyNodeSerdePlugin()] : [], +}) +if (!built.success) { + console.error(built.logs.join("\n")) + process.exit(1) +} diff --git a/packages/opencode/test/script/bedrock-eventstream-frames.ts b/packages/opencode/test/script/bedrock-eventstream-frames.ts new file mode 100644 index 0000000000..69b6683f23 --- /dev/null +++ b/packages/opencode/test/script/bedrock-eventstream-frames.ts @@ -0,0 +1,38 @@ +const enc = new TextEncoder() + +function header(name: string, value: string): Uint8Array { + const n = enc.encode(name) + const v = enc.encode(value) + const out = new Uint8Array(1 + n.length + 1 + 2 + v.length) + out[0] = n.length + out.set(n, 1) + out[1 + n.length] = 7 // header value type: string + new DataView(out.buffer).setUint16(2 + n.length, v.length, false) + out.set(v, 4 + n.length) + return out +} + +/** Encode one `application/vnd.amazon.eventstream` message frame carrying a JSON payload. */ +export function eventStreamFrame(eventType: string, payload: unknown): Uint8Array { + const headers = [ + header(":event-type", eventType), + header(":content-type", "application/json"), + header(":message-type", "event"), + ] + const headersLen = headers.reduce((a, h) => a + h.length, 0) + const body = enc.encode(JSON.stringify(payload)) + const total = 12 + headersLen + body.length + 4 + const out = new Uint8Array(total) + const dv = new DataView(out.buffer) + dv.setUint32(0, total, false) + dv.setUint32(4, headersLen, false) + dv.setUint32(8, Bun.hash.crc32(out.subarray(0, 8)) as number, false) + let off = 12 + for (const h of headers) { + out.set(h, off) + off += h.length + } + out.set(body, off) + dv.setUint32(total - 4, Bun.hash.crc32(out.subarray(0, total - 4)) as number, false) + return out +} diff --git a/packages/opencode/test/script/bedrock-eventstream-probe.ts b/packages/opencode/test/script/bedrock-eventstream-probe.ts new file mode 100644 index 0000000000..eea0f61999 --- /dev/null +++ b/packages/opencode/test/script/bedrock-eventstream-probe.ts @@ -0,0 +1,40 @@ +// Bundled by bedrock-eventstream-build.test.ts under the release build's resolution conditions, +// then executed. Prints the parsed result of one synthetic Bedrock Converse stream as JSON. +import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock" +import { eventStreamFrame as frame } from "./bedrock-eventstream-frames" + +const frames = [ + frame("messageStart", { role: "assistant" }), + frame("contentBlockDelta", { contentBlockIndex: 0, delta: { text: "hello" } }), + frame("contentBlockStop", { contentBlockIndex: 0 }), + frame("messageStop", { stopReason: "end_turn" }), + frame("metadata", { usage: { inputTokens: 4, outputTokens: 2, totalTokens: 6 }, metrics: { latencyMs: 1 } }), +] +const body = new ReadableStream({ + start(c) { + for (const f of frames) c.enqueue(f) + c.close() + }, +}) +const provider = createAmazonBedrock({ + region: "us-east-1", + apiKey: "unused-synthetic-key", + fetch: (async () => + new Response(body, { + headers: { "content-type": "application/vnd.amazon.eventstream" }, + })) as unknown as typeof fetch, +}) +const { stream } = await provider("us.anthropic.claude-haiku-4-5-20251001-v1:0").doStream({ + prompt: [{ role: "user", content: [{ type: "text", text: "hi" }] }], +} as any) +let text = "" +let finish: unknown +let usage: any +for await (const part of stream as any) { + if (part.type === "text-delta") text += part.delta + if (part.type === "finish") { + finish = part.finishReason + usage = part.usage + } +} +console.log(JSON.stringify({ text, finish, input: usage?.inputTokens?.total, output: usage?.outputTokens?.total })) From 05bced11e7733e82bc566ce1d6afe9609b9e2db2 Mon Sep 17 00:00:00 2001 From: Anand Gupta Date: Wed, 7 Oct 2026 11:21:36 -0700 Subject: [PATCH 2/8] fix(session): bill cached input tokens once for Anthropic and Bedrock AI SDK v6 adapters for Anthropic and Bedrock report `usage.inputTokens` as noCache + cacheRead + cacheWrite and expose the uncached part as `inputTokenDetails.noCacheTokens`. `Session.getUsage` still treated the number as excluding cache (the raw API convention) whenever Anthropic or Bedrock provider metadata was present, so each cached token was billed at the full input price in addition to its cache read or write price. Use `noCacheTokens` on that branch when the SDK supplies it. Raw counts without details (the learn/usage path) and all other providers keep their arithmetic. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/opencode/src/session/index.ts | 20 ++- .../session-getusage-inclusive.test.ts | 124 ++++++++++++++++++ 2 files changed, 140 insertions(+), 4 deletions(-) create mode 100644 packages/opencode/test/session/session-getusage-inclusive.test.ts diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 2db11be27b..048df6f5ab 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -879,10 +879,22 @@ export namespace Session { // outlier. Clamping at zero preserves the `input + cache.read + // cache.write === inputTotal` invariant (just at the clamped value) // and keeps cost monotonically non-negative. - const adjustedInputTokens = Math.max( - 0, - safe(excludesCachedTokens ? inputTokens : inputTokens - cacheReadInputTokens - cacheWriteInputTokens), - ) + // altimate_change end + // altimate_change start — upstream_fix: AI SDK v6 adapters for Anthropic and Bedrock report + // `usage.inputTokens` as noCache + cacheRead + cacheWrite and publish the uncached part in + // `inputTokenDetails.noCacheTokens`. Treating the inclusive number as uncached (the raw API + // convention above) bills every cached token twice. Prefer the explicit uncached count when + // the SDK supplies it, only on the Anthropic/Bedrock branch; every other provider and raw counts + // (no details) keep the previous arithmetic. + const noCacheTokens = (input.usage as { inputTokenDetails?: { noCacheTokens?: number } }).inputTokenDetails + ?.noCacheTokens + const adjustedInputTokens = + excludesCachedTokens && typeof noCacheTokens === "number" && Number.isFinite(noCacheTokens) + ? Math.max(0, noCacheTokens) + : Math.max( + 0, + safe(excludesCachedTokens ? inputTokens : inputTokens - cacheReadInputTokens - cacheWriteInputTokens), + ) // altimate_change end const total = iife(() => { diff --git a/packages/opencode/test/session/session-getusage-inclusive.test.ts b/packages/opencode/test/session/session-getusage-inclusive.test.ts new file mode 100644 index 0000000000..9cb8fe6ed7 --- /dev/null +++ b/packages/opencode/test/session/session-getusage-inclusive.test.ts @@ -0,0 +1,124 @@ +import { describe, test, expect } from "bun:test" +import { Session } from "../../src/session" + +/** + * Cost accounting for providers whose AI SDK adapter reports an INCLUSIVE `inputTokens`. + * + * AI SDK v6 (`ai` 6.x) normalizes `usage.inputTokens` to + * `noCache + cacheRead + cacheWrite` for @ai-sdk/amazon-bedrock and + * @ai-sdk/anthropic, and also exposes `usage.inputTokenDetails.noCacheTokens`. + * The raw API counters (Bedrock Converse `inputTokens`, Anthropic `input_tokens`) + * exclude cached tokens, which is what the `excludesCachedTokens` branch assumed. + * Treating the inclusive number as uncached bills every cached token a second + * time at the full input price. + * + * The field shapes below are taken from step-finish events recorded from a live + * Bedrock Converse run (counts only, no content). + */ + +const PRICE = { input: 3, output: 15, cache: { read: 0.3, write: 3.75 } } + +function model(npm: string): any { + return { id: "m", providerID: "p", api: { npm }, cost: PRICE } +} + +/** Build the usage object the AI SDK hands to the processor for an Anthropic-family call. */ +function inclusiveUsage(noCache: number, read: number, write: number, output: number) { + const total = noCache + read + write + return { + inputTokens: total, + outputTokens: output, + totalTokens: total + output, + cachedInputTokens: read, + inputTokenDetails: { noCacheTokens: noCache, cacheReadTokens: read, cacheWriteTokens: write }, + } as any +} + +describe("Session.getUsage - Bedrock Converse (inclusive inputTokens)", () => { + const bedrock = (write: number) => ({ bedrock: { usage: { cacheWriteInputTokens: write } } }) as any + + test("no caching: input and output billed once", () => { + const r = Session.getUsage({ + model: model("@ai-sdk/amazon-bedrock"), + usage: inclusiveUsage(5000, 0, 0, 200), + metadata: bedrock(0), + }) + expect(r.tokens.input).toBe(5000) + expect(r.tokens.inputTotal).toBe(5000) + // (5000 * 3 + 200 * 15) / 1e6 + expect(r.cost).toBeCloseTo(0.018, 12) + }) + + test("cache write: written tokens are billed at the write price only", () => { + // recorded: inputTokens 61363, cache write 61359, output 194 + const r = Session.getUsage({ + model: model("@ai-sdk/amazon-bedrock"), + usage: inclusiveUsage(4, 0, 61359, 194), + metadata: bedrock(61359), + }) + expect(r.tokens.input).toBe(4) + expect(r.tokens.cache.write).toBe(61359) + expect(r.tokens.inputTotal).toBe(61363) + expect(r.tokens.total).toBe(61363 + 194) + // (4*3 + 194*15 + 61359*3.75) / 1e6 + expect(r.cost).toBeCloseTo(0.23301825, 12) + }) + + test("cache read: read tokens are billed at the read price only", () => { + const r = Session.getUsage({ + model: model("@ai-sdk/amazon-bedrock"), + usage: inclusiveUsage(4, 61359, 0, 352), + metadata: bedrock(0), + }) + expect(r.tokens.input).toBe(4) + expect(r.tokens.cache.read).toBe(61359) + // (4*3 + 352*15 + 61359*0.3) / 1e6 + expect(r.cost).toBeCloseTo((12 + 5280 + 18407.7) / 1e6, 12) + }) + + test("mix of uncached, cache read and cache write", () => { + // recorded: inputTokens 63362, cache read 61359, cache write 2001, output 352 + const r = Session.getUsage({ + model: model("@ai-sdk/amazon-bedrock"), + usage: inclusiveUsage(2, 61359, 2001, 352), + metadata: bedrock(2001), + }) + expect(r.tokens.input).toBe(2) + expect(r.tokens.inputTotal).toBe(63362) + expect(r.tokens.total).toBe(63362 + 352) + // (2*3 + 352*15 + 61359*0.3 + 2001*3.75) / 1e6 + expect(r.cost).toBeCloseTo((6 + 5280 + 18407.7 + 7503.75) / 1e6, 12) + }) +}) + +describe("Session.getUsage - other providers are not changed", () => { + test("direct Anthropic (inclusive inputTokens + inputTokenDetails) is billed once", () => { + const r = Session.getUsage({ + model: model("@ai-sdk/anthropic"), + usage: inclusiveUsage(10, 20000, 3000, 100), + metadata: { anthropic: { cacheCreationInputTokens: 3000 } } as any, + }) + expect(r.tokens.input).toBe(10) + expect(r.cost).toBeCloseTo((10 * 3 + 100 * 15 + 20000 * 0.3 + 3000 * 3.75) / 1e6, 12) + }) + + test("OpenAI-style inclusive input without details still subtracts the cached part", () => { + const r = Session.getUsage({ + model: model("@ai-sdk/openai"), + usage: { inputTokens: 5000, outputTokens: 100, cachedInputTokens: 2000 } as any, + metadata: {} as any, + }) + expect(r.tokens.input).toBe(3000) + expect(r.cost).toBeCloseTo((3000 * 3 + 100 * 15 + 2000 * 0.3) / 1e6, 12) + }) + + test("raw exclusive counts with no inputTokenDetails (learn/usage path) keep the old behaviour", () => { + const r = Session.getUsage({ + model: model("@ai-sdk/amazon-bedrock"), + usage: { inputTokens: 800, outputTokens: 40, cachedInputTokens: 2000 } as any, + metadata: { bedrock: { usage: { cacheWriteInputTokens: 600 } } } as any, + }) + expect(r.tokens.input).toBe(800) + expect(r.tokens.inputTotal).toBe(3400) + }) +}) From 5b02f2115c1bac5127000c95b42eccf63317d2de Mon Sep 17 00:00:00 2001 From: Anand Gupta Date: Wed, 7 Oct 2026 11:39:43 -0700 Subject: [PATCH 3/8] fix(learn): pass the SDK's uncached input count to session cost accounting `generateObject().usage.inputTokens` is inclusive of cache reads and writes for Anthropic and Bedrock, so `accountUsage` produced the same double billing as the session processor. Forward `inputTokenDetails.noCacheTokens` to `Session.getUsage`, which now prefers it on that branch. Tighten the test control for the non-Anthropic path and label the build-plugin control test. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../opencode/src/altimate/learn/reflect.ts | 2 +- packages/opencode/src/altimate/learn/usage.ts | 5 +++ .../script/bedrock-eventstream-build.test.ts | 5 ++- .../session-getusage-inclusive.test.ts | 35 +++++++++++++++++++ 4 files changed, 45 insertions(+), 2 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..84612952fa 100644 --- a/packages/opencode/src/altimate/learn/usage.ts +++ b/packages/opencode/src/altimate/learn/usage.ts @@ -25,6 +25,11 @@ export async function accountUsage(model: UsageModel, usage: GenerateUsage, meta totalTokens: usage.totalTokens, reasoningTokens: usage.reasoningTokens ?? usage.outputTokenDetails?.reasoningTokens, cachedInputTokens: usage.cachedInputTokens ?? usage.inputTokenDetails?.cacheReadTokens, + // AI SDK v6 reports inputTokens inclusive of cache for Anthropic/Bedrock; the uncached count lets + // Session.getUsage bill each token class once. + ...(usage.inputTokenDetails?.noCacheTokens !== undefined + ? { inputTokenDetails: { noCacheTokens: usage.inputTokenDetails.noCacheTokens } } + : {}), }, metadata, }) diff --git a/packages/opencode/test/script/bedrock-eventstream-build.test.ts b/packages/opencode/test/script/bedrock-eventstream-build.test.ts index bb71b428ad..d6335c9844 100644 --- a/packages/opencode/test/script/bedrock-eventstream-build.test.ts +++ b/packages/opencode/test/script/bedrock-eventstream-build.test.ts @@ -41,7 +41,10 @@ describe("Bedrock event-stream decoding in the release build's resolution condit expect(result).toEqual({ text: "hello", finish: { unified: "stop", raw: "end_turn" }, input: 4, output: 2 }) }, 60_000) - test("without the plugin the same bundle is empty (documents the defect the plugin prevents)", async () => { + // Control: proves the probe detects the defect. If an @smithy/core or @smithy/util-buffer-from upgrade + // makes this fail (the stream is no longer empty), the plugin is no longer needed and can be removed + // together with this test. + test("without the plugin the same bundle is empty (control for the probe)", async () => { const result = await bundleAndRun("none") expect(result.text).toBe("") expect(result.finish.unified).toBe("other") diff --git a/packages/opencode/test/session/session-getusage-inclusive.test.ts b/packages/opencode/test/session/session-getusage-inclusive.test.ts index 9cb8fe6ed7..6d67a79d22 100644 --- a/packages/opencode/test/session/session-getusage-inclusive.test.ts +++ b/packages/opencode/test/session/session-getusage-inclusive.test.ts @@ -1,5 +1,6 @@ import { describe, test, expect } from "bun:test" import { Session } from "../../src/session" +import { accountUsage } from "../../src/altimate/learn/usage" /** * Cost accounting for providers whose AI SDK adapter reports an INCLUSIVE `inputTokens`. @@ -112,6 +113,21 @@ describe("Session.getUsage - other providers are not changed", () => { expect(r.cost).toBeCloseTo((3000 * 3 + 100 * 15 + 2000 * 0.3) / 1e6, 12) }) + test("noCacheTokens is ignored off the Anthropic/Bedrock branch (arithmetic path wins)", () => { + // Deliberately inconsistent noCacheTokens: only the existing subtraction may produce 3000. + const r = Session.getUsage({ + model: model("@ai-sdk/openai"), + usage: { + inputTokens: 5000, + outputTokens: 100, + cachedInputTokens: 2000, + inputTokenDetails: { noCacheTokens: 9999, cacheReadTokens: 2000, cacheWriteTokens: 0 }, + } as any, + metadata: {} as any, + }) + expect(r.tokens.input).toBe(3000) + }) + test("raw exclusive counts with no inputTokenDetails (learn/usage path) keep the old behaviour", () => { const r = Session.getUsage({ model: model("@ai-sdk/amazon-bedrock"), @@ -122,3 +138,22 @@ describe("Session.getUsage - other providers are not changed", () => { expect(r.tokens.inputTotal).toBe(3400) }) }) + +describe("learn accountUsage with an inclusive SDK usage (generateObject shape)", () => { + test("bills Bedrock cache read and write once", async () => { + // Same counts as the mixed Bedrock case above, as returned by generateObject().usage. + const accounted = await accountUsage( + { ...model("@ai-sdk/amazon-bedrock") }, + { + inputTokens: 63362, + outputTokens: 352, + totalTokens: 63714, + cachedInputTokens: 61359, + inputTokenDetails: { noCacheTokens: 2, cacheReadTokens: 61359, cacheWriteTokens: 2001 }, + }, + { bedrock: { usage: { cacheWriteInputTokens: 2001 } } } as any, + ) + expect(accounted.inputTokens).toBe(63362) + expect(accounted.estimatedCost).toBeCloseTo((6 + 5280 + 18407.7 + 7503.75) / 1e6, 12) + }) +}) From 02d507bbb5ad48527231cb4c71f1fa99bfa8e1ba Mon Sep 17 00:00:00 2001 From: Anand Gupta Date: Wed, 7 Oct 2026 11:59:15 -0700 Subject: [PATCH 4/8] fix(session): count cache writes in the long-context price tier and read detail-only cache reads Review follow-ups on the cached-token accounting: - the `experimentalOver200K` tier check now uses the whole prompt (uncached + cache read + cache write); with `noCacheTokens` on the Anthropic/Bedrock branch a prompt made of cache writes would otherwise select the base price - read `inputTokenDetails.cacheReadTokens` when `cachedInputTokens` is absent - fail the build with an actionable error if the serde plugin cannot resolve - the build-plugin test reads the probe's stderr and asserts its exit code Co-Authored-By: Claude Opus 5.5 (1M context) --- .../script/smithy-node-serde-plugin.ts | 9 +++- packages/opencode/src/session/index.ts | 13 +++-- .../script/bedrock-eventstream-build.test.ts | 4 +- .../session-getusage-inclusive.test.ts | 53 +++++++++++++++++-- 4 files changed, 67 insertions(+), 12 deletions(-) diff --git a/packages/opencode/script/smithy-node-serde-plugin.ts b/packages/opencode/script/smithy-node-serde-plugin.ts index 6f243cad50..cd6ca17849 100644 --- a/packages/opencode/script/smithy-node-serde-plugin.ts +++ b/packages/opencode/script/smithy-node-serde-plugin.ts @@ -26,7 +26,14 @@ export function smithyNodeSerdePlugin(): BunPlugin { build.onResolve({ filter: /^@smithy\/core\/serde$/ }, (args) => { if (!IMPORTER.test(args.importer)) return undefined // Resolve from the importer's directory with Bun's default (non-browser) conditions. - return { path: Bun.resolveSync("@smithy/core/serde", path.dirname(args.importer)) } + try { + return { path: Bun.resolveSync("@smithy/core/serde", path.dirname(args.importer)) } + } catch (cause) { + throw new Error( + `smithy-node-serde plugin: cannot resolve @smithy/core/serde from ${args.importer}; Bedrock streaming would be empty in the built binary`, + { cause }, + ) + } }) }, } diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 048df6f5ab..08a18d0218 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -856,7 +856,11 @@ 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: SDK usage may carry the read count only in inputTokenDetails. + const sdkDetails = (input.usage as { inputTokenDetails?: { noCacheTokens?: number; cacheReadTokens?: number } }) + .inputTokenDetails + const cacheReadInputTokens = safe(input.usage.cachedInputTokens ?? sdkDetails?.cacheReadTokens ?? 0) + // altimate_change end const cacheWriteInputTokens = safe( (input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ?? // @ts-expect-error @@ -886,8 +890,7 @@ export namespace Session { // convention above) bills every cached token twice. Prefer the explicit uncached count when // the SDK supplies it, only on the Anthropic/Bedrock branch; every other provider and raw counts // (no details) keep the previous arithmetic. - const noCacheTokens = (input.usage as { inputTokenDetails?: { noCacheTokens?: number } }).inputTokenDetails - ?.noCacheTokens + const noCacheTokens = sdkDetails?.noCacheTokens const adjustedInputTokens = excludesCachedTokens && typeof noCacheTokens === "number" && Number.isFinite(noCacheTokens) ? Math.max(0, noCacheTokens) @@ -924,10 +927,12 @@ export namespace Session { }, } + // altimate_change start — upstream_fix: the long-context threshold counts the whole prompt, cache writes included. const costInfo = - input.model.cost?.experimentalOver200K && tokens.input + tokens.cache.read > 200_000 + input.model.cost?.experimentalOver200K && tokens.inputTotal > 200_000 ? input.model.cost.experimentalOver200K : input.model.cost + // altimate_change end return { cost: safe( new Decimal(0) diff --git a/packages/opencode/test/script/bedrock-eventstream-build.test.ts b/packages/opencode/test/script/bedrock-eventstream-build.test.ts index d6335c9844..35b6e67b29 100644 --- a/packages/opencode/test/script/bedrock-eventstream-build.test.ts +++ b/packages/opencode/test/script/bedrock-eventstream-build.test.ts @@ -27,8 +27,8 @@ async function bundleAndRun(mode: "plugin" | "none") { stdout: "pipe", stderr: "pipe", }) - const out = await new Response(run.stdout).text() - await run.exited + const [out, err] = await Promise.all([new Response(run.stdout).text(), new Response(run.stderr).text()]) + expect(await run.exited, err).toBe(0) return JSON.parse(out.trim().split("\n").pop()!) } finally { fs.rmSync(outdir, { recursive: true, force: true }) diff --git a/packages/opencode/test/session/session-getusage-inclusive.test.ts b/packages/opencode/test/session/session-getusage-inclusive.test.ts index 6d67a79d22..6382af130b 100644 --- a/packages/opencode/test/session/session-getusage-inclusive.test.ts +++ b/packages/opencode/test/session/session-getusage-inclusive.test.ts @@ -128,14 +128,57 @@ describe("Session.getUsage - other providers are not changed", () => { expect(r.tokens.input).toBe(3000) }) - test("raw exclusive counts with no inputTokenDetails (learn/usage path) keep the old behaviour", () => { + test("learn accountUsage without inputTokenDetails keeps the raw-count arithmetic", async () => { + const accounted = await accountUsage( + model("@ai-sdk/amazon-bedrock"), + { inputTokens: 800, outputTokens: 40, cachedInputTokens: 2000 }, + { bedrock: { usage: { cacheWriteInputTokens: 600 } } } as any, + ) + expect(accounted.inputTokens).toBe(3400) + expect(accounted.estimatedCost).toBeCloseTo((800 * 3 + 40 * 15 + 2000 * 0.3 + 600 * 3.75) / 1e6, 12) + }) +}) + +describe("Session.getUsage - details-only cache reads and the long-context tier", () => { + test("cache reads present only in inputTokenDetails are counted", () => { const r = Session.getUsage({ model: model("@ai-sdk/amazon-bedrock"), - usage: { inputTokens: 800, outputTokens: 40, cachedInputTokens: 2000 } as any, - metadata: { bedrock: { usage: { cacheWriteInputTokens: 600 } } } as any, + usage: { + inputTokens: 61361, + outputTokens: 10, + inputTokenDetails: { noCacheTokens: 2, cacheReadTokens: 61359, cacheWriteTokens: 0 }, + } as any, + metadata: { bedrock: { usage: { cacheWriteInputTokens: 0 } } } as any, + }) + expect(r.tokens.cache.read).toBe(61359) + expect(r.tokens.inputTotal).toBe(61361) + expect(r.cost).toBeCloseTo((2 * 3 + 10 * 15 + 61359 * 0.3) / 1e6, 12) + }) + + test("a prompt over 200K made of cache writes selects the over-200K price", () => { + const tiered: any = { + ...model("@ai-sdk/anthropic"), + cost: { ...PRICE, experimentalOver200K: { input: 6, output: 22.5, cache: { read: 0.6, write: 7.5 } } }, + } + const r = Session.getUsage({ + model: tiered, + usage: inclusiveUsage(1, 0, 210_000, 5), + metadata: { anthropic: { cacheCreationInputTokens: 210_000 } } as any, + }) + expect(r.cost).toBeCloseTo((1 * 6 + 5 * 22.5 + 210_000 * 7.5) / 1e6, 12) + }) + + test("a prompt at or under 200K keeps the base price", () => { + const tiered: any = { + ...model("@ai-sdk/anthropic"), + cost: { ...PRICE, experimentalOver200K: { input: 6, output: 22.5, cache: { read: 0.6, write: 7.5 } } }, + } + const r = Session.getUsage({ + model: tiered, + usage: inclusiveUsage(0, 0, 200_000, 5), + metadata: { anthropic: { cacheCreationInputTokens: 200_000 } } as any, }) - expect(r.tokens.input).toBe(800) - expect(r.tokens.inputTotal).toBe(3400) + expect(r.cost).toBeCloseTo((5 * 15 + 200_000 * 3.75) / 1e6, 12) }) }) From b23e393fc0977b2ba2de3741a7027da335e35842 Mon Sep 17 00:00:00 2001 From: Anand Gupta Date: Wed, 7 Oct 2026 12:27:14 -0700 Subject: [PATCH 5/8] fix(session): keep detail-only cache writes and reads consistent across accounting and telemetry - read `inputTokenDetails.cacheWriteTokens` as a last-resort cache-write count, on the Anthropic/Bedrock branch only - forward it from the learn accounting path - emit the generation telemetry `tokens_cache_read` field when the read count is only in `inputTokenDetails` - test the serde plugin's resolution-failure branch Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/opencode/src/altimate/learn/usage.ts | 9 ++++-- packages/opencode/src/session/index.ts | 10 ++++-- packages/opencode/src/session/processor.ts | 4 ++- .../script/bedrock-eventstream-build.test.ts | 19 +++++++++++ .../session-getusage-inclusive.test.ts | 32 +++++++++++++++++++ 5 files changed, 69 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/altimate/learn/usage.ts b/packages/opencode/src/altimate/learn/usage.ts index 84612952fa..923d112aef 100644 --- a/packages/opencode/src/altimate/learn/usage.ts +++ b/packages/opencode/src/altimate/learn/usage.ts @@ -27,8 +27,13 @@ export async function accountUsage(model: UsageModel, usage: GenerateUsage, meta cachedInputTokens: usage.cachedInputTokens ?? usage.inputTokenDetails?.cacheReadTokens, // AI SDK v6 reports inputTokens inclusive of cache for Anthropic/Bedrock; the uncached count lets // Session.getUsage bill each token class once. - ...(usage.inputTokenDetails?.noCacheTokens !== undefined - ? { inputTokenDetails: { noCacheTokens: usage.inputTokenDetails.noCacheTokens } } + ...(usage.inputTokenDetails?.noCacheTokens !== undefined || usage.inputTokenDetails?.cacheWriteTokens !== undefined + ? { + inputTokenDetails: { + noCacheTokens: usage.inputTokenDetails.noCacheTokens, + cacheWriteTokens: usage.inputTokenDetails.cacheWriteTokens, + }, + } : {}), }, metadata, diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 08a18d0218..cb8c9926a5 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -857,8 +857,11 @@ export namespace Session { const reasoningTokens = safe(input.usage.reasoningTokens ?? 0) // altimate_change start — upstream_fix: SDK usage may carry the read count only in inputTokenDetails. - const sdkDetails = (input.usage as { inputTokenDetails?: { noCacheTokens?: number; cacheReadTokens?: number } }) - .inputTokenDetails + const sdkDetails = ( + input.usage as { + inputTokenDetails?: { noCacheTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number } + } + ).inputTokenDetails const cacheReadInputTokens = safe(input.usage.cachedInputTokens ?? sdkDetails?.cacheReadTokens ?? 0) // altimate_change end const cacheWriteInputTokens = safe( @@ -867,6 +870,9 @@ export namespace Session { input.metadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ?? // @ts-expect-error input.metadata?.["venice"]?.["usage"]?.["cacheCreationInputTokens"] ?? + // altimate_change start — upstream_fix: Anthropic/Bedrock adapters may report writes only in the details. + (input.metadata?.["anthropic"] || input.metadata?.["bedrock"] ? sdkDetails?.cacheWriteTokens : undefined) ?? + // altimate_change end 0) as number, ) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 81cfd3036f..a9238ceb7f 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -945,7 +945,9 @@ export namespace SessionProcessor { tokens_input_total: usage.tokens.inputTotal, // altimate_change end ...(value.usage.reasoningTokens !== undefined && { tokens_reasoning: usage.tokens.reasoning }), - ...(value.usage.cachedInputTokens !== undefined && { tokens_cache_read: usage.tokens.cache.read }), + ...((value.usage.cachedInputTokens !== undefined || + (value.usage as { inputTokenDetails?: { cacheReadTokens?: number } }).inputTokenDetails + ?.cacheReadTokens !== undefined) && { tokens_cache_read: usage.tokens.cache.read }), ...(usage.tokens.cache.write > 0 && { tokens_cache_write: usage.tokens.cache.write }), }) // altimate_change end diff --git a/packages/opencode/test/script/bedrock-eventstream-build.test.ts b/packages/opencode/test/script/bedrock-eventstream-build.test.ts index 35b6e67b29..01fccaf03c 100644 --- a/packages/opencode/test/script/bedrock-eventstream-build.test.ts +++ b/packages/opencode/test/script/bedrock-eventstream-build.test.ts @@ -74,6 +74,25 @@ describe("smithyNodeSerdePlugin scope", () => { expect(hit.path).not.toContain("browser") }) + test("fails with an actionable error (and keeps the cause) when the serde module cannot be resolved", () => { + const resolve = resolver() + const original = Bun.resolveSync + const boom = new Error("not found") + ;(Bun as any).resolveSync = () => { + throw boom + } + try { + resolve({ importer: "/x/node_modules/@smithy/util-buffer-from/dist-es/index.js" }) + throw new Error("expected resolver to throw") + } catch (err: any) { + expect(err.message).toContain("cannot resolve @smithy/core/serde") + expect(err.message).toContain("/x/node_modules/@smithy/util-buffer-from/dist-es/index.js") + expect(err.cause).toBe(boom) + } finally { + ;(Bun as any).resolveSync = original + } + }) + test("leaves every other importer untouched", () => { const resolve = resolver() expect(resolve({ importer: "/x/node_modules/@smithy/util-utf8/dist-es/fromUtf8.js" })).toBeUndefined() diff --git a/packages/opencode/test/session/session-getusage-inclusive.test.ts b/packages/opencode/test/session/session-getusage-inclusive.test.ts index 6382af130b..4784d3dcb6 100644 --- a/packages/opencode/test/session/session-getusage-inclusive.test.ts +++ b/packages/opencode/test/session/session-getusage-inclusive.test.ts @@ -155,6 +155,38 @@ describe("Session.getUsage - details-only cache reads and the long-context tier" expect(r.cost).toBeCloseTo((2 * 3 + 10 * 15 + 61359 * 0.3) / 1e6, 12) }) + test("cache writes present only in inputTokenDetails are counted for Anthropic/Bedrock, not for others", () => { + const usage = { + inputTokens: 1010, + outputTokens: 10, + cachedInputTokens: 0, + inputTokenDetails: { noCacheTokens: 10, cacheReadTokens: 0, cacheWriteTokens: 1000 }, + } as any + const bedrock = Session.getUsage({ + model: model("@ai-sdk/amazon-bedrock"), + usage, + metadata: { bedrock: {} } as any, + }) + expect(bedrock.tokens.cache.write).toBe(1000) + expect(bedrock.cost).toBeCloseTo((10 * 3 + 10 * 15 + 1000 * 3.75) / 1e6, 12) + const openai = Session.getUsage({ model: model("@ai-sdk/openai"), usage, metadata: {} as any }) + expect(openai.tokens.cache.write).toBe(0) + }) + + test("learn accountUsage forwards detail-only cache writes", async () => { + const accounted = await accountUsage( + model("@ai-sdk/amazon-bedrock"), + { + inputTokens: 1010, + outputTokens: 10, + inputTokenDetails: { noCacheTokens: 10, cacheWriteTokens: 1000 }, + }, + { bedrock: {} } as any, + ) + expect(accounted.inputTokens).toBe(1010) + expect(accounted.estimatedCost).toBeCloseTo((10 * 3 + 10 * 15 + 1000 * 3.75) / 1e6, 12) + }) + test("a prompt over 200K made of cache writes selects the over-200K price", () => { const tiered: any = { ...model("@ai-sdk/anthropic"), From aaaf42f981636fafb988004cabfe08c30c90d721 Mon Sep 17 00:00:00 2001 From: Anand Gupta Date: Wed, 7 Oct 2026 13:04:59 -0700 Subject: [PATCH 6/8] fix(session): trust detail cache counts only alongside `noCacheTokens` An incomplete SDK usage record (details without `noCacheTokens`) may not be inclusive; adding its read/write counts to a full `inputTokens` would bill them twice. Couple the detail-derived read/write fallbacks to the presence of `noCacheTokens` and test the incomplete shape. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/opencode/src/session/index.ts | 11 +++++++++-- .../session/session-getusage-inclusive.test.ts | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index cb8c9926a5..9651aae3cf 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -862,7 +862,12 @@ export namespace Session { inputTokenDetails?: { noCacheTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number } } ).inputTokenDetails - const cacheReadInputTokens = safe(input.usage.cachedInputTokens ?? sdkDetails?.cacheReadTokens ?? 0) + // Detail counts are only trusted together with noCacheTokens, i.e. a complete v6 usage record; + // otherwise inputTokens may not be inclusive and adding them would bill cached tokens twice. + const detailsComplete = typeof sdkDetails?.noCacheTokens === "number" + const cacheReadInputTokens = safe( + input.usage.cachedInputTokens ?? (detailsComplete ? sdkDetails?.cacheReadTokens : undefined) ?? 0, + ) // altimate_change end const cacheWriteInputTokens = safe( (input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ?? @@ -871,7 +876,9 @@ export namespace Session { // @ts-expect-error input.metadata?.["venice"]?.["usage"]?.["cacheCreationInputTokens"] ?? // altimate_change start — upstream_fix: Anthropic/Bedrock adapters may report writes only in the details. - (input.metadata?.["anthropic"] || input.metadata?.["bedrock"] ? sdkDetails?.cacheWriteTokens : undefined) ?? + (detailsComplete && (input.metadata?.["anthropic"] || input.metadata?.["bedrock"]) + ? sdkDetails?.cacheWriteTokens + : undefined) ?? // altimate_change end 0) as number, ) diff --git a/packages/opencode/test/session/session-getusage-inclusive.test.ts b/packages/opencode/test/session/session-getusage-inclusive.test.ts index 4784d3dcb6..fdd22fc7b7 100644 --- a/packages/opencode/test/session/session-getusage-inclusive.test.ts +++ b/packages/opencode/test/session/session-getusage-inclusive.test.ts @@ -187,6 +187,21 @@ describe("Session.getUsage - details-only cache reads and the long-context tier" expect(accounted.estimatedCost).toBeCloseTo((10 * 3 + 10 * 15 + 1000 * 3.75) / 1e6, 12) }) + test("detail cache counts without noCacheTokens are ignored (no double billing from an incomplete record)", () => { + const r = Session.getUsage({ + model: model("@ai-sdk/amazon-bedrock"), + usage: { + inputTokens: 2011, + outputTokens: 10, + inputTokenDetails: { cacheReadTokens: 1000, cacheWriteTokens: 1000 }, + } as any, + metadata: { bedrock: {} } as any, + }) + expect(r.tokens.cache.read).toBe(0) + expect(r.tokens.cache.write).toBe(0) + expect(r.tokens.inputTotal).toBe(2011) + }) + test("a prompt over 200K made of cache writes selects the over-200K price", () => { const tiered: any = { ...model("@ai-sdk/anthropic"), From 5249f8ad1b85e04c4cce0c2d4a3be7af9ceed0f5 Mon Sep 17 00:00:00 2001 From: Anand Gupta Date: Wed, 7 Oct 2026 13:35:16 -0700 Subject: [PATCH 7/8] fix(session): keep detail cache fallbacks for inclusive providers and metadata-less Anthropic calls - the incomplete-record guard applies only on the Anthropic/Bedrock branch; OpenAI-style providers keep reading cache reads from `inputTokenDetails` - when provider metadata is missing (failed `generateObject` paths), identify Anthropic-family models by their SDK package so cache writes still come from `inputTokenDetails` Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/opencode/src/session/index.ts | 20 +++++++++----- .../session-getusage-inclusive.test.ts | 26 +++++++++++++++++++ 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 9651aae3cf..5ebeeaf33b 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -862,11 +862,20 @@ export namespace Session { inputTokenDetails?: { noCacheTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number } } ).inputTokenDetails - // Detail counts are only trusted together with noCacheTokens, i.e. a complete v6 usage record; - // otherwise inputTokens may not be inclusive and adding them would bill cached tokens twice. + // altimate_change end + const excludesCachedTokens = !!(input.metadata?.["anthropic"] || input.metadata?.["bedrock"]) + // altimate_change start — upstream_fix: detail counts are trusted when the arithmetic below subtracts + // them (inclusive providers) or when noCacheTokens marks a complete v6 record; otherwise adding them to + // a full inputTokens would bill cached tokens twice. const detailsComplete = typeof sdkDetails?.noCacheTokens === "number" + const trustDetails = detailsComplete || !excludesCachedTokens + const anthropicFamily = + excludesCachedTokens || + input.model.api.npm === "@ai-sdk/anthropic" || + input.model.api.npm === "@ai-sdk/amazon-bedrock" || + input.model.api.npm === "@ai-sdk/google-vertex/anthropic" const cacheReadInputTokens = safe( - input.usage.cachedInputTokens ?? (detailsComplete ? sdkDetails?.cacheReadTokens : undefined) ?? 0, + input.usage.cachedInputTokens ?? (trustDetails ? sdkDetails?.cacheReadTokens : undefined) ?? 0, ) // altimate_change end const cacheWriteInputTokens = safe( @@ -876,9 +885,7 @@ export namespace Session { // @ts-expect-error input.metadata?.["venice"]?.["usage"]?.["cacheCreationInputTokens"] ?? // altimate_change start — upstream_fix: Anthropic/Bedrock adapters may report writes only in the details. - (detailsComplete && (input.metadata?.["anthropic"] || input.metadata?.["bedrock"]) - ? sdkDetails?.cacheWriteTokens - : undefined) ?? + (trustDetails && anthropicFamily ? sdkDetails?.cacheWriteTokens : undefined) ?? // altimate_change end 0) as number, ) @@ -887,7 +894,6 @@ export namespace Session { // 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 — 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-inclusive.test.ts b/packages/opencode/test/session/session-getusage-inclusive.test.ts index fdd22fc7b7..ef42fc608e 100644 --- a/packages/opencode/test/session/session-getusage-inclusive.test.ts +++ b/packages/opencode/test/session/session-getusage-inclusive.test.ts @@ -202,6 +202,32 @@ describe("Session.getUsage - details-only cache reads and the long-context tier" expect(r.tokens.inputTotal).toBe(2011) }) + test("inclusive providers keep the details-only cache-read fallback without noCacheTokens", () => { + const r = Session.getUsage({ + model: model("@ai-sdk/openai"), + usage: { inputTokens: 5000, outputTokens: 10, inputTokenDetails: { cacheReadTokens: 2000 } } as any, + metadata: {} as any, + }) + expect(r.tokens.input).toBe(3000) + expect(r.tokens.cache.read).toBe(2000) + }) + + test("Anthropic-family model without provider metadata still takes cache writes from the details", () => { + // generateObject failure paths report usage without provider metadata + const r = Session.getUsage({ + model: model("@ai-sdk/amazon-bedrock"), + usage: { + inputTokens: 1010, + outputTokens: 10, + inputTokenDetails: { noCacheTokens: 10, cacheReadTokens: 0, cacheWriteTokens: 1000 }, + } as any, + metadata: undefined, + }) + expect(r.tokens.cache.write).toBe(1000) + expect(r.tokens.input).toBe(10) + expect(r.cost).toBeCloseTo((10 * 3 + 10 * 15 + 1000 * 3.75) / 1e6, 12) + }) + test("a prompt over 200K made of cache writes selects the over-200K price", () => { const tiered: any = { ...model("@ai-sdk/anthropic"), From 94df9bc5c3aee5b4cf1ab056e61b68022130c620 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Wed, 7 Oct 2026 13:55:23 -0700 Subject: [PATCH 8/8] fix(session): omit `tokens_cache_read` when accounting rejected the detail count - A cache-read count present only in `inputTokenDetails` can be rejected as incomplete by `Session.getUsage`; telemetry then reported a zero that looked measured. `cacheReadReported` emits the field only when the count was kept. - Make the resolver failure test fail clearly if the resolver stops throwing. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/opencode/src/session/processor.ts | 20 ++++++++++-- .../script/bedrock-eventstream-build.test.ts | 12 ++++--- .../session-getusage-inclusive.test.ts | 31 +++++++++++++++++++ 3 files changed, 55 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index a9238ceb7f..c07b05d735 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -22,6 +22,20 @@ import type { SessionID, MessageID } from "./schema" // altimate_change start — import Telemetry for per-generation token tracking import { Telemetry } from "@/altimate/telemetry" // altimate_change end +// altimate_change start — upstream_fix: report a cache-read count only when accounting accepted it +/** + * Whether the provider supplied a cache-read count that `Session.getUsage` kept. A count present only in + * `inputTokenDetails` can be rejected as incomplete; reporting the resulting zero would look measured. + */ +export function cacheReadReported( + raw: { cachedInputTokens?: number; inputTokenDetails?: { cacheReadTokens?: number } }, + accepted: number, +): boolean { + if (raw.cachedInputTokens !== undefined) return true + const detail = raw.inputTokenDetails?.cacheReadTokens + return detail !== undefined && detail === accepted +} +// altimate_change end // altimate_change start — write-starvation breaker + loop detection (fork-only // modules) and the run-mode flag that gates armed behavior. import { SessionStarvation } from "./starvation" @@ -945,9 +959,9 @@ export namespace SessionProcessor { tokens_input_total: usage.tokens.inputTotal, // altimate_change end ...(value.usage.reasoningTokens !== undefined && { tokens_reasoning: usage.tokens.reasoning }), - ...((value.usage.cachedInputTokens !== undefined || - (value.usage as { inputTokenDetails?: { cacheReadTokens?: number } }).inputTokenDetails - ?.cacheReadTokens !== undefined) && { tokens_cache_read: usage.tokens.cache.read }), + ...(cacheReadReported(value.usage, usage.tokens.cache.read) && { + tokens_cache_read: usage.tokens.cache.read, + }), ...(usage.tokens.cache.write > 0 && { tokens_cache_write: usage.tokens.cache.write }), }) // altimate_change end diff --git a/packages/opencode/test/script/bedrock-eventstream-build.test.ts b/packages/opencode/test/script/bedrock-eventstream-build.test.ts index 01fccaf03c..08a4d61c08 100644 --- a/packages/opencode/test/script/bedrock-eventstream-build.test.ts +++ b/packages/opencode/test/script/bedrock-eventstream-build.test.ts @@ -81,16 +81,18 @@ describe("smithyNodeSerdePlugin scope", () => { ;(Bun as any).resolveSync = () => { throw boom } + let caught: any try { resolve({ importer: "/x/node_modules/@smithy/util-buffer-from/dist-es/index.js" }) - throw new Error("expected resolver to throw") - } catch (err: any) { - expect(err.message).toContain("cannot resolve @smithy/core/serde") - expect(err.message).toContain("/x/node_modules/@smithy/util-buffer-from/dist-es/index.js") - expect(err.cause).toBe(boom) + } catch (err) { + caught = err } finally { ;(Bun as any).resolveSync = original } + expect(caught).toBeDefined() + expect(caught.message).toContain("cannot resolve @smithy/core/serde") + expect(caught.message).toContain("/x/node_modules/@smithy/util-buffer-from/dist-es/index.js") + expect(caught.cause).toBe(boom) }) test("leaves every other importer untouched", () => { diff --git a/packages/opencode/test/session/session-getusage-inclusive.test.ts b/packages/opencode/test/session/session-getusage-inclusive.test.ts index ef42fc608e..7cf1cba28b 100644 --- a/packages/opencode/test/session/session-getusage-inclusive.test.ts +++ b/packages/opencode/test/session/session-getusage-inclusive.test.ts @@ -1,6 +1,7 @@ import { describe, test, expect } from "bun:test" import { Session } from "../../src/session" import { accountUsage } from "../../src/altimate/learn/usage" +import { cacheReadReported } from "../../src/session/processor" /** * Cost accounting for providers whose AI SDK adapter reports an INCLUSIVE `inputTokens`. @@ -202,6 +203,36 @@ describe("Session.getUsage - details-only cache reads and the long-context tier" expect(r.tokens.inputTotal).toBe(2011) }) + test("learn accountUsage keeps an explicit top-level cachedInputTokens", async () => { + const accounted = await accountUsage( + model("@ai-sdk/amazon-bedrock"), + { inputTokens: 1011, outputTokens: 10, cachedInputTokens: 1000 }, + { bedrock: {} } as any, + ) + expect(accounted.inputTokens).toBe(2011) + }) + + test("learn accountUsage still counts detail-only cache reads for inclusive providers", async () => { + const accounted = await accountUsage( + model("@ai-sdk/openai"), + { inputTokens: 5000, outputTokens: 10, inputTokenDetails: { cacheReadTokens: 2000 } }, + {} as any, + ) + expect(accounted.inputTokens).toBe(5000) + expect(accounted.estimatedCost).toBeCloseTo((3000 * 3 + 2000 * 0.3 + 10 * 15) / 1e6, 12) + }) + + test("telemetry reports a cache-read count only when accounting kept it", () => { + // Rejected incomplete detail: accounting holds 0, the provider said 1000. + expect(cacheReadReported({ inputTokenDetails: { cacheReadTokens: 1000 } }, 0)).toBe(false) + // Accepted detail, including a genuine zero. + expect(cacheReadReported({ inputTokenDetails: { cacheReadTokens: 1000 } }, 1000)).toBe(true) + expect(cacheReadReported({ inputTokenDetails: { cacheReadTokens: 0 } }, 0)).toBe(true) + // An explicit top-level count is always reported; no count at all never is. + expect(cacheReadReported({ cachedInputTokens: 0 }, 0)).toBe(true) + expect(cacheReadReported({}, 0)).toBe(false) + }) + test("inclusive providers keep the details-only cache-read fallback without noCacheTokens", () => { const r = Session.getUsage({ model: model("@ai-sdk/openai"),