Compare commits

...
Author SHA1 Message Date
Aiden Cline 31ee5ee443 fix(core): price Anthropic 1-hour cache writes correctly 2026-08-30 22:59:41 -05:00
6 changed files with 162 additions and 6 deletions
@@ -1254,6 +1254,45 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("preserves mixed cache TTL usage when the terminal delta only reports aggregate writes", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "message_start",
message: {
usage: {
input_tokens: 5,
cache_creation_input_tokens: 10_000,
cache_creation: { ephemeral_5m_input_tokens: 2_000, ephemeral_1h_input_tokens: 8_000 },
},
},
},
{
type: "message_delta",
delta: { stop_reason: "end_turn" },
usage: { input_tokens: 5, cache_creation_input_tokens: 10_000, output_tokens: 8 },
},
{ type: "message_stop" },
),
),
),
)
expect(response.usage).toMatchObject({
inputTokens: 10_005,
cacheWriteInputTokens: 10_000,
providerMetadata: {
anthropic: {
cache_creation: { ephemeral_5m_input_tokens: 2_000, ephemeral_1h_input_tokens: 8_000 },
},
},
})
}),
)
it.effect("maps nullable input tokens and preserves unknown Anthropic usage fields", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
@@ -39,6 +39,7 @@ export interface StepRecord {
readonly rawFinish?: string
readonly providerState?: SessionMessage.ProviderState
readonly tokens: ReturnType<typeof SessionUsage.tokens>
readonly usageMetadata?: ProviderMetadata
}
readonly needsContinuation: boolean
}
@@ -516,6 +517,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
rawFinish: event.reason.raw,
providerState: providerState(event.providerMetadata),
tokens: SessionUsage.tokens(event.usage),
usageMetadata: event.usage?.providerMetadata,
}
if (event.reason.normalized === "content-filter") {
providerFailed = true
+4 -1
View File
@@ -224,7 +224,10 @@ export const make = Effect.gen(function* () {
.pipe(Effect.orElseSucceed(() => undefined))
: undefined
const usage = record.finish
? { cost: SessionUsage.calculateCost(input.model.cost, record.finish.tokens), tokens: record.finish.tokens }
? {
cost: SessionUsage.calculateCost(input.model.cost, record.finish.tokens, record.finish.usageMetadata),
tokens: record.finish.tokens,
}
: undefined
if (record.failure) yield* publisher.publishStepFailure({ ...usage, snapshot, files })
if (record.finish && usage && !record.failure)
+16 -3
View File
@@ -1,6 +1,7 @@
export * as SessionUsage from "./usage.js"
import type { Usage } from "@opencode-ai/ai"
import { Option, Schema } from "effect"
import { Money } from "@opencode-ai/schema/money"
import type { TokenUsage } from "@opencode-ai/schema/token-usage"
import type { Model } from "@opencode-ai/schema/model"
@@ -8,6 +9,8 @@ import type { Model } from "@opencode-ai/schema/model"
const finite = (value: number) => (Number.isFinite(value) ? value : 0)
const safe = (value: number | undefined) => Math.max(0, finite(value ?? 0))
const cacheCreation = Schema.decodeUnknownOption(Schema.Struct({ ephemeral_1h_input_tokens: Schema.Finite }))
export const tokens = (usage: Usage | undefined): TokenUsage.Info => ({
input: safe(usage?.nonCachedInputTokens),
output: safe(usage?.visibleOutputTokens),
@@ -19,18 +22,28 @@ export const tokens = (usage: Usage | undefined): TokenUsage.Info => ({
})
// TODO(#35765): Use Copilot's reported billed amount once billing has a dedicated typed runtime contract.
export function calculateCost(costs: Model.Info["cost"], usage: TokenUsage.Info) {
export function calculateCost(
costs: Model.Info["cost"],
usage: TokenUsage.Info,
providerMetadata?: Usage["providerMetadata"],
) {
const context = usage.input + usage.cache.read + usage.cache.write
const tier = costs
.filter((cost) => cost.tier?.type === "context" && context > cost.tier.size)
.toSorted((a, b) => (b.tier?.size ?? 0) - (a.tier?.size ?? 0))[0]
const cost = tier ?? costs.find((cost) => cost.tier === undefined)
if (!cost) return Money.USD.zero
const creation = cacheCreation(providerMetadata?.anthropic?.cache_creation)
const write1h = Math.min(
usage.cache.write,
safe(Option.isSome(creation) ? creation.value.ephemeral_1h_input_tokens : undefined),
)
return Money.USD.make(
(usage.input * finite(cost.input) +
(usage.output + usage.reasoning) * finite(cost.output) +
usage.cache.read * finite(cost.cache.read) +
usage.cache.write * finite(cost.cache.write)) /
// Anthropic's 1h/5m write-price ratio is 2/1.25. Preserve configured write rates, including zero.
(usage.cache.write + write1h * 0.6) * finite(cost.cache.write)) /
1_000_000,
)
}
@@ -39,7 +52,7 @@ export type Recorded = { readonly tokens: TokenUsage.Info; readonly cost: Money.
export const record = (usage: Usage | undefined, costs: Model.Info["cost"]): Recorded => {
const normalized = tokens(usage)
return { tokens: normalized, cost: calculateCost(costs, normalized) }
return { tokens: normalized, cost: calculateCost(costs, normalized, usage?.providerMetadata) }
}
export const add = (a: Recorded, b: Recorded): Recorded => ({
@@ -482,11 +482,24 @@ test("success event data can carry provider-executed result state", () => {
test("step finish records settlement without publishing step ended", async () => {
const { published, publisher } = capture()
const usageMetadata = { anthropic: { cache_creation: { ephemeral_1h_input_tokens: 8_000 } } }
await Effect.runPromise(publisher.publish(LLMEvent.stepStart({ index: 0 })))
await Effect.runPromise(publisher.publish(LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } })))
await Effect.runPromise(
publisher.publish(
LLMEvent.stepFinish({
index: 0,
reason: { normalized: "stop" },
usage: { cacheWriteInputTokens: 10_000, providerMetadata: usageMetadata },
}),
),
)
expect(published.map((event) => event.type)).toEqual(["session.step.started.1"])
expect(publisher.record().finish).toMatchObject({ finish: "stop" })
expect(publisher.record().finish).toMatchObject({
finish: "stop",
tokens: { cache: { write: 10_000 } },
usageMetadata,
})
})
test("content-filter finish retains failure evidence until step closeout", async () => {
+86
View File
@@ -0,0 +1,86 @@
import { expect, test } from "bun:test"
import { Usage } from "@opencode-ai/ai"
import { SessionUsage } from "@opencode-ai/core/session/usage"
import { Money } from "@opencode-ai/schema/money"
const costs = [
{
input: Money.USDPerMillionTokens.make(3),
output: Money.USDPerMillionTokens.make(15),
cache: {
read: Money.USDPerMillionTokens.make(0.3),
write: Money.USDPerMillionTokens.make(3.75),
},
},
]
test.each([
{ name: "missing breakdown", creation: undefined, cost: 0.0375 },
{ name: "5-minute writes", creation: { ephemeral_1h_input_tokens: 0 }, cost: 0.0375 },
{
name: "mixed TTL writes",
creation: { ephemeral_5m_input_tokens: 2_000, ephemeral_1h_input_tokens: 8_000 },
cost: 0.0555,
},
{ name: "1-hour writes", creation: { ephemeral_1h_input_tokens: 10_000 }, cost: 0.06 },
{ name: "null breakdown", creation: null, cost: 0.0375 },
{ name: "malformed breakdown", creation: { ephemeral_1h_input_tokens: "8000" }, cost: 0.0375 },
{ name: "negative subset", creation: { ephemeral_1h_input_tokens: -1 }, cost: 0.0375 },
{ name: "oversized subset", creation: { ephemeral_1h_input_tokens: 20_000 }, cost: 0.06 },
])("prices Anthropic cache creation: $name", ({ creation, cost }) => {
const recorded = SessionUsage.record(
new Usage({
cacheWriteInputTokens: 10_000,
providerMetadata: { anthropic: { cache_creation: creation } },
}),
costs,
)
expect(recorded.cost).toBeCloseTo(cost, 10)
expect(recorded.tokens).toEqual({ input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 10_000 } })
})
test("prices 1-hour writes with the selected context tier without double-counting its subset", () => {
const recorded = SessionUsage.record(
new Usage({
nonCachedInputTokens: 1,
cacheWriteInputTokens: 10_000,
providerMetadata: { anthropic: { cache_creation: { ephemeral_1h_input_tokens: 8_000 } } },
}),
[
...costs,
{
tier: { type: "context", size: 10_000 },
input: Money.USDPerMillionTokens.make(6),
output: Money.USDPerMillionTokens.make(30),
cache: { read: Money.USDPerMillionTokens.make(0.6), write: Money.USDPerMillionTokens.make(7.5) },
},
...costs.map((cost) => ({ ...cost, tier: { type: "context" as const, size: 15_000 } })),
],
)
expect(recorded.cost).toBeCloseTo(0.111006, 10)
expect(recorded.tokens.cache.write).toBe(10_000)
})
test("does not apply Anthropic pricing to another provider's metadata", () => {
expect(
SessionUsage.record(
new Usage({
cacheWriteInputTokens: 10_000,
providerMetadata: { openai: { cache_creation: { ephemeral_1h_input_tokens: 8_000 } } },
}),
costs,
).cost,
).toBeCloseTo(0.0375, 10)
})
test.each([0, 2])("respects a configured cache-write price of %s for 1-hour writes", (write) => {
expect(
SessionUsage.record(
new Usage({
cacheWriteInputTokens: 10_000,
providerMetadata: { anthropic: { cache_creation: { ephemeral_1h_input_tokens: 8_000 } } },
}),
costs.map((cost) => ({ ...cost, cache: { ...cost.cache, write: Money.USDPerMillionTokens.make(write) } })),
).cost,
).toBeCloseTo((2_000 * write + 8_000 * write * 1.6) / 1_000_000, 10)
})