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..cd6ca17849 --- /dev/null +++ b/packages/opencode/script/smithy-node-serde-plugin.ts @@ -0,0 +1,40 @@ +// 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. + 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/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..923d112aef 100644 --- a/packages/opencode/src/altimate/learn/usage.ts +++ b/packages/opencode/src/altimate/learn/usage.ts @@ -25,6 +25,16 @@ 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 || 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 2db11be27b..5ebeeaf33b 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -856,13 +856,37 @@ 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; cacheWriteTokens?: number } + } + ).inputTokenDetails + // 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 ?? (trustDetails ? sdkDetails?.cacheReadTokens : undefined) ?? 0, + ) + // altimate_change end const cacheWriteInputTokens = safe( (input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ?? // @ts-expect-error 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. + (trustDetails && anthropicFamily ? sdkDetails?.cacheWriteTokens : undefined) ?? + // altimate_change end 0) as number, ) @@ -870,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 @@ -879,10 +902,21 @@ 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 = sdkDetails?.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(() => { @@ -912,10 +946,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/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 81cfd3036f..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,7 +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 && { 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 new file mode 100644 index 0000000000..08a4d61c08 --- /dev/null +++ b/packages/opencode/test/script/bedrock-eventstream-build.test.ts @@ -0,0 +1,113 @@ +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, 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 }) + } +} + +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) + + // 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") + }, 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("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 + } + let caught: any + try { + resolve({ importer: "/x/node_modules/@smithy/util-buffer-from/dist-es/index.js" }) + } 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", () => { + 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 })) 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..7cf1cba28b --- /dev/null +++ b/packages/opencode/test/session/session-getusage-inclusive.test.ts @@ -0,0 +1,306 @@ +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`. + * + * 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("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("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: 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("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("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("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"), + 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"), + 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.cost).toBeCloseTo((5 * 15 + 200_000 * 3.75) / 1e6, 12) + }) +}) + +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) + }) +})