Explorar el Código

fix(ai): preserve Anthropic usage metadata (#38751)

Aiden Cline hace 3 semanas
padre
commit
c7d7f61146

+ 29 - 13
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<typeof AnthropicMessagesBody>
 
-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<typeof AnthropicUsage>
 
 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"]) ?? {},
     },
   })
 }

+ 3 - 3
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.)

+ 60 - 0
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(