From c7d7f611462446b28d064e3be5a9de1808ccce4b Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:23:51 -0500 Subject: [PATCH] fix(ai): preserve Anthropic usage metadata (#38751) --- .../ai/src/protocols/anthropic-messages.ts | 42 +++++++++---- packages/ai/src/schema/events.ts | 6 +- .../test/provider/anthropic-messages.test.ts | 60 +++++++++++++++++++ 3 files changed, 92 insertions(+), 16 deletions(-) diff --git a/packages/ai/src/protocols/anthropic-messages.ts b/packages/ai/src/protocols/anthropic-messages.ts index 2812e0136f..316f193d92 100644 --- a/packages/ai/src/protocols/anthropic-messages.ts +++ b/packages/ai/src/protocols/anthropic-messages.ts @@ -7,6 +7,7 @@ import { Protocol } from "../route/protocol" import { LLMError, LLMEvent, + mergeJsonRecords, Usage, type CacheHint, type FinishReasonDetails, @@ -234,12 +235,27 @@ const AnthropicBodyFields = { export const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields) export type AnthropicMessagesBody = Schema.Schema.Type -const AnthropicUsage = Schema.Struct({ - input_tokens: Schema.optional(Schema.Number), - output_tokens: Schema.optional(Schema.Number), - cache_creation_input_tokens: optionalNull(Schema.Number), - cache_read_input_tokens: optionalNull(Schema.Number), -}) +const AnthropicUsage = Schema.StructWithRest( + Schema.Struct({ + input_tokens: Schema.optional(Schema.Number), + output_tokens: Schema.optional(Schema.Number), + cache_creation_input_tokens: optionalNull(Schema.Number), + cache_read_input_tokens: optionalNull(Schema.Number), + server_tool_use: optionalNull( + Schema.StructWithRest( + Schema.Struct({ web_search_requests: Schema.optional(Schema.Number) }), + [Schema.Record(Schema.String, Schema.Unknown)], + ), + ), + output_tokens_details: optionalNull( + Schema.StructWithRest( + Schema.Struct({ thinking_tokens: Schema.optional(Schema.Number) }), + [Schema.Record(Schema.String, Schema.Unknown)], + ), + ), + }), + [Schema.Record(Schema.String, Schema.Unknown)], +) type AnthropicUsage = Schema.Schema.Type const AnthropicStreamBlock = Schema.Struct({ @@ -666,9 +682,8 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => { // `input_tokens` is the *non-cached* count per the Messages API docs, with // cache reads and writes as separate fields. We sum them to derive the // inclusive `inputTokens` the rest of the contract expects. Extended -// thinking tokens are *not* broken out by Anthropic — they're billed as -// part of `output_tokens`, so `reasoningTokens` stays `undefined` and -// `outputTokens` carries the combined total. +// thinking tokens are included in `output_tokens`; newer responses also +// expose that subset through `output_tokens_details.thinking_tokens`. const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => { if (!usage) return undefined const nonCached = usage.input_tokens @@ -681,6 +696,7 @@ const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => { nonCachedInputTokens: nonCached, cacheReadInputTokens: cacheRead, cacheWriteInputTokens: cacheWrite, + reasoningTokens: usage.output_tokens_details?.thinking_tokens, totalTokens: ProviderShared.totalTokens(inputTokens, usage.output_tokens, undefined), providerMetadata: { anthropic: usage }, }) @@ -699,18 +715,18 @@ const mergeUsage = (left: Usage | undefined, right: Usage | undefined) => { const cacheWriteInputTokens = right.cacheWriteInputTokens ?? left.cacheWriteInputTokens const inputTokens = ProviderShared.sumTokens(nonCachedInputTokens, cacheReadInputTokens, cacheWriteInputTokens) const outputTokens = right.outputTokens ?? left.outputTokens + const reasoningTokens = right.reasoningTokens ?? left.reasoningTokens return new Usage({ inputTokens, outputTokens, nonCachedInputTokens, cacheReadInputTokens, cacheWriteInputTokens, + reasoningTokens, totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined), providerMetadata: { - anthropic: { - ...left.providerMetadata?.["anthropic"], - ...right.providerMetadata?.["anthropic"], - }, + anthropic: + mergeJsonRecords(left.providerMetadata?.["anthropic"], right.providerMetadata?.["anthropic"]) ?? {}, }, }) } diff --git a/packages/ai/src/schema/events.ts b/packages/ai/src/schema/events.ts index d2195d8c19..654a892859 100644 --- a/packages/ai/src/schema/events.ts +++ b/packages/ai/src/schema/events.ts @@ -40,9 +40,9 @@ import { ProviderFailureClassification } from "./errors" * - Anthropic and Bedrock report the input breakdown natively: Anthropic's * `input_tokens` and Bedrock's `inputTokens` are non-cached only. Their * mappers sum the breakdown to derive the inclusive `inputTokens`. - * Anthropic does *not* break extended-thinking out of `output_tokens`, so - * `reasoningTokens` is `undefined` and `outputTokens` carries the - * combined total — a documented limitation of the Anthropic API. + * Anthropic's `outputTokens` includes extended thinking. Newer responses + * expose that subset as `output_tokens_details.thinking_tokens`, which maps + * to `reasoningTokens`; older responses leave it undefined. * * `providerMetadata` always carries the provider's raw usage payload — * keyed by provider name (`{ openai: ... }`, `{ anthropic: ... }`, etc.) diff --git a/packages/ai/test/provider/anthropic-messages.test.ts b/packages/ai/test/provider/anthropic-messages.test.ts index 004a378bcf..af1b3d99c8 100644 --- a/packages/ai/test/provider/anthropic-messages.test.ts +++ b/packages/ai/test/provider/anthropic-messages.test.ts @@ -543,6 +543,66 @@ describe("Anthropic Messages route", () => { }), ) + it.effect("maps thinking tokens and preserves unknown Anthropic usage fields", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents( + { + type: "message_start", + message: { + usage: { + input_tokens: 5, + cache_read_input_tokens: 2, + service_tier: "standard", + cache_creation: { ephemeral_5m_input_tokens: 1 }, + server_tool_use: { web_search_requests: 1, start_counter: 2 }, + output_tokens_details: { thinking_tokens: 3, start_detail: "preserved" }, + }, + }, + }, + { + type: "message_delta", + delta: { stop_reason: "end_turn" }, + usage: { + output_tokens: 8, + server_tool_use: { web_search_requests: 2, terminal_counter: 3 }, + output_tokens_details: { terminal_detail: "preserved" }, + future_terminal: { requests: 4 }, + }, + }, + { type: "message_stop" }, + ), + ), + ), + ) + + expect(response.usage).toMatchObject({ + inputTokens: 7, + outputTokens: 8, + reasoningTokens: 3, + totalTokens: 15, + providerMetadata: { + anthropic: { + input_tokens: 5, + cache_read_input_tokens: 2, + service_tier: "standard", + cache_creation: { ephemeral_5m_input_tokens: 1 }, + server_tool_use: { web_search_requests: 2, start_counter: 2, terminal_counter: 3 }, + output_tokens: 8, + output_tokens_details: { + thinking_tokens: 3, + start_detail: "preserved", + terminal_detail: "preserved", + }, + future_terminal: { requests: 4 }, + }, + }, + }) + }), + ) + it.effect("round-trips omitted thinking carried only by a signature delta", () => Effect.gen(function* () { const response = yield* LLMClient.generate(request).pipe(