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

This commit is contained in:
Aiden Cline 2026-07-24 16:23:51 -05:00 committed by GitHub
commit c7d7f61146
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 92 additions and 16 deletions

View file

@ -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"]) ?? {},
},
})
}

View file

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

View file

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