From d7868b099853a68f4fdb94f05e6913760dde3f7e Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 25 Jun 2026 10:34:47 +0200 Subject: [PATCH] feat(ai): add reasoning token counts to Usage Add optional reasoning?: number to Usage as a subset of output. Populate for Anthropic (output_tokens_details.thinking_tokens), OpenAI Responses/Codex/Azure (output_tokens_details.reasoning_tokens), OpenAI Completions (completion_tokens_details.reasoning_tokens), and Google Generative AI / Vertex (thoughtsTokenCount). Bedrock Converse and Mistral do not return a reasoning breakdown, so they stay unset. closes #6057 --- packages/ai/CHANGELOG.md | 4 ++++ packages/ai/src/api/anthropic-messages.ts | 8 ++++++++ packages/ai/src/api/google-generative-ai.ts | 1 + packages/ai/src/api/google-vertex.ts | 1 + packages/ai/src/api/openai-completions.ts | 2 ++ packages/ai/src/api/openai-responses-shared.ts | 1 + packages/ai/src/types.ts | 6 ++++++ 7 files changed, 23 insertions(+) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 03818dc31..9458d7a9e 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added an optional `reasoning` field to `Usage` reporting reasoning/thinking token counts as a subset of `output`. Populated for Anthropic (`output_tokens_details.thinking_tokens`), OpenAI Responses/Codex/Azure (`output_tokens_details.reasoning_tokens`), OpenAI Completions (`completion_tokens_details.reasoning_tokens`), and Google Generative AI / Vertex (`thoughtsTokenCount`). Bedrock Converse and Mistral are not populated because those APIs do not return a reasoning token breakdown ([#6057](https://github.com/earendil-works/pi/issues/6057)). + ### Fixed - Fixed retry classification for provider errors that explicitly tell callers to retry the request ([#6019](https://github.com/earendil-works/pi/issues/6019)). diff --git a/packages/ai/src/api/anthropic-messages.ts b/packages/ai/src/api/anthropic-messages.ts index 03bbc26c4..39fe4eea1 100644 --- a/packages/ai/src/api/anthropic-messages.ts +++ b/packages/ai/src/api/anthropic-messages.ts @@ -699,6 +699,14 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = ( 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 = output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite; diff --git a/packages/ai/src/api/google-generative-ai.ts b/packages/ai/src/api/google-generative-ai.ts index 4e402a23b..1e90e53ae 100644 --- a/packages/ai/src/api/google-generative-ai.ts +++ b/packages/ai/src/api/google-generative-ai.ts @@ -221,6 +221,7 @@ export const stream: StreamFunction<"google-generative-ai", GoogleOptions> = ( (chunk.usageMetadata.candidatesTokenCount || 0) + (chunk.usageMetadata.thoughtsTokenCount || 0), cacheRead: chunk.usageMetadata.cachedContentTokenCount || 0, cacheWrite: 0, + reasoning: chunk.usageMetadata.thoughtsTokenCount || 0, totalTokens: chunk.usageMetadata.totalTokenCount || 0, cost: { input: 0, diff --git a/packages/ai/src/api/google-vertex.ts b/packages/ai/src/api/google-vertex.ts index de0ecd544..f4ea7b5c0 100644 --- a/packages/ai/src/api/google-vertex.ts +++ b/packages/ai/src/api/google-vertex.ts @@ -238,6 +238,7 @@ export const stream: StreamFunction<"google-vertex", GoogleVertexOptions> = ( (chunk.usageMetadata.candidatesTokenCount || 0) + (chunk.usageMetadata.thoughtsTokenCount || 0), cacheRead: chunk.usageMetadata.cachedContentTokenCount || 0, cacheWrite: 0, + reasoning: chunk.usageMetadata.thoughtsTokenCount || 0, totalTokens: chunk.usageMetadata.totalTokenCount || 0, cost: { input: 0, diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index e916c52a0..27f44b2cf 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -1107,6 +1107,7 @@ function parseChunkUsage( completion_tokens?: number; prompt_cache_hit_tokens?: number; prompt_tokens_details?: { cached_tokens?: number; cache_write_tokens?: number }; + completion_tokens_details?: { reasoning_tokens?: number }; }, model: Model<"openai-completions">, ): AssistantMessage["usage"] { @@ -1130,6 +1131,7 @@ function parseChunkUsage( output: outputTokens, cacheRead: cacheReadTokens, cacheWrite: cacheWriteTokens, + reasoning: rawUsage.completion_tokens_details?.reasoning_tokens || 0, totalTokens: input + outputTokens + cacheReadTokens + cacheWriteTokens, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }; diff --git a/packages/ai/src/api/openai-responses-shared.ts b/packages/ai/src/api/openai-responses-shared.ts index 72b4b8ae5..376d81650 100644 --- a/packages/ai/src/api/openai-responses-shared.ts +++ b/packages/ai/src/api/openai-responses-shared.ts @@ -312,6 +312,7 @@ export async function processResponsesStream( output: response.usage.output_tokens || 0, cacheRead: cachedTokens, cacheWrite: 0, + reasoning: response.usage.output_tokens_details?.reasoning_tokens || 0, totalTokens: response.usage.total_tokens || 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }; diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 294498134..e4f263f48 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -356,6 +356,12 @@ export interface Usage { cacheWrite: number; /** Subset of `cacheWrite` written with 1h retention. Only Anthropic reports this split. */ cacheWrite1h?: number; + /** + * Reasoning/thinking tokens, when the provider reports them. This is a subset of + * `output`: `output` already includes these tokens. Set to a number (possibly 0) by + * providers that expose a reasoning breakdown; left undefined by providers that don't. + */ + reasoning?: number; totalTokens: number; cost: { input: number;