Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
21 changes: 12 additions & 9 deletions packages/opencode/src/altimate/learn/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,21 @@ export type UsageModel = { cost?: Provider.Model["cost"]; api?: Pick<Provider.Mo
export async function accountUsage(model: UsageModel, usage: GenerateUsage, metadata = usage.providerMetadata): Promise<UsageSummary> {
// Loading the session lazily avoids a cycle through the learning hooks during startup.
const { Session } = await import("@/session")
const sdkUsage = {
// Accounted callbacks retain original counts so import estimates can fill missing values
// without normalizing Anthropic's cache counters a second time.
inputTokens: usage.rawUsage?.inputTokens ?? usage.inputTokens,
Comment thread
anandgupta42 marked this conversation as resolved.
outputTokens: usage.rawUsage?.outputTokens ?? usage.outputTokens,
totalTokens: usage.totalTokens,
reasoningTokens: usage.reasoningTokens ?? usage.outputTokenDetails?.reasoningTokens,
cachedInputTokens: usage.cachedInputTokens ?? usage.inputTokenDetails?.cacheReadTokens,
// The SDK's cache split; without it an inclusive input count is billed as uncached.
inputTokenDetails: usage.inputTokenDetails,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
const accounted = Session.getUsage({
// Session accounting only reads cost metadata and the provider's SDK name.
model: { ...model, api: model.api ?? { npm: "" } } as Provider.Model,
usage: {
// Accounted callbacks retain original counts so import estimates can fill missing values
// without normalizing Anthropic's cache counters a second time.
inputTokens: usage.rawUsage?.inputTokens ?? usage.inputTokens,
outputTokens: usage.rawUsage?.outputTokens ?? usage.outputTokens,
totalTokens: usage.totalTokens,
reasoningTokens: usage.reasoningTokens ?? usage.outputTokenDetails?.reasoningTokens,
cachedInputTokens: usage.cachedInputTokens ?? usage.inputTokenDetails?.cacheReadTokens,
},
usage: sdkUsage,
metadata,
})
return {
Expand Down
10 changes: 5 additions & 5 deletions packages/opencode/src/altimate/telemetry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,11 +275,11 @@ export namespace Telemetry {
//
// SEMANTICS (read this before writing dashboard queries):
// tokens_input = UNCACHED input tokens. Equal to 0 on a full cache hit.
// Normalized across providers: Anthropic (and Bedrock
// Anthropic) return this directly; non-Anthropic
// providers return the inclusive total and Session.getUsage
// subtracts cache_read (and cache_write where present)
// to derive the uncached portion. cache_write in
// Normalized across providers: the AI SDK reports the
// inclusive total with a no-cache/cache-read/cache-write
// split, which Session.getUsage uses (older SDK usage
// without the split falls back to subtracting cache
// tokens, except Anthropic/Bedrock). cache_write in
Comment thread
anandgupta42 marked this conversation as resolved.
// particular is only populated for Anthropic / Bedrock /
// Venice metadata paths — OpenAI / OpenRouter don't
// surface a "cache write" concept today, so the
Expand Down
19 changes: 15 additions & 4 deletions packages/opencode/src/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -872,21 +872,30 @@ export namespace Session {
const outputTokens = safe(input.usage.outputTokens ?? 0)
const reasoningTokens = safe(input.usage.reasoningTokens ?? 0)

const cacheReadInputTokens = safe(input.usage.cachedInputTokens ?? 0)
// altimate_change start — upstream_fix: AI SDK v6 reports `inputTokenDetails`, where
// `inputTokens` includes cached tokens for every provider (Anthropic and Bedrock too).
// Without this, cached input was also billed at the uncached rate (~6x on cached sessions).
const details = (input.usage as { inputTokenDetails?: Record<string, number | undefined> }).inputTokenDetails
const reportsDetails = typeof details?.noCacheTokens === "number"
const cacheReadInputTokens = safe((reportsDetails ? details?.cacheReadTokens : undefined) ?? input.usage.cachedInputTokens ?? 0)
Comment thread
anandgupta42 marked this conversation as resolved.
Comment thread
anandgupta42 marked this conversation as resolved.
const cacheWriteInputTokens = safe(
(input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
((reportsDetails ? details?.cacheWriteTokens : undefined) ??
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
// @ts-expect-error
input.metadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ??
// @ts-expect-error
input.metadata?.["venice"]?.["usage"]?.["cacheCreationInputTokens"] ??
0) as number,
)
// altimate_change end

// OpenRouter provides inputTokens as the total count of input tokens (including cached).
// AFAIK other providers (OpenRouter/OpenAI/Gemini etc.) do it the same way e.g. vercel/ai#8794 (comment)
// Anthropic does it differently though - inputTokens doesn't include cached tokens.
// It looks like Altimate Code's cost calculation assumes all providers return inputTokens the same way Anthropic does (I'm guessing getUsage logic was originally implemented with anthropic), so it's causing incorrect cost calculation for OpenRouter and others.
const excludesCachedTokens = !!(input.metadata?.["anthropic"] || input.metadata?.["bedrock"])
// altimate_change start — upstream_fix: only the pre-v6 usage shape excludes cached tokens
const excludesCachedTokens = !reportsDetails && !!(input.metadata?.["anthropic"] || input.metadata?.["bedrock"])
Comment thread
anandgupta42 marked this conversation as resolved.
// altimate_change end
// altimate_change start — clamp at zero so inconsistent provider counts
// (inputTokens < cachedInputTokens) don't produce negative cost.
// Without the Math.max(0, ...), `tokens.input * costInfo.input` becomes
Expand Down Expand Up @@ -929,7 +938,9 @@ export namespace Session {
}

const costInfo =
input.model.cost?.experimentalOver200K && tokens.input + tokens.cache.read > 200_000
// altimate_change start — upstream_fix: the long-context tier counts all prompt tokens, cache writes included
input.model.cost?.experimentalOver200K && tokens.input + tokens.cache.read + tokens.cache.write > 200_000
// altimate_change end
? input.model.cost.experimentalOver200K
: input.model.cost
return {
Expand Down
13 changes: 13 additions & 0 deletions packages/opencode/test/altimate/learn/reflect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,19 @@ describe("reflect (stubbed model)", () => {
})
})

test("splits the SDK's inclusive input count by its cache details instead of billing it as uncached", async () => {
const model = { cost: { input: 4, output: 20, cache: { read: 0.2, write: 5 } } }
const accounted = await accountUsage(model, {
inputTokens: 31_767,
outputTokens: 256,
cachedInputTokens: 29_128,
inputTokenDetails: { noCacheTokens: 4, cacheReadTokens: 29_128, cacheWriteTokens: 2_635 },
providerMetadata: { anthropic: { cacheCreationInputTokens: 2_635 } },
})
expect(accounted.inputTokens).toBe(31_767)
expect(accounted.estimatedCost).toBeCloseTo(0.0241366, 7)
})

test("uses session metadata cache pricing and the active session's handling of provider metadata", async () => {
const model = { cost: { input: 2, output: 8, cache: { read: 0.25, write: 3 } } }
const usage = { inputTokens: 1_000, outputTokens: 200, cachedInputTokens: 300, reasoningTokens: 40 }
Expand Down
77 changes: 77 additions & 0 deletions packages/opencode/test/session/session-getusage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,83 @@ describe("Session.getUsage — OpenAI-style (inclusive input)", () => {
})
})

describe("Session.getUsage — AI SDK v6 usage (inclusive input with inputTokenDetails)", () => {
// Shape @ai-sdk/anthropic 3.x + ai 6 deliver for one cached agent step.
const opusPricing = { input: 4, output: 20, cache: { read: 0.2, write: 5 } }
const v6Step = {
inputTokens: 31_767,
inputTokenDetails: { noCacheTokens: 4, cacheReadTokens: 29_128, cacheWriteTokens: 2_635 },
outputTokens: 256,
cachedInputTokens: 29_128,
}

test("Anthropic: cached tokens are not also billed as uncached input", () => {
const result = Session.getUsage({
model: { ...fakeModel("@ai-sdk/anthropic"), cost: opusPricing },
usage: v6Step as any,
metadata: { anthropic: { cacheCreationInputTokens: 2_635 } } as any,
})
expect(result.tokens.input).toBe(4)
expect(result.tokens.cache.read).toBe(29_128)
expect(result.tokens.cache.write).toBe(2_635)
expect(result.tokens.inputTotal).toBe(31_767)
expect(result.tokens.total).toBe(31_767 + 256)
// 4*4 + 256*20 + 29128*0.2 + 2635*5 per million; the old path charged all 31,767 at $4/M (≈ $0.151).
expect(result.cost).toBeCloseTo(0.0241366, 7)
})

test("Bedrock: same inclusive shape is split the same way", () => {
const result = Session.getUsage({
model: fakeModel("@ai-sdk/amazon-bedrock"),
usage: v6Step as any,
metadata: { bedrock: { usage: { cacheWriteInputTokens: 2_635 } } } as any,
})
expect(result.tokens.input).toBe(4)
expect(result.tokens.inputTotal).toBe(31_767)
})

test("cache writes count toward the over-200K price tier", () => {
const tiered = { input: 1, output: 1, cache: { read: 0, write: 1 }, experimentalOver200K: { input: 2, output: 2, cache: { read: 0, write: 2 } } }
const result = Session.getUsage({
model: { ...fakeModel("@ai-sdk/anthropic"), cost: tiered },
usage: { inputTokens: 210_000, inputTokenDetails: { noCacheTokens: 10_000, cacheReadTokens: 0, cacheWriteTokens: 200_000 }, outputTokens: 0 } as any,
metadata: { anthropic: { cacheCreationInputTokens: 200_000 } } as any,
})
// 10K uncached + 200K cache write at the over-200K rates of $2/M each.
expect(result.cost).toBeCloseTo(0.42, 10)
})

test("full cache hit reports zero uncached input", () => {
const result = Session.getUsage({
model: fakeModel("@ai-sdk/anthropic"),
usage: { inputTokens: 8_000, inputTokenDetails: { noCacheTokens: 0, cacheReadTokens: 8_000, cacheWriteTokens: 0 }, outputTokens: 10 } as any,
metadata: { anthropic: { cacheCreationInputTokens: 0 } } as any,
})
expect(result.tokens.input).toBe(0)
expect(result.tokens.inputTotal).toBe(8_000)
})

test("OpenAI v6 shape without cache write matches the legacy subtraction", () => {
const result = Session.getUsage({
model: fakeModel("@ai-sdk/openai"),
usage: { inputTokens: 5_000, inputTokenDetails: { noCacheTokens: 3_000, cacheReadTokens: 2_000 }, outputTokens: 10, totalTokens: 5_010 } as any,
})
expect(result.tokens.input).toBe(3_000)
expect(result.tokens.cache.write).toBe(0)
expect(result.tokens.inputTotal).toBe(5_000)
})

test("cache write comes from inputTokenDetails when provider metadata lacks it", () => {
const result = Session.getUsage({
model: fakeModel("@ai-sdk/anthropic"),
usage: v6Step as any,
metadata: { anthropic: {} } as any,
})
expect(result.tokens.cache.write).toBe(2_635)
expect(result.tokens.input).toBe(4)
})
})

describe("Session.getUsage — provider edge cases", () => {
test("@ai-sdk/google-vertex/anthropic uses Anthropic-style accounting (per total branch)", () => {
// The `total` computation at session/index.ts:828 includes this NPM as
Expand Down
Loading