From 77f16f48086f9beee7b4f1f7affd7c397afab113 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Mon, 2 Nov 2026 14:35:48 +0100 Subject: [PATCH 1/7] fix: report token usage on every path where the provider billed the call Many adapters dropped usage that they already had: a RUN_ERROR after a cut-off response, a refusal, a parse failure or a stream error, a non-streaming structuredOutput() that throws, and media calls that fail after the response. chat() stops at the first RUN_ERROR, so a RUN_FINISHED that came after it was lost too. Adapters now put the usage on RUN_ERROR, and attach it as `usage` on a thrown error. Core reads it in the structured-output fallback, media activities, summarize, and media streams. onUsage now also runs for a RUN_ERROR with usage, and a structured value that fails the schema check still reports the usage of its call. Dropped provider fields are mapped, and opencode adds up the usage of every agent step. --- .changeset/usage-on-every-path.md | 29 ++ docs/adapters/anthropic-advanced.md | 2 +- docs/adapters/openrouter-advanced.md | 8 +- docs/advanced/middleware-advanced.md | 33 ++- docs/config.json | 7 +- packages/ai-acp/src/adapters/compatible.ts | 15 +- packages/ai-acp/src/stream/translate.ts | 30 +- packages/ai-acp/src/types/acp-types.ts | 7 + packages/ai-acp/tests/compatible.test.ts | 41 ++- packages/ai-acp/tests/translate.test.ts | 79 +++++ packages/ai-anthropic/src/adapters/text.ts | 15 +- packages/ai-anthropic/src/usage.ts | 41 ++- .../tests/usage-extraction.test.ts | 248 ++++++++++++++++ .../ai-bedrock/src/adapters/converse-text.ts | 22 +- packages/ai-bedrock/src/converse/usage.ts | 5 + .../ai-bedrock/tests/converse/adapter.test.ts | 135 +++++++++ .../tests/converse/stream-processor.test.ts | 24 ++ .../ai-claude-code/src/stream/sdk-types.ts | 13 + .../ai-claude-code/src/stream/translate.ts | 19 +- .../ai-claude-code/tests/translate.test.ts | 47 +++ .../src/adapters/transcription.ts | 19 +- .../tests/cloudflare-adapter.test.ts | 22 ++ packages/ai-codex/src/stream/translate.ts | 18 +- packages/ai-codex/tests/translate.test.ts | 20 ++ packages/ai-gemini/src/adapters/embedding.ts | 21 +- packages/ai-gemini/src/adapters/image.ts | 61 +--- packages/ai-gemini/src/adapters/text.ts | 131 ++++++--- packages/ai-gemini/src/adapters/tts.ts | 24 +- .../experimental/text-interactions/adapter.ts | 39 ++- packages/ai-gemini/src/usage.ts | 52 +++- .../ai-gemini/tests/agentic-video.test.ts | 32 +++ .../ai-gemini/tests/embedding-adapter.test.ts | 29 +- .../ai-gemini/tests/gemini-adapter.test.ts | 178 ++++++++++++ .../ai-gemini/tests/image-adapter.test.ts | 36 +++ .../tests/text-interactions-adapter.test.ts | 110 +++++++ packages/ai-gemini/tests/tts-adapter.test.ts | 29 ++ packages/ai-grok-build/src/adapters/text.ts | 15 +- .../ai-grok-build/src/stream/translate.ts | 7 +- .../tests/acp-structured-usage.test.ts | 85 ++++++ .../ai-grok-build/tests/translate.test.ts | 21 ++ .../ai-groq/src/adapters/transcription.ts | 14 + .../ai-groq/tests/groq-transcription.test.ts | 34 +++ packages/ai-harness/src/harness-text.ts | 36 ++- .../ai-harness/tests/harness-text.test.ts | 97 +++++++ .../ai-lovable/src/adapters/transcription.ts | 10 +- .../tests/transcription-adapter.test.ts | 26 ++ packages/ai-mistral/src/adapters/text.ts | 93 +++--- .../ai-mistral/tests/mistral-adapter.test.ts | 110 +++++++ packages/ai-ollama/src/adapters/text.ts | 12 +- packages/ai-ollama/tests/text-adapter.test.ts | 27 ++ packages/ai-openai/src/adapters/evaluate.ts | 16 ++ packages/ai-openai/src/adapters/image.ts | 27 +- .../ai-openai/src/adapters/transcription.ts | 12 +- .../ai-openai/tests/evaluate-adapter.test.ts | 21 ++ .../ai-openai/tests/image-adapter.test.ts | 60 ++++ .../tests/transcription-usage.test.ts | 25 ++ packages/ai-opencode/src/adapters/text.ts | 8 + packages/ai-opencode/src/stream/sdk-types.ts | 8 + packages/ai-opencode/src/stream/translate.ts | 60 +++- .../tests/structured-output-usage.test.ts | 69 +++++ packages/ai-opencode/tests/translate.test.ts | 121 ++++++++ packages/ai-openrouter/src/adapters/image.ts | 12 +- .../src/adapters/responses-text.ts | 128 +++++---- packages/ai-openrouter/src/adapters/text.ts | 53 ++-- packages/ai-openrouter/src/usage.ts | 38 ++- .../ai-openrouter/tests/image-adapter.test.ts | 34 +++ .../openrouter-responses-adapter.test.ts | 272 ++++++++++++++++++ .../tests/structured-usage.test.ts | 182 ++++++++++++ packages/ai/src/activities/chat/index.ts | 60 +++- .../ai/src/activities/generateImage/index.ts | 13 + .../ai/src/activities/generateSpeech/index.ts | 13 + .../activities/generateTranscription/index.ts | 11 +- .../ai/src/activities/generateVideo/index.ts | 1 + .../activities/stream-generation-result.ts | 8 + .../summarize/chat-stream-summarize.ts | 3 + packages/ai/src/activities/summarize/index.ts | 44 ++- packages/ai/src/utilities/ag-ui-usage.ts | 12 + .../ai/tests/chat-stream-summarize.test.ts | 29 ++ .../chat-structured-output-stream.test.ts | 101 +++++++ packages/ai/tests/middleware.test.ts | 32 +++ .../middlewares/generation-middleware.test.ts | 124 ++++++++ packages/ai/tests/stream-generation.test.ts | 131 +++++++++ .../src/adapters/chat-completions-text.ts | 56 ++-- .../src/adapters/responses-text.ts | 154 +++++++--- ...chat-completions-incomplete-stream.test.ts | 21 ++ ...mpletions-structured-output-stream.test.ts | 67 +++++ .../tests/chat-completions-text.test.ts | 69 +++++ ...responses-structured-output-stream.test.ts | 93 ++++++ .../openai-base/tests/responses-text.test.ts | 129 +++++++++ .../tests/structured-output-stream.spec.ts | 5 +- 90 files changed, 4164 insertions(+), 386 deletions(-) create mode 100644 .changeset/usage-on-every-path.md create mode 100644 packages/ai-grok-build/tests/acp-structured-usage.test.ts create mode 100644 packages/ai-groq/tests/groq-transcription.test.ts create mode 100644 packages/ai-opencode/tests/structured-output-usage.test.ts diff --git a/.changeset/usage-on-every-path.md b/.changeset/usage-on-every-path.md new file mode 100644 index 0000000000..cce5cbc309 --- /dev/null +++ b/.changeset/usage-on-every-path.md @@ -0,0 +1,29 @@ +--- +'@tanstack/ai': patch +'@tanstack/openai-base': patch +'@tanstack/ai-openai': patch +'@tanstack/ai-openrouter': patch +'@tanstack/ai-anthropic': patch +'@tanstack/ai-bedrock': patch +'@tanstack/ai-gemini': patch +'@tanstack/ai-mistral': patch +'@tanstack/ai-ollama': patch +'@tanstack/ai-groq': patch +'@tanstack/ai-cloudflare': patch +'@tanstack/ai-lovable': patch +'@tanstack/ai-claude-code': patch +'@tanstack/ai-codex': patch +'@tanstack/ai-opencode': patch +'@tanstack/ai-acp': patch +'@tanstack/ai-grok-build': patch +'@tanstack/ai-harness': patch +--- + +Report token usage for every call that the provider billed, also when the call fails. + +- Adapters put the usage that they received on `RUN_ERROR`: a cut-off response, a refusal, a parse failure, an empty response, or a stream that fails after usage arrived. Before, many of these paths dropped it. +- A non-streaming `structuredOutput()`, image, or speech call that fails after the response throws an error with a `usage` field. `chat()`, `generateImage()`, `generateSpeech()`, and `summarize()` report it. +- The `onUsage` middleware hook now also runs for a `RUN_ERROR` with usage. A structured output value that fails the schema check still reports the usage of its call. +- Media streams carry `usage` on `RUN_FINISHED` and `RUN_ERROR`. Devtools now gets the `summarize:usage` and `transcription:usage` events. +- Provider fields that were dropped are mapped: Anthropic thinking tokens, Claude Code `total_cost_usd` as `usage.cost`, OpenRouter Responses reasoning tokens, opencode cost and cache writes, ACP and Bedrock cache writes, OpenAI evaluate token details, Gemini Vertex embedding tokens, and audio duration as billed seconds for Whisper, Groq, Cloudflare, and Lovable transcription. +- opencode adds up the usage of every agent step in a turn. Before, it counted only the last step. diff --git a/docs/adapters/anthropic-advanced.md b/docs/adapters/anthropic-advanced.md index a92787918c..aea48f7a43 100644 --- a/docs/adapters/anthropic-advanced.md +++ b/docs/adapters/anthropic-advanced.md @@ -180,7 +180,7 @@ The Messages API of Anthropic requires `max_tokens` on every request, so the ada - Set `max_tokens` only to cap the output below the model limit. - If a response stops at the default limit, the adapter logs a warning. You see it with [debug logging](../advanced/debug-logging) on. -A streamed response that stops at `max_tokens` ends in a `RUN_ERROR` with `code: 'max_tokens'`. Anthropic bills the tokens of that call, so this `RUN_ERROR` carries the `usage` of the call. A stream can also fail after it starts, for example when the connection drops. That `RUN_ERROR` carries the `usage` from Anthropic's first event (`message_start`). The input counts are complete. The output count can be lower than the real one. The `onUsage` middleware hook fires only for `RUN_FINISHED`. To count these tokens too, read `usage` from the `RUN_ERROR` chunk, in the stream or in an `onChunk` middleware. +A streamed response that stops at `max_tokens` ends in a `RUN_ERROR` with `code: 'max_tokens'`. Anthropic bills the tokens of that call, so this `RUN_ERROR` carries the `usage` of the call. A stream can also fail after it starts, for example when the connection drops. That `RUN_ERROR` carries the `usage` from Anthropic's first event (`message_start`). The input counts are complete. The output count can be lower than the real one. The `onUsage` middleware hook gets this usage too. One exception: structured output (`chat({ outputSchema })`) on models that use the non-streaming finalization path caps this default at about 21K tokens. The Anthropic SDK rejects a non-streaming request whose `max_tokens` can go past its 10-minute timeout. Streaming chat does not have this cap. To raise the structured output limit toward the true maximum of a model, stream the response. diff --git a/docs/adapters/openrouter-advanced.md b/docs/adapters/openrouter-advanced.md index b5faa5c6cd..02e6092121 100644 --- a/docs/adapters/openrouter-advanced.md +++ b/docs/adapters/openrouter-advanced.md @@ -465,11 +465,11 @@ for await (const chunk of chat({ How usage works when a call fails: -- The `structuredOutputStream()` method of `openRouterText` puts the usage that it got on its last `RUN_ERROR` event. This covers parse failures, cut-off responses, empty responses, and SDK errors. -- When `chat()` uses this path, middleware `onChunk` gets the error event, also when the promise rejects. -- The `onUsage` hook reports successful calls. Each adapter call reports usage on one last event. +- `openRouterText` and `openRouterResponsesText` put the usage and cost that they got on the `RUN_ERROR` event. This covers parse failures, cut-off responses, empty responses, and SDK errors. +- The `onUsage` hook gets that usage too, so your totals include the failed calls that OpenRouter billed. +- A failed image call throws an error with a `usage` field. - A reported `completionTokensDetails.reasoningTokens` value of `0` stays zero. A missing count stays missing. -- An error before any usage arrives has no `usage` field. If the consumer stops reading the stream, it cannot get later events. +- An error before any usage arrives has no `usage` field. ## Video generation diff --git a/docs/advanced/middleware-advanced.md b/docs/advanced/middleware-advanced.md index c69c4038fe..12e8794102 100644 --- a/docs/advanced/middleware-advanced.md +++ b/docs/advanced/middleware-advanced.md @@ -255,7 +255,7 @@ When more than one middleware has `onChunk`, chunks flow through them in order. | `chunk.type` | Meaning | Key fields | | --- | --- | --- | -| `RUN_STARTED` / `RUN_FINISHED` / `RUN_ERROR` | Start and end of the run | `runId`, `finishReason`, `usage` (on finish), `message` (on error) | +| `RUN_STARTED` / `RUN_FINISHED` / `RUN_ERROR` | Start and end of the run | `runId`, `finishReason`, `usage` (on finish, and on error when the provider billed the call), `message` (on error) | | `TEXT_MESSAGE_START` / `TEXT_MESSAGE_CONTENT` / `TEXT_MESSAGE_END` | Assistant text | `messageId`, `delta` (content) | | `TOOL_CALL_START` / `TOOL_CALL_ARGS` / `TOOL_CALL_END` | Tool calls | `toolCallId`, `toolCallName`, `delta` (args), result on end | | `REASONING_START` / `REASONING_MESSAGE_CONTENT` / `REASONING_END` | Thinking or reasoning | `messageId`, `delta`. The signature comes on `REASONING_ENCRYPTED_VALUE` | @@ -725,7 +725,10 @@ With `toolExecution: 'sequential'`, each call finishes its `onBeforeToolCall`, i ### onUsage -`onUsage` runs once per model call when the `RUN_FINISHED` chunk has usage data. It gets the usage object. +`onUsage` gets the token usage of each model call. It runs one time per call, when the call ends with usage: + +- `RUN_FINISHED`: the call completed. +- `RUN_ERROR`: the call failed after the provider billed it. Examples are a response cut off at the token limit, a refusal, or JSON that does not match the schema. ```typescript import { type ChatMiddleware } from "@tanstack/ai"; @@ -751,6 +754,30 @@ The main `usage` fields: The object can also have `promptTokensDetails`, `completionTokensDetails`, `costDetails`, and `providerUsageDetails`. +A failed call has usage only when the provider sent usage before the failure. A request that fails before the response starts has no usage. + +Media and summarize calls report billed failures too. When `generateImage()`, `generateSpeech()`, or `summarize()` fails after the provider billed the call, two things get the usage: + +- The `onUsage` hook of the generation middleware. +- The `usage` field of the error that the call throws. + +```typescript +import { generateImage } from "@tanstack/ai"; +import { openaiImage } from "@tanstack/ai-openai"; + +try { + await generateImage({ + adapter: openaiImage("gpt-image-2.5-flare"), + prompt: "A lighthouse at night", + threadId: "thread-1", + }); +} catch (error) { + if (error instanceof Error && "usage" in error) { + console.log("Billed usage of the failed call:", error.usage); + } +} +``` + ### Terminal hooks: onFinish, onAbort, onError Exactly **one** terminal hook runs per `chat()` call: @@ -805,7 +832,7 @@ On the **separate-finalization** path, the adapter makes one more provider call - `onIteration` does **not** run for that call. It runs only for agent-loop iterations. - `onFinish` runs after that call completes. Its `info` shows the end state of the **agent loop**. - `info.content` does not have the JSON deltas. Read the result from the `structured-output.complete` CUSTOM event in `onChunk`. -- `info.usage` is `undefined` for a structured-output run with no tools. Use `onUsage` to get the tokens of the final call. `onUsage` runs for **every** `RUN_FINISHED` with usage. +- `info.usage` is `undefined` for a structured-output run with no tools. Use `onUsage` to get the tokens of the final call. `onUsage` runs for **every** `RUN_FINISHED` or `RUN_ERROR` with usage. On the **native-combined** path, the JSON comes in the normal agent-loop stream: diff --git a/docs/config.json b/docs/config.json index d1855bbf8b..263c6a0728 100644 --- a/docs/config.json +++ b/docs/config.json @@ -416,7 +416,7 @@ "label": "Middleware: Advanced", "to": "advanced/middleware-advanced", "addedAt": "2026-10-21", - "updatedAt": "2026-10-22" + "updatedAt": "2026-11-02" }, { "label": "Built-in Middleware", @@ -1698,7 +1698,7 @@ "label": "Anthropic: Advanced", "to": "adapters/anthropic-advanced", "addedAt": "2026-10-21", - "updatedAt": "2026-10-22" + "updatedAt": "2026-11-02" }, { "label": "Google Gemini", @@ -1793,7 +1793,8 @@ { "label": "OpenRouter: Advanced", "to": "adapters/openrouter-advanced", - "addedAt": "2026-10-21" + "addedAt": "2026-10-21", + "updatedAt": "2026-11-02" }, { "label": "Perplexity Search", diff --git a/packages/ai-acp/src/adapters/compatible.ts b/packages/ai-acp/src/adapters/compatible.ts index 2a312e365c..f5a1de68ab 100644 --- a/packages/ai-acp/src/adapters/compatible.ts +++ b/packages/ai-acp/src/adapters/compatible.ts @@ -38,6 +38,7 @@ import type { Modality, ModelMessage, AdapterYieldChunk, + RunFinishedEvent, TextOptions, } from '@tanstack/ai' import type { AcpSessionHandle } from '../session/acp-client' @@ -644,12 +645,18 @@ export class AcpCompatibleTextAdapter< } if (options.outputSchema) { - yield* this.emitParsedStructuredOutput( + const parseFailed = yield* this.emitParsedStructuredOutput( lastAssistantText, threadId, runId, lastTextMessageId, + heldFinished?.type === EventType.RUN_FINISHED + ? heldFinished.usage + : undefined, ) + // chat() stops at the first RUN_ERROR, so the held RUN_FINISHED is + // never read. Its usage is on the RUN_ERROR. + if (parseFailed) heldFinished = undefined } if (heldFinished) yield heldFinished @@ -740,7 +747,8 @@ export class AcpCompatibleTextAdapter< threadId: string, runId: string, messageId = this.generateId(), - ): Generator { + usage?: RunFinishedEvent['usage'], + ): Generator { try { const object = parseJsonFromAssistantText(raw) yield structuredOutputStartChunk({ @@ -757,6 +765,7 @@ export class AcpCompatibleTextAdapter< object, raw, }) + return false } catch (error: unknown) { const parserMessage = error instanceof Error @@ -772,7 +781,9 @@ export class AcpCompatibleTextAdapter< message, code: 'structured-output-parse-failed', error: { message, code: 'structured-output-parse-failed' }, + ...(usage && { usage }), } + return true } } diff --git a/packages/ai-acp/src/stream/translate.ts b/packages/ai-acp/src/stream/translate.ts index 7f5b99377d..0c88c5e4c2 100644 --- a/packages/ai-acp/src/stream/translate.ts +++ b/packages/ai-acp/src/stream/translate.ts @@ -96,8 +96,15 @@ function buildUsage(usage: AcpUsage | undefined): TokenUsage | undefined { completionTokens, totalTokens: usage.totalTokens ?? promptTokens + completionTokens, }) - if (usage.cachedReadTokens) { - result.promptTokensDetails = { cachedTokens: usage.cachedReadTokens } + if (usage.cachedReadTokens || usage.cachedWriteTokens) { + result.promptTokensDetails = { + ...(usage.cachedReadTokens + ? { cachedTokens: usage.cachedReadTokens } + : {}), + ...(usage.cachedWriteTokens + ? { cacheWriteTokens: usage.cachedWriteTokens } + : {}), + } } if (usage.thoughtTokens) { result.completionTokensDetails = { reasoningTokens: usage.thoughtTokens } @@ -110,6 +117,7 @@ export async function* translateAcpStream( ctx: TranslateContext, ): AsyncIterable { const { model, runId, threadId, genId, labels } = ctx + let sessionCost: { amount: number; currency: string } | undefined const now = () => Date.now() let runStarted = false @@ -238,7 +246,9 @@ export async function* translateAcpStream( function* handleUpdate( update: AcpSessionUpdate, ): Generator { - if (update.sessionUpdate === 'agent_message_chunk') { + if (update.sessionUpdate === 'usage_update') { + if (update.cost) sessionCost = update.cost + } else if (update.sessionUpdate === 'agent_message_chunk') { yield* closeReasoning() // Non-text content (image / audio / resource / resource_link): surface it // as a CUSTOM event when the harness opted in, instead of dropping it. @@ -377,6 +387,18 @@ export async function* translateAcpStream( yield* closeReasoning() yield* synthesizeUnresolvedResults() + const usage = buildUsage(event.usage) + // ponytail: the ACP cost is the whole session's total, so it is kept + // as a detail. As `usage.cost` it would count earlier turns again. + if (usage && sessionCost) { + usage.providerUsageDetails = { + ...usage.providerUsageDetails, + sessionCost: { + amount: sessionCost.amount, + currency: sessionCost.currency, + }, + } + } if (event.stopReason === 'refusal') { const message = labels.refusalMessage ?? 'The harness refused the request.' @@ -387,9 +409,9 @@ export async function* translateAcpStream( message, code: 'refusal', error: { message, code: 'refusal' }, + ...(usage && { usage }), } } else { - const usage = buildUsage(event.usage) const finishReason = event.stopReason === 'max_tokens' || event.stopReason === 'max_turn_requests' diff --git a/packages/ai-acp/src/types/acp-types.ts b/packages/ai-acp/src/types/acp-types.ts index 398aad3ecc..3aa973ed60 100644 --- a/packages/ai-acp/src/types/acp-types.ts +++ b/packages/ai-acp/src/types/acp-types.ts @@ -39,6 +39,12 @@ export type AcpSessionUpdate = | { sessionUpdate: 'available_commands_update' } | { sessionUpdate: 'current_mode_update' } | { sessionUpdate: 'user_message_chunk'; content: AcpContentBlock } + | { + sessionUpdate: 'usage_update' + used: number + size: number + cost?: { amount: number; currency: string } | null + } export type AcpStopReason = | 'end_turn' @@ -53,6 +59,7 @@ export interface AcpUsage { outputTokens?: number | null totalTokens?: number | null cachedReadTokens?: number | null + cachedWriteTokens?: number | null thoughtTokens?: number | null } diff --git a/packages/ai-acp/tests/compatible.test.ts b/packages/ai-acp/tests/compatible.test.ts index 7ef7791ab3..667d13131b 100644 --- a/packages/ai-acp/tests/compatible.test.ts +++ b/packages/ai-acp/tests/compatible.test.ts @@ -34,6 +34,7 @@ const SDK_URL = pathToFileURL(require.resolve('@agentclientprotocol/sdk')).href function fakeAcpAgent( protocolVersionExpr = 'PROTOCOL_VERSION', reply = 'pong', + promptResultExpr = "{ stopReason: 'end_turn' }", ): string { return ` import { AgentSideConnection, ndJsonStream, PROTOCOL_VERSION } from ${JSON.stringify(SDK_URL)} @@ -63,7 +64,7 @@ new AgentSideConnection((conn) => ({ sessionId: params.sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: ${JSON.stringify(reply)} } }, }) - return { stopReason: 'end_turn' } + return ${promptResultExpr} }, async cancel() {}, }), stream) @@ -468,4 +469,42 @@ describe('acpCompatible in-sandbox adapter (stdio)', () => { await sbx.destroy() }) + + it('puts the turn usage on the parse error and drops RUN_FINISHED', async () => { + const sbx = await provider.create({}) + await sbx.fs.write( + '/workspace/fake-acp-agent.mjs', + fakeAcpAgent( + 'PROTOCOL_VERSION', + 'pong', + "{ stopReason: 'end_turn', usage: { inputTokens: 12, outputTokens: 3, totalTokens: 15 } }", + ), + ) + + const chunks = await collect( + acpCompatibleText('pi-fast', { + name: 'pi', + command: () => 'node fake-acp-agent.mjs', + }).chatStream({ + threadId: 'thread-1', + model: 'pi-fast', + messages: [{ role: 'user', content: 'report' }], + outputSchema: { + type: 'object', + properties: { ok: { type: 'boolean' } }, + required: ['ok'], + }, + logger: noopLogger, + capabilities: capabilityContextWith(sbx), + }), + ) + + expect(chunks.find((chunk) => chunk.type === 'RUN_ERROR')).toMatchObject({ + code: 'structured-output-parse-failed', + usage: { promptTokens: 12, completionTokens: 3, totalTokens: 15 }, + }) + expect(chunks.some((chunk) => chunk.type === 'RUN_FINISHED')).toBe(false) + + await sbx.destroy() + }) }) diff --git a/packages/ai-acp/tests/translate.test.ts b/packages/ai-acp/tests/translate.test.ts index 08343eeee9..563a439e08 100644 --- a/packages/ai-acp/tests/translate.test.ts +++ b/packages/ai-acp/tests/translate.test.ts @@ -360,6 +360,85 @@ describe('translateAcpStream', () => { expect(chunks.at(-1)).toMatchObject({ type: 'RUN_ERROR', code: 'refusal' }) }) + it('puts usage on the refusal RUN_ERROR', async () => { + const chunks = await collect([ + session, + { + kind: 'done', + stopReason: 'refusal', + usage: { inputTokens: 50, outputTokens: 10, totalTokens: 60 }, + }, + ]) + expect(chunks.at(-1)).toMatchObject({ + type: 'RUN_ERROR', + code: 'refusal', + usage: { promptTokens: 50, completionTokens: 10, totalTokens: 60 }, + }) + }) + + it('maps cachedWriteTokens to cacheWriteTokens', async () => { + const chunks = await collect([ + session, + { + kind: 'done', + stopReason: 'end_turn', + usage: { + inputTokens: 50, + outputTokens: 10, + totalTokens: 60, + cachedReadTokens: 20, + cachedWriteTokens: 7, + }, + }, + ]) + expect(chunks.at(-1)).toMatchObject({ + type: 'RUN_FINISHED', + usage: { + promptTokensDetails: { cachedTokens: 20, cacheWriteTokens: 7 }, + }, + }) + }) + + it('keeps the session cost of the last usage_update in providerUsageDetails', async () => { + const chunks = await collect([ + session, + { + kind: 'update', + update: { + sessionUpdate: 'usage_update', + used: 900, + size: 200000, + cost: { amount: 0.01, currency: 'USD' }, + }, + }, + { + kind: 'update', + update: { + sessionUpdate: 'usage_update', + used: 1200, + size: 200000, + cost: { amount: 0.03, currency: 'USD' }, + }, + }, + { + kind: 'done', + stopReason: 'end_turn', + usage: { inputTokens: 50, outputTokens: 10, totalTokens: 60 }, + }, + ]) + const finished = chunks.at(-1) + expect(finished).toMatchObject({ + type: 'RUN_FINISHED', + usage: { + providerUsageDetails: { + sessionCost: { amount: 0.03, currency: 'USD' }, + }, + }, + }) + // The session total includes earlier turns, so it is not the run cost. + expect(finished).not.toHaveProperty('usage.cost') + }) + it('reports usage from the prompt response when present', async () => { const chunks = await collect([ session, diff --git a/packages/ai-anthropic/src/adapters/text.ts b/packages/ai-anthropic/src/adapters/text.ts index 1b019fddd6..b6fc12ceda 100644 --- a/packages/ai-anthropic/src/adapters/text.ts +++ b/packages/ai-anthropic/src/adapters/text.ts @@ -818,6 +818,9 @@ export class AnthropicTextAdapter< }, } + // Set once the response arrives, so a later throw still reports it. + let usage: ReturnType + try { logger.request( `activity=chat provider=anthropic model=${this.model} messages=${chatOptions.messages.length} tools=${chatOptions.tools?.length ?? 0} stream=false`, @@ -846,6 +849,7 @@ export class AnthropicTextAdapter< headers: this.requestHeaders(chatOptions.request?.headers, betas), }, ) + usage = buildAnthropicUsage(response.usage) // A forced tool call cut off at the output cap carries partial input, // which would read like a schema failure (issue #1426). @@ -889,7 +893,7 @@ export class AnthropicTextAdapter< return { data: parsed, rawText, - usage: buildAnthropicUsage(response.usage), + usage, responseId: response.id, model: response.model, } @@ -899,8 +903,11 @@ export class AnthropicTextAdapter< error, source: 'anthropic.structuredOutput', }) - throw new Error( - `Structured output generation failed: ${err.message || 'Unknown error occurred'}`, + throw Object.assign( + new Error( + `Structured output generation failed: ${err.message || 'Unknown error occurred'}`, + ), + usage && { usage }, ) } } @@ -2197,6 +2204,7 @@ export class AnthropicTextAdapter< // message_delta carries the real stop_reason (tool_use, end_turn, etc.), // while message_stop is just a completion signal. if (!hasEmittedRunFinished) { + const usage = buildAnthropicUsage(messageStartUsage) yield { type: EventType.RUN_FINISHED, ...(responseId !== undefined && { responseId }), @@ -2205,6 +2213,7 @@ export class AnthropicTextAdapter< model, timestamp: Date.now(), finishReason: 'stop', + ...(usage && { usage }), } } } else if (event.type === 'message_delta') { diff --git a/packages/ai-anthropic/src/usage.ts b/packages/ai-anthropic/src/usage.ts index c8d7d02b19..a4c387e7d2 100644 --- a/packages/ai-anthropic/src/usage.ts +++ b/packages/ai-anthropic/src/usage.ts @@ -17,6 +17,18 @@ export type AnthropicProviderUsageDetails = { /** Number of web fetch requests made during the response */ webFetchRequests?: number } + /** + * Per-iteration token usage (`usage.iterations`). A `compaction` entry's + * tokens are not in the top-level counts. + */ + iterations?: Array<{ + type: string + model?: string + inputTokens: number + outputTokens: number + cacheCreationInputTokens: number + cacheReadInputTokens: number + }> } /** @@ -80,6 +92,13 @@ export function buildAnthropicUsage( result.promptTokensDetails = promptTokensDetails } + // The closing message_delta has the cumulative count, so a stream does not + // take message_start's. Zero is kept. + const thinkingTokens = usage.output_tokens_details?.thinking_tokens + if (thinkingTokens !== undefined) { + result.completionTokensDetails = { reasoningTokens: thinkingTokens } + } + // Add provider-specific usage details for server tool use, again only when // the provider actually reported any server tool requests. const serverToolUse = usage.server_tool_use ?? start?.server_tool_use @@ -91,10 +110,24 @@ export function buildAnthropicUsage( ? { webFetchRequests: serverToolUse.web_fetch_requests } : {}), } - if (Object.keys(serverToolUseDetails).length > 0) { - result.providerUsageDetails = { - serverToolUse: serverToolUseDetails, - } satisfies AnthropicProviderUsageDetails + const iterations = usage.iterations?.map((iteration) => ({ + type: iteration.type, + ...('model' in iteration && iteration.model + ? { model: iteration.model } + : {}), + inputTokens: iteration.input_tokens, + outputTokens: iteration.output_tokens, + cacheCreationInputTokens: iteration.cache_creation_input_tokens, + cacheReadInputTokens: iteration.cache_read_input_tokens, + })) + const providerUsageDetails: AnthropicProviderUsageDetails = { + ...(Object.keys(serverToolUseDetails).length > 0 + ? { serverToolUse: serverToolUseDetails } + : {}), + ...(iterations?.length ? { iterations } : {}), + } + if (Object.keys(providerUsageDetails).length > 0) { + result.providerUsageDetails = providerUsageDetails } return result diff --git a/packages/ai-anthropic/tests/usage-extraction.test.ts b/packages/ai-anthropic/tests/usage-extraction.test.ts index e5b75a6702..969eb3ab8a 100644 --- a/packages/ai-anthropic/tests/usage-extraction.test.ts +++ b/packages/ai-anthropic/tests/usage-extraction.test.ts @@ -780,4 +780,252 @@ describe('Anthropic usage extraction', () => { }, ) }) + + it.each([ + ['30 thinking tokens', 30], + ['0 thinking tokens', 0], + ])( + 'maps output_tokens_details.thinking_tokens to reasoningTokens (%s)', + async (_, thinkingTokens) => { + mocks.betaMessagesCreate.mockResolvedValueOnce( + createMockStream([ + { + type: 'message_start', + message: { + id: 'msg_123', + type: 'message', + role: 'assistant', + content: [], + model: 'claude-sonnet-4-5', + usage: { + input_tokens: 10, + output_tokens: 1, + output_tokens_details: { thinking_tokens: 0 }, + }, + }, + }, + { + type: 'message_delta', + delta: { stop_reason: 'end_turn' }, + usage: { + output_tokens: 50, + output_tokens_details: { thinking_tokens: thinkingTokens }, + }, + }, + { type: 'message_stop' }, + ]), + ) + + const chunks: Array = [] + for await (const chunk of createAdapter().chatStream({ + model: 'claude-sonnet-4-5', + threadId: 'thread-1', + messages: [{ role: 'user', content: 'Hello' }], + logger: createSilentLogger(), + })) { + chunks.push(chunk) + } + + const doneChunk = chunks.find((c) => c.type === 'RUN_FINISHED') + expect(tokenUsageOf(doneChunk)?.completionTokensDetails).toEqual({ + reasoningTokens: thinkingTokens, + }) + }, + ) + + it('reports usage iterations in providerUsageDetails without changing the totals', async () => { + mocks.betaMessagesCreate.mockResolvedValueOnce( + createMockStream([ + { + type: 'message_start', + message: { + id: 'msg_123', + type: 'message', + role: 'assistant', + content: [], + model: 'claude-sonnet-4-5', + usage: { input_tokens: 100, output_tokens: 1 }, + }, + }, + { + type: 'message_delta', + delta: { stop_reason: 'end_turn' }, + usage: { + input_tokens: 100, + output_tokens: 20, + iterations: [ + { + type: 'compaction', + input_tokens: 5000, + output_tokens: 300, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + cache_creation: null, + }, + { + type: 'message', + model: 'claude-sonnet-4-5', + input_tokens: 100, + output_tokens: 20, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 40, + cache_creation: null, + }, + ], + }, + }, + { type: 'message_stop' }, + ]), + ) + + const chunks: Array = [] + for await (const chunk of createAdapter().chatStream({ + model: 'claude-sonnet-4-5', + threadId: 'thread-1', + messages: [{ role: 'user', content: 'Hello' }], + logger: createSilentLogger(), + })) { + chunks.push(chunk) + } + + const usage = tokenUsageOf(chunks.find((c) => c.type === 'RUN_FINISHED')) + // Compaction tokens are not in the top-level counts, so the totals stay. + expect(usage).toMatchObject({ + promptTokens: 100, + completionTokens: 20, + totalTokens: 120, + }) + expect(usage?.providerUsageDetails).toEqual({ + iterations: [ + { + type: 'compaction', + inputTokens: 5000, + outputTokens: 300, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + }, + { + type: 'message', + model: 'claude-sonnet-4-5', + inputTokens: 100, + outputTokens: 20, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 40, + }, + ], + }) + }) + + it('reports the message_start usage when message_stop ends the run without a stop_reason', async () => { + mocks.betaMessagesCreate.mockResolvedValueOnce( + createMockStream([ + { + type: 'message_start', + message: { + id: 'msg_123', + type: 'message', + role: 'assistant', + content: [], + model: 'claude-sonnet-4-5', + usage: { input_tokens: 12, output_tokens: 3 }, + }, + }, + { type: 'message_stop' }, + ]), + ) + + const chunks: Array = [] + for await (const chunk of createAdapter().chatStream({ + model: 'claude-sonnet-4-5', + threadId: 'thread-1', + messages: [{ role: 'user', content: 'Hello' }], + logger: createSilentLogger(), + })) { + chunks.push(chunk) + } + + const doneChunk = chunks.find((c) => c.type === 'RUN_FINISHED') + expect(tokenUsageOf(doneChunk)).toEqual({ + promptTokens: 12, + completionTokens: 3, + totalTokens: 15, + }) + }) + + describe('when structuredOutput fails after the response arrived', () => { + const structuredOptions = { + chatOptions: { + threadId: 'thread-1', + model: 'claude-sonnet-4-5', + messages: [{ role: 'user' as const, content: 'recommend a guitar' }], + logger: createSilentLogger(), + }, + outputSchema: { + type: 'object', + properties: { recommendation: { type: 'string' } }, + }, + } + const response = { + id: 'msg_structured', + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-5', + usage: { + input_tokens: 10, + output_tokens: 100, + cache_read_input_tokens: 5, + }, + } + + it.each([ + [ + 'max_tokens', + { + stop_reason: 'max_tokens', + content: [ + { + type: 'tool_use', + id: 'toolu_1', + name: 'structured_output', + input: {}, + }, + ], + }, + ], + [ + 'refusal', + { + stop_reason: 'refusal', + content: [{ type: 'text', text: 'I cannot help with that.' }], + }, + ], + ])('attaches the usage to the thrown error (%s)', async (_, outcome) => { + mocks.betaMessagesCreate.mockResolvedValueOnce({ + ...response, + ...outcome, + }) + + await expect( + createAdapter().structuredOutput(structuredOptions), + ).rejects.toMatchObject({ + message: expect.stringMatching(/^Structured output generation failed/), + usage: { + promptTokens: 15, + completionTokens: 100, + totalTokens: 115, + promptTokensDetails: { cachedTokens: 5 }, + }, + }) + }) + + it('attaches no usage when the request itself failed', async () => { + mocks.betaMessagesCreate.mockRejectedValueOnce(new Error('ECONNRESET')) + + const error: unknown = await createAdapter() + .structuredOutput(structuredOptions) + .catch((e: unknown) => e) + expect(error).toBeInstanceOf(Error) + expect(error).not.toHaveProperty('usage') + }) + }) }) diff --git a/packages/ai-bedrock/src/adapters/converse-text.ts b/packages/ai-bedrock/src/adapters/converse-text.ts index c4400e9741..14727851fd 100644 --- a/packages/ai-bedrock/src/adapters/converse-text.ts +++ b/packages/ai-bedrock/src/adapters/converse-text.ts @@ -345,24 +345,30 @@ export class BedrockConverseTextAdapter< toolConfig: buildStructuredToolConfig(outputSchema), } const res = await this.send(input) + const usage = res.usage && buildConverseUsage(res.usage) // A forced tool call cut off at the output cap carries partial input, // which would read like a schema failure (issue #1426). if (res.stopReason === 'max_tokens') { - throw new Error( - `${this.name}.structuredOutput: the response was cut off because the maximum token limit was reached (stopReason=max_tokens); raise modelOptions.max_completion_tokens`, + throw Object.assign( + new Error( + `${this.name}.structuredOutput: the response was cut off because the maximum token limit was reached (stopReason=max_tokens); raise modelOptions.max_completion_tokens`, + ), + usage && { usage }, ) } const structured = extractStructuredToolInput(res) if (structured === undefined) { - throw new Error( - `${this.name}.structuredOutput: response contained no forced-tool output`, + throw Object.assign( + new Error( + `${this.name}.structuredOutput: response contained no forced-tool output`, + ), + usage && { usage }, ) } - const usage = res.usage return { data: structured, rawText: JSON.stringify(structured), - ...(usage && { usage: buildConverseUsage(usage) }), + ...(usage && { usage }), } } catch (error: unknown) { chatOptions.logger.errors(`${this.name}.structuredOutput fatal`, { @@ -518,6 +524,7 @@ export class BedrockConverseTextAdapter< message, code: 'max_tokens', error: { message, code: 'max_tokens' }, + ...(usage && { usage }), } return } @@ -535,6 +542,7 @@ export class BedrockConverseTextAdapter< message: `${this.name}.structuredOutputStream: response contained no content`, code: 'empty-response', }, + ...(usage && { usage }), } return } @@ -555,6 +563,7 @@ export class BedrockConverseTextAdapter< message: 'Failed to parse structured output as JSON', code: 'parse-error', }, + ...(usage && { usage }), } return } @@ -613,6 +622,7 @@ export class BedrockConverseTextAdapter< message: errorPayload.message, ...(errorPayload.code !== undefined && { code: errorPayload.code }), }, + ...(usage && { usage }), } } } diff --git a/packages/ai-bedrock/src/converse/usage.ts b/packages/ai-bedrock/src/converse/usage.ts index bfa48bca36..9eb4b62dae 100644 --- a/packages/ai-bedrock/src/converse/usage.ts +++ b/packages/ai-bedrock/src/converse/usage.ts @@ -15,6 +15,10 @@ import type { TokenUsage as ConverseTokenUsage } from '@aws-sdk/client-bedrock-r export function buildConverseUsage(usage: ConverseTokenUsage): TokenUsage { const cachedTokens = usage.cacheReadInputTokens const cacheWriteTokens = usage.cacheWriteInputTokens + // The 1-hour part of the cache write. + const cacheWrite1hTokens = usage.cacheDetails?.find( + (detail) => detail.ttl === '1h', + )?.inputTokens const promptTokens = (usage.inputTokens ?? 0) + (cachedTokens ?? 0) + (cacheWriteTokens ?? 0) const completionTokens = usage.outputTokens ?? 0 @@ -31,6 +35,7 @@ export function buildConverseUsage(usage: ConverseTokenUsage): TokenUsage { const promptTokensDetails = { ...(cachedTokens !== undefined ? { cachedTokens } : {}), ...(cacheWriteTokens !== undefined ? { cacheWriteTokens } : {}), + ...(cacheWrite1hTokens !== undefined ? { cacheWrite1hTokens } : {}), } if (Object.keys(promptTokensDetails).length > 0) { result.promptTokensDetails = promptTokensDetails diff --git a/packages/ai-bedrock/tests/converse/adapter.test.ts b/packages/ai-bedrock/tests/converse/adapter.test.ts index 10c7b5660e..febb7a4f6a 100644 --- a/packages/ai-bedrock/tests/converse/adapter.test.ts +++ b/packages/ai-bedrock/tests/converse/adapter.test.ts @@ -629,6 +629,141 @@ describe('BedrockConverseTextAdapter', () => { expect(errors[0]?.code).toBe('parse-error') }) + describe('reports the provider usage when structured output fails', () => { + // SDK boundary: the metadata event requires `metrics` too. + const metadataEvent = { + metadata: { + usage: { inputTokens: 7, outputTokens: 11, totalTokens: 18 }, + }, + } as unknown as ConverseStreamOutput + const expectedUsage = { + promptTokens: 7, + completionTokens: 11, + totalTokens: 18, + } + const toolStart: ConverseStreamOutput = { + contentBlockStart: { + start: { toolUse: { toolUseId: 's', name: 'structured_output' } }, + contentBlockIndex: 0, + }, + } + const toolInput = (input: string): ConverseStreamOutput => ({ + contentBlockDelta: { + delta: { toolUse: { input } }, + contentBlockIndex: 0, + }, + }) + + it.each([ + [ + 'max_tokens', + [ + toolStart, + toolInput('{"n":'), + { messageStop: { stopReason: 'max_tokens' } }, + metadataEvent, + ], + ], + [ + 'empty-response', + [{ messageStop: { stopReason: 'end_turn' } }, metadataEvent], + ], + [ + 'parse-error', + [ + toolStart, + toolInput('{"n":'), + { messageStop: { stopReason: 'tool_use' } }, + metadataEvent, + ], + ], + [ + 'ThrottlingException', + [ + toolStart, + toolInput('{"n":5}'), + { messageStop: { stopReason: 'tool_use' } }, + metadataEvent, + { + throttlingException: new Error('rate limited'), + } as unknown as ConverseStreamOutput, + ], + ], + ] satisfies Array<[string, Array]>)( + 'on the structuredOutputStream RUN_ERROR (%s)', + async (_, streamEvents) => { + const a = new StubAdapter({ apiKey: 'k' }, 'us.amazon.nova-pro-v1:0') + a.streamEvents = [ + { messageStart: { role: 'assistant' } }, + ...streamEvents, + ] + const errors: Array = [] + for await (const c of a.structuredOutputStream({ + chatOptions: textOptions({ + messages: [{ role: 'user', content: 'go' }], + }), + outputSchema: { + type: 'object', + properties: { n: { type: 'number' } }, + }, + })) { + if (c.type === EventType.RUN_ERROR) errors.push(c) + } + expect(errors).toHaveLength(1) + expect((errors[0] as { usage?: unknown }).usage).toEqual(expectedUsage) + }, + ) + + it.each([ + [ + 'max_tokens', + { + stopReason: 'max_tokens', + output: { + message: { + role: 'assistant', + content: [ + { + toolUse: { + toolUseId: 's', + name: 'structured_output', + input: {}, + }, + }, + ], + }, + }, + }, + ], + [ + 'guardrail_intervened', + { + stopReason: 'guardrail_intervened', + output: { + message: { role: 'assistant', content: [{ text: 'Blocked.' }] }, + }, + }, + ], + ])('on the structuredOutput error (%s)', async (_, outcome) => { + const a = new StubAdapter({ apiKey: 'k' }, 'us.amazon.nova-pro-v1:0') + a.nonStreamOutput = { + ...outcome, + usage: { inputTokens: 7, outputTokens: 11, totalTokens: 18 }, + } as unknown as ConverseCommandOutput + await expect( + a.structuredOutput({ + chatOptions: textOptions({ + messages: [{ role: 'user', content: 'go' }], + }), + outputSchema: { + type: 'object', + properties: { n: { type: 'number' } }, + }, + }), + ).rejects.toMatchObject({ usage: expectedUsage }) + }) + }) + it('prefers the bearer auth scheme for a token (SDK defaults to SigV4 otherwise)', () => { class Probe extends BedrockConverseTextAdapter<'us.amazon.nova-pro-v1:0'> { bearer(token: string) { diff --git a/packages/ai-bedrock/tests/converse/stream-processor.test.ts b/packages/ai-bedrock/tests/converse/stream-processor.test.ts index b6cf302411..d77d5d13a5 100644 --- a/packages/ai-bedrock/tests/converse/stream-processor.test.ts +++ b/packages/ai-bedrock/tests/converse/stream-processor.test.ts @@ -232,6 +232,30 @@ describe('processConverseStream', () => { }) }) + it('reports the 1-hour cache write from cacheDetails as cacheWrite1hTokens', async () => { + const usage = await finishedUsage({ + inputTokens: 5, + outputTokens: 7, + totalTokens: 1012, + cacheReadInputTokens: 0, + cacheWriteInputTokens: 1000, + cacheDetails: [ + { ttl: '5m', inputTokens: 200 }, + { ttl: '1h', inputTokens: 800 }, + ], + }) + expect(usage).toEqual({ + promptTokens: 1005, + completionTokens: 7, + totalTokens: 1012, + promptTokensDetails: { + cachedTokens: 0, + cacheWriteTokens: 1000, + cacheWrite1hTokens: 800, + }, + }) + }) + it('drains a tool call that never received contentBlockStop (truncated stream)', async () => { const events = await collect( { diff --git a/packages/ai-claude-code/src/stream/sdk-types.ts b/packages/ai-claude-code/src/stream/sdk-types.ts index 9ad3b11e29..e57d72e621 100644 --- a/packages/ai-claude-code/src/stream/sdk-types.ts +++ b/packages/ai-claude-code/src/stream/sdk-types.ts @@ -95,6 +95,18 @@ export interface SdkUsage { cache_creation_input_tokens?: number } +/** Per-model usage entry of `result.modelUsage`. */ +export interface SdkModelUsage { + inputTokens: number + outputTokens: number + cacheReadInputTokens: number + cacheCreationInputTokens: number + webSearchRequests: number + costUSD: number + contextWindow: number + maxOutputTokens: number +} + export interface SdkResultMessage { type: 'result' subtype: @@ -107,6 +119,7 @@ export interface SdkResultMessage { errors?: Array usage?: SdkUsage total_cost_usd?: number + modelUsage?: Record structured_output?: unknown } diff --git a/packages/ai-claude-code/src/stream/translate.ts b/packages/ai-claude-code/src/stream/translate.ts index 1747fd9827..42809b1e03 100644 --- a/packages/ai-claude-code/src/stream/translate.ts +++ b/packages/ai-claude-code/src/stream/translate.ts @@ -8,6 +8,7 @@ import type { AdapterYieldChunk, TokenUsage } from '@tanstack/ai' import type { AgentSdkMessage, SdkAssistantMessage, + SdkModelUsage, SdkPartialAssistantMessage, SdkResultMessage, SdkToolResultContent, @@ -34,6 +35,8 @@ export const SYNTHETIC_STRUCTURED_OUTPUT_TOOL = 'StructuredOutput' export type ClaudeCodeProviderUsageDetails = { /** Total cost of the harness run in USD, as reported by Claude Code. */ totalCostUsd?: number + /** Per-model usage breakdown, as reported by Claude Code. */ + modelUsage?: Record } export interface TranslateContext { @@ -87,6 +90,7 @@ function stringifyToolResultContent( function buildUsage( usage: SdkUsage | undefined, totalCostUsd: number | undefined, + modelUsage: Record | undefined, ): TokenUsage | undefined { if (!usage) return undefined const cacheWrite = usage.cache_creation_input_tokens ?? 0 @@ -108,7 +112,13 @@ function buildUsage( result.promptTokensDetails = promptTokensDetails } if (totalCostUsd !== undefined) { - result.providerUsageDetails = { totalCostUsd } + result.cost = totalCostUsd + } + if (totalCostUsd !== undefined || modelUsage !== undefined) { + result.providerUsageDetails = { + ...(totalCostUsd !== undefined && { totalCostUsd }), + ...(modelUsage !== undefined && { modelUsage }), + } } return result } @@ -432,7 +442,11 @@ export async function* translateSdkStream( yield* closePartialToolUses() yield* synthesizeUnresolvedResults() - const usage = buildUsage(message.usage, message.total_cost_usd) + const usage = buildUsage( + message.usage, + message.total_cost_usd, + message.modelUsage, + ) if (ctx.expectStructuredOutput === true) { const fromResult = isUsefulStructuredObject(message.structured_output) ? message.structured_output @@ -488,6 +502,7 @@ export async function* translateSdkStream( message: errorMessage, code: message.subtype, error: { message: errorMessage, code: message.subtype }, + ...(usage !== undefined && { usage }), } } } diff --git a/packages/ai-claude-code/tests/translate.test.ts b/packages/ai-claude-code/tests/translate.test.ts index b3a25971d3..878374ccd5 100644 --- a/packages/ai-claude-code/tests/translate.test.ts +++ b/packages/ai-claude-code/tests/translate.test.ts @@ -452,6 +452,53 @@ describe('translateSdkStream', () => { }) }) + it.each([ + 'error_during_execution', + 'error_max_budget_usd', + 'error_max_structured_output_retries', + ] as const)('carries usage and cost on the %s RUN_ERROR', async (subtype) => { + const chunks = await collect([ + init, + { type: 'result', subtype, usage, total_cost_usd: 0.5, errors: ['x'] }, + ]) + expect(chunks.at(-1)).toMatchObject({ + type: 'RUN_ERROR', + usage: { + promptTokens: 115, + completionTokens: 50, + totalTokens: 165, + cost: 0.5, + providerUsageDetails: { totalCostUsd: 0.5 }, + }, + }) + }) + + it('maps total_cost_usd to usage.cost and keeps modelUsage in providerUsageDetails', async () => { + const modelUsage = { + 'claude-opus-4-6': { + inputTokens: 100, + outputTokens: 50, + cacheReadInputTokens: 10, + cacheCreationInputTokens: 5, + webSearchRequests: 0, + costUSD: 0.12, + contextWindow: 200000, + maxOutputTokens: 32000, + }, + } + const chunks = await collect([ + init, + assistantText('hi'), + { ...resultSuccess, modelUsage }, + ]) + expect(chunks.find((c) => c.type === 'RUN_FINISHED')).toMatchObject({ + usage: { + cost: 0.12, + providerUsageDetails: { totalCostUsd: 0.12, modelUsage }, + }, + }) + }) + it('skips subagent messages (parent_tool_use_id set)', async () => { const chunks = await collect([ init, diff --git a/packages/ai-cloudflare/src/adapters/transcription.ts b/packages/ai-cloudflare/src/adapters/transcription.ts index eb67f6b688..a5c9cc5e21 100644 --- a/packages/ai-cloudflare/src/adapters/transcription.ts +++ b/packages/ai-cloudflare/src/adapters/transcription.ts @@ -4,6 +4,7 @@ import { arrayBufferToBase64, generateId } from '@tanstack/ai-utils' import { isBindingConfig, resolveConfigFromEnv } from '../utils/config' import { runModel } from '../utils/run' import type { + TokenUsage, TranscriptionOptions, TranscriptionResult, TranscriptionSegment, @@ -121,7 +122,23 @@ export class CloudflareTranscriptionAdapter< { language, prompt, ...options.modelOptions }, options.abortSignal, ) - return { id: generateId(this.name), model, ...output } + // Workers AI speech-to-text is billed by audio duration and returns no + // token counts, so surface the duration as the billed quantity. + const usage: TokenUsage | undefined = + output.duration !== undefined && output.duration > 0 + ? { + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + billed: { quantity: output.duration, unit: 'seconds' }, + } + : undefined + return { + id: generateId(this.name), + model, + ...output, + ...(usage !== undefined && { usage }), + } } catch (error: unknown) { logger.errors(`${this.name}.transcribe fatal`, { error: toRunErrorPayload(error, `${this.name}.transcribe failed`), diff --git a/packages/ai-cloudflare/tests/cloudflare-adapter.test.ts b/packages/ai-cloudflare/tests/cloudflare-adapter.test.ts index 633a997eae..2e21b48cba 100644 --- a/packages/ai-cloudflare/tests/cloudflare-adapter.test.ts +++ b/packages/ai-cloudflare/tests/cloudflare-adapter.test.ts @@ -594,6 +594,28 @@ describe('transcription adapter', () => { }) }) + it('reports the audio duration as billed seconds', async () => { + const { binding } = fakeBinding(() => ({ + text: 'Hello there.', + transcription_info: { language: 'en', duration: 1.05 }, + })) + const adapter = createCloudflareTranscription( + '@cf/openai/whisper-large-v3-turbo', + { binding }, + ) + const result = await adapter.transcribe({ + model: '@cf/openai/whisper-large-v3-turbo', + audio: new Uint8Array([65]).buffer, + logger, + }) + expect(result.usage).toEqual({ + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + billed: { quantity: 1.05, unit: 'seconds' }, + }) + }) + it('fails instead of returning an empty transcript', async () => { const { binding } = fakeBinding(() => ({ transcription_info: {} })) const adapter = createCloudflareTranscription( diff --git a/packages/ai-codex/src/stream/translate.ts b/packages/ai-codex/src/stream/translate.ts index fac128ac0b..421ba172a2 100644 --- a/packages/ai-codex/src/stream/translate.ts +++ b/packages/ai-codex/src/stream/translate.ts @@ -300,9 +300,12 @@ export async function* translateThreadEvents( if (done) yield* endText(item.id) } - function* emitStructuredFromLast(): Generator { - if (ctx.expectStructuredOutput !== true) return - if (lastAgentMessage === undefined) return + /** Returns true when it yielded a parse-failure RUN_ERROR. */ + function* emitStructuredFromLast( + usage?: TokenUsage, + ): Generator { + if (ctx.expectStructuredOutput !== true) return false + if (lastAgentMessage === undefined) return false const item = lastAgentMessage lastAgentMessage = undefined try { @@ -321,6 +324,7 @@ export async function* translateThreadEvents( object, raw: item.text, }) + return false } catch (error: unknown) { const parserMessage = error instanceof Error @@ -336,7 +340,9 @@ export async function* translateThreadEvents( message, code: 'structured-output-parse-failed', error: { message, code: 'structured-output-parse-failed' }, + ...(usage && { usage }), } + return true } } @@ -427,9 +433,11 @@ export async function* translateThreadEvents( } else if (event.type === 'item.completed') { yield* handleItemCompleted(event.item) } else if (event.type === 'turn.completed') { - yield* emitStructuredFromLast() - yield* synthesizeUnresolvedResults() const usage = buildUsage(event.usage) + const parseFailed = yield* emitStructuredFromLast(usage) + yield* synthesizeUnresolvedResults() + // chat() stops at the first RUN_ERROR, so a RUN_FINISHED is never read. + if (parseFailed) continue yield { type: EventType.RUN_FINISHED, runId, diff --git a/packages/ai-codex/tests/translate.test.ts b/packages/ai-codex/tests/translate.test.ts index 49d6773b16..9ad41413a1 100644 --- a/packages/ai-codex/tests/translate.test.ts +++ b/packages/ai-codex/tests/translate.test.ts @@ -542,6 +542,26 @@ describe('translateThreadEvents', () => { }) }) + it('puts the turn usage on the parse-failure RUN_ERROR and ends there', async () => { + const chunks = await collect( + [ + started, + { + type: 'item.completed', + item: { id: 'item-1', type: 'agent_message', text: 'not json' }, + }, + completedTurn, + ], + makeCtx({ expectStructuredOutput: true }), + ) + expect(chunks.at(-1)).toMatchObject({ + type: 'RUN_ERROR', + code: 'structured-output-parse-failed', + usage: { promptTokens: 100, completionTokens: 20, totalTokens: 120 }, + }) + expect(chunks.some((c) => c.type === 'RUN_FINISHED')).toBe(false) + }) + it('parses fenced JSON from the last agent_message', async () => { const chunks = await collect( [ diff --git a/packages/ai-gemini/src/adapters/embedding.ts b/packages/ai-gemini/src/adapters/embedding.ts index f73a80c350..0501261fcd 100644 --- a/packages/ai-gemini/src/adapters/embedding.ts +++ b/packages/ai-gemini/src/adapters/embedding.ts @@ -88,8 +88,15 @@ export class GeminiEmbeddingAdapter< ) } - // The Gemini embedding API does not report token usage, so `usage` is - // omitted from the result. + // Only Vertex reports usage (per-input token counts and billable + // characters). The Developer API reports none, so `usage` is omitted. + const tokenCounts = embeddings.flatMap( + (embedding) => embedding.statistics?.tokenCount ?? [], + ) + const billableCharacters = response.metadata?.billableCharacterCount + const tokens = tokenCounts.reduce((sum, count) => sum + count, 0) + const hasUsage = + tokenCounts.length > 0 || billableCharacters !== undefined return { id: generateId(this.name), model, @@ -97,6 +104,16 @@ export class GeminiEmbeddingAdapter< vector: embedding.values ?? [], index, })), + ...(hasUsage && { + usage: { + promptTokens: tokens, + completionTokens: 0, + totalTokens: tokens, + ...(billableCharacters !== undefined && { + billed: { quantity: billableCharacters, unit: 'characters' }, + }), + }, + }), } } catch (error: unknown) { logger.errors(`${this.name}.createEmbeddings fatal`, { diff --git a/packages/ai-gemini/src/adapters/image.ts b/packages/ai-gemini/src/adapters/image.ts index 2aea84a24b..1eb9fbe059 100644 --- a/packages/ai-gemini/src/adapters/image.ts +++ b/packages/ai-gemini/src/adapters/image.ts @@ -9,7 +9,7 @@ import { generateId, getGeminiApiKeyFromEnv, } from '../utils' -import { flattenModalityTokenCounts, hasModalityTokens } from '../usage' +import { buildGeminiInteractionsUsage } from '../usage' import { parseNativeImageSize, validatePrompt, @@ -28,7 +28,6 @@ import type { ImagePart, MediaInputMetadata, ResolvedMediaPrompt, - TokenUsage, } from '@tanstack/ai' import { ThinkingLevel } from '@google/genai' import type { @@ -271,9 +270,14 @@ export class GeminiImageAdapter< interaction: Interactions.Interaction, stored: boolean, ): ImageGenerationResult { + // The provider bills a failed or empty interaction too. + const usage = buildGeminiInteractionsUsage(interaction.usage) if (interaction.status !== 'completed') { - throw new Error( - `Gemini ${model} image interaction ended with status "${interaction.status}".`, + throw Object.assign( + new Error( + `Gemini ${model} image interaction ended with status "${interaction.status}".`, + ), + usage && { usage }, ) } // The interaction id only matters for chaining an edit through @@ -309,10 +313,12 @@ export class GeminiImageAdapter< textParts.length > 0 ? `: ${textParts.join(' ').trim()}` : ' (no image was returned).' - throw new Error(`Gemini ${model} returned no images${reason}`) + throw Object.assign( + new Error(`Gemini ${model} returned no images${reason}`), + usage && { usage }, + ) } - const usage = interactionImageUsage(interaction.usage) return { id: interaction.id ?? generateId(this.name), model, @@ -496,46 +502,3 @@ function generatedImageFromBlock(block: { } return undefined } - -function interactionImageUsage( - usage: Interactions.Interaction['usage'], -): TokenUsage | undefined { - if (!usage) return undefined - const promptTokens = usage.total_input_tokens ?? 0 - const completionTokens = usage.total_output_tokens ?? 0 - const promptModalities = flattenModalityTokenCounts( - usage.input_tokens_by_modality?.map((item) => ({ - modality: item.modality, - tokenCount: item.tokens, - })), - ) - const completionModalities = flattenModalityTokenCounts( - usage.output_tokens_by_modality?.map((item) => ({ - modality: item.modality, - tokenCount: item.tokens, - })), - ) - const promptTokensDetails = { - ...(hasModalityTokens(promptModalities) ? promptModalities : {}), - ...(usage.total_cached_tokens - ? { cachedTokens: usage.total_cached_tokens } - : {}), - } - const completionTokensDetails = { - ...(hasModalityTokens(completionModalities) ? completionModalities : {}), - ...(usage.total_thought_tokens - ? { reasoningTokens: usage.total_thought_tokens } - : {}), - } - return { - promptTokens, - completionTokens, - totalTokens: usage.total_tokens ?? promptTokens + completionTokens, - ...(Object.keys(promptTokensDetails).length > 0 - ? { promptTokensDetails } - : {}), - ...(Object.keys(completionTokensDetails).length > 0 - ? { completionTokensDetails } - : {}), - } -} diff --git a/packages/ai-gemini/src/adapters/text.ts b/packages/ai-gemini/src/adapters/text.ts index a5d6acf2f6..73b7cc7382 100644 --- a/packages/ai-gemini/src/adapters/text.ts +++ b/packages/ai-gemini/src/adapters/text.ts @@ -16,7 +16,7 @@ import { import { BaseTextAdapter } from '@tanstack/ai/adapters' import { convertToolsToProviderFormat } from '../tools/tool-converter' import { getGeminiProviderToolKind } from '../tools/gemini-provider-tool' -import { buildGeminiUsage } from '../usage' +import { buildGeminiInteractionsUsage, buildGeminiUsage } from '../usage' import { geminiThinkingConfig } from '../text/reasoning' import { createGeminiClient, @@ -46,6 +46,7 @@ import type { FunctionCallingConfig, GenerateContentParameters, GenerateContentResponse, + GenerateContentResponseUsageMetadata, GoogleGenAI, GroundingMetadata, Part, @@ -60,6 +61,7 @@ import type { AdapterYieldChunk, ProviderExecutedToolSource, TextOptions, + TokenUsage, ToolChoice, } from '@tanstack/ai' import type { ExternalTextProviderOptions } from '../text/text-provider-options' @@ -490,6 +492,7 @@ export class GeminiTextAdapter< const mappedOptions = this.mapCommonOptionsToGemini(options) const { logger } = options + const seen: StreamUsageSeen = {} try { logger.request( @@ -499,10 +502,14 @@ export class GeminiTextAdapter< const result = await this.client.models.generateContentStream(mappedOptions) - yield* this.processStreamChunks(result, options, logger) + yield* this.processStreamChunks(result, options, logger, seen) } catch (error) { const overflow = isGeminiContextOverflow(error) const rawEvent = toRunErrorRawEvent(error) + // A RUN_FINISHED that was already sent reported this usage. + const usage = seen.reported + ? undefined + : buildGeminiUsage(seen.usageMetadata) logger.errors('gemini.chatStream fatal', { error, source: 'gemini.chatStream', @@ -519,6 +526,7 @@ export class GeminiTextAdapter< // Forward the provider's structured error body when present (see // toRunErrorRawEvent); omitted otherwise. ...(rawEvent !== undefined && { rawEvent }), + ...(usage && { usage }), error: { message: error instanceof Error @@ -579,6 +587,7 @@ export class GeminiTextAdapter< }) const text = interaction.output_text ?? '' + const usage = buildGeminiInteractionsUsage(interaction.usage) const responseMetadata = { tanstack: { ...(interaction.id && { responseId: interaction.id }), @@ -626,6 +635,7 @@ export class GeminiTextAdapter< model, timestamp: Date.now(), finishReason: 'stop', + ...(usage && { usage }), } } catch (error) { const overflow = isGeminiContextOverflow(error) @@ -667,6 +677,7 @@ export class GeminiTextAdapter< const { logger } = chatOptions const mappedOptions = this.mapCommonOptionsToGemini(chatOptions) + let usage: TokenUsage | undefined try { logger.request( @@ -682,6 +693,7 @@ export class GeminiTextAdapter< responseSchema: outputSchema, }, }) + usage = buildGeminiUsage(result.usageMetadata) // A response cut off at the output cap is a truncated JSON document; // report it before the parse error (issue #1426). @@ -707,19 +719,21 @@ export class GeminiTextAdapter< rawText, ...(result.responseId && { responseId: result.responseId }), ...(result.modelVersion && { model: result.modelVersion }), - usage: result.usageMetadata - ? buildGeminiUsage(result.usageMetadata) - : undefined, + usage, } } catch (error) { logger.errors('gemini.structuredOutput fatal', { error, source: 'gemini.structuredOutput', }) - throw new Error( - error instanceof Error - ? error.message - : 'An unknown error occurred during structured output generation.', + // The provider already billed the tokens, so the caller still gets them. + throw Object.assign( + new Error( + error instanceof Error + ? error.message + : 'An unknown error occurred during structured output generation.', + ), + usage && { usage }, ) } } @@ -752,6 +766,7 @@ export class GeminiTextAdapter< runId: requestedChatOptions.runId ?? generateId(this.name), } const mappedOptions = this.mapCommonOptionsToGemini(chatOptions) + const seen: StreamUsageSeen = {} try { chatOptions.logger.request( @@ -776,6 +791,7 @@ export class GeminiTextAdapter< result, chatOptions, chatOptions.logger, + seen, )) { if (chunk.type === EventType.TEXT_MESSAGE_CONTENT) { rawText += chunk.delta @@ -794,6 +810,7 @@ export class GeminiTextAdapter< chatOptions, 'Gemini structured-output stream ended without a terminal event', 'truncated-stream', + buildGeminiUsage(seen.usageMetadata), ) return } @@ -802,6 +819,7 @@ export class GeminiTextAdapter< chatOptions, 'Gemini structured-output stream contained no content', 'empty-response', + finished.usage, ) return } @@ -814,6 +832,7 @@ export class GeminiTextAdapter< chatOptions, jsonContentParseError(rawText, 'Gemini structured-output stream'), 'parse-error', + finished.usage, ) return } @@ -837,7 +856,12 @@ export class GeminiTextAdapter< source: 'gemini.structuredOutputStream', }) yield { - ...structuredStreamError(chatOptions, message, 'provider-error'), + ...structuredStreamError( + chatOptions, + message, + 'provider-error', + buildGeminiUsage(seen.usageMetadata), + ), ...(rawEvent !== undefined && { rawEvent }), } } @@ -864,9 +888,12 @@ export class GeminiTextAdapter< result: AsyncGenerator, options: TextOptions, logger: InternalLogger, + seen: StreamUsageSeen = {}, ): AsyncIterable { const model = options.model let responseId: string | undefined + let blockReason: string | undefined + let sawFinishReason = false let reportedModel: string | undefined let accumulatedContent = '' const toolCallMap = new Map< @@ -963,6 +990,11 @@ export class GeminiTextAdapter< for await (const chunk of result) { if (chunk.responseId) responseId = chunk.responseId if (chunk.modelVersion) reportedModel = chunk.modelVersion + // usageMetadata is cumulative, so the last one is the billed total. + if (chunk.usageMetadata) seen.usageMetadata = chunk.usageMetadata + if (chunk.promptFeedback?.blockReason) { + blockReason = chunk.promptFeedback.blockReason + } logger.provider(`provider=gemini`, { chunk }) // Emit RUN_STARTED on first chunk if (!hasEmittedRunStarted) { @@ -1180,6 +1212,7 @@ export class GeminiTextAdapter< } if (chunk.candidates?.[0]?.finishReason) { + sawFinishReason = true yield* emitGroundingToolCall() const finishReason = chunk.candidates[0].finishReason @@ -1213,23 +1246,6 @@ export class GeminiTextAdapter< hasEmittedTextMessageStart = false } - if (finishReason === FinishReason.MAX_TOKENS) { - yield { - type: EventType.RUN_ERROR, - runId, - model, - timestamp: Date.now(), - message: - 'The response was cut off because the maximum token limit was reached.', - code: 'max_tokens', - error: { - message: - 'The response was cut off because the maximum token limit was reached.', - code: 'max_tokens', - }, - } - } - // Close reasoning events if still open if (reasoningMessageId && !hasClosedReasoning) { hasClosedReasoning = true @@ -1257,6 +1273,32 @@ export class GeminiTextAdapter< } } + if (finishReason === FinishReason.MAX_TOKENS) { + // RUN_ERROR ends the run (as in the Anthropic adapter), so it + // carries the usage and no RUN_FINISHED follows. + const usage = buildGeminiUsage(seen.usageMetadata) + seen.reported = true + yield { + type: EventType.RUN_ERROR, + runId, + model, + timestamp: Date.now(), + message: + 'The response was cut off because the maximum token limit was reached.', + code: 'max_tokens', + error: { + message: + 'The response was cut off because the maximum token limit was reached.', + code: 'max_tokens', + }, + ...(usage && { usage }), + } + continue + } + + // The last usageMetadata seen, in case the finish chunk has none. + const usage = buildGeminiUsage(seen.usageMetadata) + seen.reported = true yield { type: EventType.RUN_FINISHED, metadata: { @@ -1270,17 +1312,28 @@ export class GeminiTextAdapter< model, timestamp: Date.now(), finishReason: toolCallMap.size > 0 ? 'tool_calls' : 'stop', - // RunFinishedEvent.usage is `usage?: {...}` (no `| undefined`) under - // exactOptionalPropertyTypes; only include it when usageMetadata is - // present rather than assigning an explicit `undefined`. - ...(chunk.usageMetadata && { - usage: buildGeminiUsage(chunk.usageMetadata), - }), + ...(usage && { usage }), } } } yield* emitGroundingToolCall() + + // A blocked prompt gets no candidates, so no finishReason ends the run. + if (blockReason && !sawFinishReason) { + const message = `Gemini blocked the prompt (blockReason=${blockReason}).` + const usage = buildGeminiUsage(seen.usageMetadata) + yield { + type: EventType.RUN_ERROR, + runId, + model, + timestamp: Date.now(), + message, + code: blockReason, + error: { message, code: blockReason }, + ...(usage && { usage }), + } + } } private convertContentPartToGemini(part: ContentPart): Part { @@ -1749,10 +1802,21 @@ function jsonContentParseError(rawText: string, label: string) { return `Failed to parse ${label} as JSON. Content: ${snippet}${ellipsis}` } +/** The last usageMetadata a stream reported, read by the error paths. */ +interface StreamUsageSeen { + usageMetadata?: GenerateContentResponseUsageMetadata + /** A terminal event already carried the usage. */ + reported?: boolean +} + function structuredStreamError( options: TextOptions, message: string, code: string, + usage?: Extract< + AdapterYieldChunk, + { type: typeof EventType.RUN_ERROR } + >['usage'], ): Extract { return { type: EventType.RUN_ERROR, @@ -1762,6 +1826,7 @@ function structuredStreamError( message, code, error: { message, code }, + ...(usage && { usage }), } } diff --git a/packages/ai-gemini/src/adapters/tts.ts b/packages/ai-gemini/src/adapters/tts.ts index 856634b4cf..0d5a91b483 100644 --- a/packages/ai-gemini/src/adapters/tts.ts +++ b/packages/ai-gemini/src/adapters/tts.ts @@ -223,12 +223,22 @@ export class GeminiTTSAdapter< }, }) + // Surface token usage (with per-modality breakdown) when Gemini reports + // it. Spread conditionally for exactOptionalPropertyTypes. The no-audio + // errors carry it too, because the provider already billed the tokens. + const usageField = response.usageMetadata + ? { usage: buildGeminiUsage(response.usageMetadata) } + : {} + // Extract audio data from response const candidate = response.candidates?.[0] const parts = candidate?.content?.parts if (!parts || parts.length === 0) { - throw new Error('No audio output received from Gemini TTS') + throw Object.assign( + new Error('No audio output received from Gemini TTS'), + usageField, + ) } // Look for inline data (audio) @@ -237,20 +247,16 @@ export class GeminiTTSAdapter< ) if (!audioPart || !audioPart.inlineData || !audioPart.inlineData.data) { - throw new Error('No audio data in Gemini TTS response') + throw Object.assign( + new Error('No audio data in Gemini TTS response'), + usageField, + ) } const audioBase64 = audioPart.inlineData.data // mime is guaranteed by the `startsWith('audio/')` find predicate above. const mimeType = audioPart.inlineData.mimeType as string - // Surface token usage (with per-modality breakdown) when Gemini reports - // it. Spread conditionally for exactOptionalPropertyTypes — shared by both - // the PCM→WAV and pass-through return paths below. - const usageField = response.usageMetadata - ? { usage: buildGeminiUsage(response.usageMetadata) } - : {} - // Gemini TTS models return raw 16-bit LE PCM with a mime type like // `audio/L16;codec=pcm;rate=24000`. That isn't playable in an