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
This commit is contained in:
David Brailovsky 2026-09-23 17:16:32 +02:00
parent 002fc83852
commit 667fc3dd36
3 changed files with 46 additions and 0 deletions

View file

@ -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

View file

@ -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) {

View file

@ -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));