From 0e6909f050eeb15e8f6c05185511f3788357ddb3 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Mon, 13 Jul 2026 18:07:59 +0200 Subject: [PATCH] anthropic-messages: skip usage fields if empty (#6611) fixes: #6567 --- packages/ai/src/api/anthropic-messages.ts | 40 ++++++++++--------- .../ai/test/anthropic-sse-parsing.test.ts | 28 +++++++++++++ 2 files changed, 49 insertions(+), 19 deletions(-) diff --git a/packages/ai/src/api/anthropic-messages.ts b/packages/ai/src/api/anthropic-messages.ts index e5dc8bc6..6c419bce 100644 --- a/packages/ai/src/api/anthropic-messages.ts +++ b/packages/ai/src/api/anthropic-messages.ts @@ -703,25 +703,27 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = ( } // Only update usage fields if present (not null). // Preserves input_tokens from message_start when proxies omit it in message_delta. - if (event.usage.input_tokens != null) { - output.usage.input = event.usage.input_tokens; - } - if (event.usage.output_tokens != null) { - output.usage.output = event.usage.output_tokens; - } - if (event.usage.cache_read_input_tokens != null) { - output.usage.cacheRead = event.usage.cache_read_input_tokens; - } - if (event.usage.cache_creation_input_tokens != null) { - output.usage.cacheWrite = event.usage.cache_creation_input_tokens; - } - // Anthropic reports reasoning tokens in `output_tokens_details.thinking_tokens` on the - // final message_delta usage (a subset of output_tokens). SDK 0.91.1 omits the field from - // its Usage type, so read it through a narrow cast. Verified against the live API. - const thinkingTokens = (event.usage as { output_tokens_details?: { thinking_tokens?: number } }) - .output_tokens_details?.thinking_tokens; - if (thinkingTokens != null) { - output.usage.reasoning = thinkingTokens; + if (event.usage) { + if (event.usage.input_tokens != null) { + output.usage.input = event.usage.input_tokens; + } + if (event.usage.output_tokens != null) { + output.usage.output = event.usage.output_tokens; + } + if (event.usage.cache_read_input_tokens != null) { + output.usage.cacheRead = event.usage.cache_read_input_tokens; + } + if (event.usage.cache_creation_input_tokens != null) { + output.usage.cacheWrite = event.usage.cache_creation_input_tokens; + } + // Anthropic reports reasoning tokens in `output_tokens_details.thinking_tokens` on the + // final message_delta usage (a subset of output_tokens). SDK 0.91.1 omits the field from + // its Usage type, so read it through a narrow cast. Verified against the live API. + const thinkingTokens = (event.usage as { output_tokens_details?: { thinking_tokens?: number } }) + .output_tokens_details?.thinking_tokens; + if (thinkingTokens != null) { + output.usage.reasoning = thinkingTokens; + } } // Anthropic doesn't provide total_tokens, compute from components output.usage.totalTokens = diff --git a/packages/ai/test/anthropic-sse-parsing.test.ts b/packages/ai/test/anthropic-sse-parsing.test.ts index e510ec55..0cdd0577 100644 --- a/packages/ai/test/anthropic-sse-parsing.test.ts +++ b/packages/ai/test/anthropic-sse-parsing.test.ts @@ -224,6 +224,34 @@ describe("Anthropic raw SSE parsing", () => { expect(result.errorMessage).toBe(explanation); }); + it("treats message_delta without usage as a no-op for usage accumulation", async () => { + const model = getModel("anthropic", "claude-haiku-4-5"); + const context: Context = { + messages: [{ role: "user", content: "Say hello.", timestamp: Date.now() }], + }; + const response = createSseResponse( + minimalAnthropicEvents.map((event) => + event.event === "message_delta" + ? { + event: "message_delta", + data: JSON.stringify({ type: "message_delta", delta: { stop_reason: "end_turn" } }), + } + : event, + ), + ); + + const stream = streamAnthropic(model, context, { + client: createFakeAnthropicClient(response), + }); + const result = await stream.result(); + + expect(result.stopReason).toBe("stop"); + expect(result.errorMessage).toBeUndefined(); + expect(result.content).toEqual([{ type: "text", text: "Hello" }]); + expect(result.usage.input).toBe(12); + expect(result.usage.totalTokens).toBe(12); + }); + it("ignores unknown SSE events after message_stop", async () => { const model = getModel("anthropic", "claude-haiku-4-5"); const context: Context = {