fix(ai): preserve Anthropic usage metadata (#38751)
This commit is contained in:
parent
13b6845e7e
commit
c7d7f61146
3 changed files with 92 additions and 16 deletions
|
|
@ -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"]) ?? {},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue