Skip to content
9 changes: 8 additions & 1 deletion packages/opencode/script/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions packages/opencode/script/smithy-node-serde-plugin.ts
Original file line number Diff line number Diff line change
@@ -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(
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
`smithy-node-serde plugin: cannot resolve @smithy/core/serde from ${args.importer}; Bedrock streaming would be empty in the built binary`,
{ cause },
)
}
})
},
}
}
2 changes: 1 addition & 1 deletion packages/opencode/src/altimate/learn/reflect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions packages/opencode/src/altimate/learn/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand Down
50 changes: 43 additions & 7 deletions packages/opencode/src/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -856,21 +856,44 @@ 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,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)
// 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,
)

// 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 — clamp at zero so inconsistent provider counts
// (inputTokens < cachedInputTokens) don't produce negative cost.
// Without the Math.max(0, ...), `tokens.input * costInfo.input` becomes
Expand All @@ -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 =
Comment thread
anandgupta42 marked this conversation as resolved.
excludesCachedTokens && typeof noCacheTokens === "number" && Number.isFinite(noCacheTokens)
Comment thread
anandgupta42 marked this conversation as resolved.
? Math.max(0, noCacheTokens)
Comment thread
anandgupta42 marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
: Math.max(
0,
safe(excludesCachedTokens ? inputTokens : inputTokens - cacheReadInputTokens - cacheWriteInputTokens),
)
// altimate_change end

const total = iife(() => {
Expand Down Expand Up @@ -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)
Expand Down
18 changes: 17 additions & 1 deletion packages/opencode/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
113 changes: 113 additions & 0 deletions packages/opencode/test/script/bedrock-eventstream-build.test.ts
Original file line number Diff line number Diff line change
@@ -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()")
})
})
20 changes: 20 additions & 0 deletions packages/opencode/test/script/bedrock-eventstream-bundle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Usage: bun bedrock-eventstream-bundle.ts <outdir> <plugin|none>
// 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)
}
38 changes: 38 additions & 0 deletions packages/opencode/test/script/bedrock-eventstream-frames.ts
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading