From 667fc3dd3608da6ff38410283d6d12d61f3fe087 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Wed, 23 Sep 2026 17:16:32 +0200 Subject: [PATCH] fix(ai): price Vercel AI Gateway 1-hour cache writes correctly Read the 1-hour cache-write count from streaming deltas when present, and add a regression test. Fixes #9210 --- packages/ai/CHANGELOG.md | 4 +++ packages/ai/src/api/anthropic-messages.ts | 7 ++++ .../anthropic-cache-write-1h-cost.test.ts | 35 +++++++++++++++++++ 3 files changed, 46 insertions(+) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 8ff552c5f..7ee21720f 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -6,6 +6,10 @@ - Added `onProviderStreamEvent` to observe parsed provider stream events before normalization, including provider-specific fields not retained in assistant messages ([#9784](https://github.com/earendil-works/pi/issues/9784)). +### Fixed + +- Fixed 1-hour Anthropic cache writes reported by Vercel AI Gateway in streaming deltas being priced at the 5-minute rate ([#9210](https://github.com/earendil-works/pi/issues/9210)). + ## [0.87.1] - 2026-09-22 ### Added diff --git a/packages/ai/src/api/anthropic-messages.ts b/packages/ai/src/api/anthropic-messages.ts index a935e01c5..9f227a245 100644 --- a/packages/ai/src/api/anthropic-messages.ts +++ b/packages/ai/src/api/anthropic-messages.ts @@ -776,6 +776,13 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = ( if (event.usage.cache_creation_input_tokens != null) { output.usage.cacheWrite = event.usage.cache_creation_input_tokens; } + // Vercel AI Gateway includes the TTL breakdown in deltas, though the SDK only types it on message_start. + const cacheCreation = ( + event.usage as typeof event.usage & { cache_creation?: { ephemeral_1h_input_tokens?: number } } + ).cache_creation; + if (cacheCreation?.ephemeral_1h_input_tokens != null) { + output.usage.cacheWrite1h = cacheCreation.ephemeral_1h_input_tokens; + } // Anthropic reports reasoning tokens as a subset of output tokens. const thinkingTokens = event.usage.output_tokens_details?.thinking_tokens; if (thinkingTokens != null) { diff --git a/packages/ai/test/anthropic-cache-write-1h-cost.test.ts b/packages/ai/test/anthropic-cache-write-1h-cost.test.ts index 299c7dc50..dd4d82e2a 100644 --- a/packages/ai/test/anthropic-cache-write-1h-cost.test.ts +++ b/packages/ai/test/anthropic-cache-write-1h-cost.test.ts @@ -74,6 +74,41 @@ describe("Anthropic 1h cache write cost", () => { expect(result.usage.cost.cacheWrite).toBeCloseTo(7.75, 10); }); + // Regression for #9210: Vercel AI Gateway sends cache usage in message_delta, not message_start. + it("prices 1h cache writes reported only in message_delta", async () => { + const model = getModel("vercel-ai-gateway", "anthropic/claude-haiku-4.5"); + const response = createSseResponse([ + { + event: "message_start", + data: JSON.stringify({ + type: "message_start", + message: { id: "msg_test", usage: { input_tokens: 0, output_tokens: 0 } }, + }), + }, + { + event: "message_delta", + data: JSON.stringify({ + type: "message_delta", + delta: { stop_reason: "end_turn" }, + usage: { + input_tokens: 3, + output_tokens: 4, + cache_creation_input_tokens: 6535, + cache_creation: { ephemeral_5m_input_tokens: 0, ephemeral_1h_input_tokens: 6535 }, + }, + }), + }, + { event: "message_stop", data: JSON.stringify({ type: "message_stop" }) }, + ]); + const result = await streamAnthropic(model, context, { + client: createFakeAnthropicClient(response), + }).result(); + + expect(result.usage.cacheWrite).toBe(6535); + expect(result.usage.cacheWrite1h).toBe(6535); + expect(result.usage.cost.cacheWrite).toBeCloseTo((6535 * model.cost.input * 2) / 1_000_000, 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));