From 0be5bb6c968ded1698793d0a3e7e60a0294050e2 Mon Sep 17 00:00:00 2001 From: Bucky <16464422+theBucky@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:21:14 +0800 Subject: [PATCH] fix(ai): price anthropic 1h cache writes at 2x input (#5738) anthropic 1h cache writes were billed at the 5m rate; split the bucket and price the 1h portion correctly --- packages/ai/src/models.ts | 5 +- packages/ai/src/providers/anthropic.ts | 1 + packages/ai/src/types.ts | 2 + .../anthropic-cache-write-1h-cost.test.ts | 86 +++++++++++++++++++ 4 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 packages/ai/test/anthropic-cache-write-1h-cost.test.ts diff --git a/packages/ai/src/models.ts b/packages/ai/src/models.ts index e14a6c2f..9d9fa519 100644 --- a/packages/ai/src/models.ts +++ b/packages/ai/src/models.ts @@ -37,10 +37,13 @@ export function getModels( } export function calculateCost(model: Model, usage: Usage): Usage["cost"] { + // Anthropic charges 2x base input for 1h cache writes. + const longWrite = usage.cacheWrite1h ?? 0; + const shortWrite = usage.cacheWrite - longWrite; usage.cost.input = (model.cost.input / 1000000) * usage.input; usage.cost.output = (model.cost.output / 1000000) * usage.output; usage.cost.cacheRead = (model.cost.cacheRead / 1000000) * usage.cacheRead; - usage.cost.cacheWrite = (model.cost.cacheWrite / 1000000) * usage.cacheWrite; + usage.cost.cacheWrite = (model.cost.cacheWrite * shortWrite + model.cost.input * 2 * longWrite) / 1000000; usage.cost.total = usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite; return usage.cost; } diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 90b46146..9a64cbd8 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -535,6 +535,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti output.usage.output = event.message.usage.output_tokens || 0; output.usage.cacheRead = event.message.usage.cache_read_input_tokens || 0; output.usage.cacheWrite = event.message.usage.cache_creation_input_tokens || 0; + output.usage.cacheWrite1h = event.message.usage.cache_creation?.ephemeral_1h_input_tokens || 0; // Anthropic doesn't provide total_tokens, compute from components output.usage.totalTokens = output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite; diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 897e87d9..b7d0fda0 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -267,6 +267,8 @@ export interface Usage { output: number; cacheRead: number; cacheWrite: number; + /** Subset of `cacheWrite` written with 1h retention. Only Anthropic reports this split. */ + cacheWrite1h?: number; totalTokens: number; cost: { input: number; diff --git a/packages/ai/test/anthropic-cache-write-1h-cost.test.ts b/packages/ai/test/anthropic-cache-write-1h-cost.test.ts new file mode 100644 index 00000000..f9523b40 --- /dev/null +++ b/packages/ai/test/anthropic-cache-write-1h-cost.test.ts @@ -0,0 +1,86 @@ +import type Anthropic from "@anthropic-ai/sdk"; +import { describe, expect, it } from "vitest"; +import { getModel } from "../src/models.ts"; +import { streamAnthropic } from "../src/providers/anthropic.ts"; +import type { Context } from "../src/types.ts"; + +function createSseResponse(events: Array<{ event: string; data: string }>): Response { + const body = events.map(({ event, data }) => `event: ${event}\ndata: ${data}\n`).join("\n"); + return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } }); +} + +function createFakeAnthropicClient(response: Response): Anthropic { + return { + messages: { create: () => ({ asResponse: async () => response }) }, + } as unknown as Anthropic; +} + +function eventsWithCacheCreation( + cacheCreation: Record | undefined, +): Array<{ event: string; data: string }> { + const startUsage: Record = { + input_tokens: 100, + output_tokens: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 1_000_000, + }; + if (cacheCreation) startUsage.cache_creation = cacheCreation; + return [ + { + event: "message_start", + data: JSON.stringify({ type: "message_start", message: { id: "msg_test", usage: startUsage } }), + }, + { + event: "content_block_start", + data: JSON.stringify({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }), + }, + { + event: "content_block_delta", + data: JSON.stringify({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hi" } }), + }, + { event: "content_block_stop", data: JSON.stringify({ type: "content_block_stop", index: 0 }) }, + { + event: "message_delta", + data: JSON.stringify({ + type: "message_delta", + delta: { stop_reason: "end_turn" }, + usage: { + input_tokens: 100, + output_tokens: 5, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 1_000_000, + }, + }), + }, + { event: "message_stop", data: JSON.stringify({ type: "message_stop" }) }, + ]; +} + +// claude-opus-4-8: input 5, cacheWrite (5m) 6.25 per Mtok. 1h write = 2x input = 10. +const context: Context = { messages: [{ role: "user", content: "hi", timestamp: Date.now() }] }; + +describe("Anthropic 1h cache write cost", () => { + it("prices the 1h portion at 2x input and the rest at the 5m rate", async () => { + const model = getModel("anthropic", "claude-opus-4-8"); + const response = createSseResponse( + eventsWithCacheCreation({ ephemeral_5m_input_tokens: 600_000, ephemeral_1h_input_tokens: 400_000 }), + ); + const result = await streamAnthropic(model, context, { client: createFakeAnthropicClient(response) }).result(); + + expect(result.usage.cacheWrite).toBe(1_000_000); + expect(result.usage.cacheWrite1h).toBe(400_000); + // 600k * 6.25/Mtok + 400k * 10/Mtok = 3.75 + 4.0 = 7.75 + expect(result.usage.cost.cacheWrite).toBeCloseTo(7.75, 10); + }); + + it("falls back to the 5m rate when no breakdown is reported", async () => { + const model = getModel("anthropic", "claude-opus-4-8"); + const response = createSseResponse(eventsWithCacheCreation(undefined)); + const result = await streamAnthropic(model, context, { client: createFakeAnthropicClient(response) }).result(); + + expect(result.usage.cacheWrite).toBe(1_000_000); + expect(result.usage.cacheWrite1h ?? 0).toBe(0); + // 1M * 6.25/Mtok = 6.25 + expect(result.usage.cost.cacheWrite).toBeCloseTo(6.25, 10); + }); +});