Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .changeset/usage-on-every-path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
'@tanstack/ai': patch
'@tanstack/ai-event-client': 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, and `text:usage` for a chat `RUN_ERROR` with usage. A call that `onModelError` or a rate-limit retry drops still reports its usage.
- 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.
- Gemini Interactions usage (text, agentic video, and images) counts thought tokens in `completionTokens`, the same as `generateContent`.
2 changes: 1 addition & 1 deletion docs/adapters/anthropic-advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
8 changes: 4 additions & 4 deletions docs/adapters/openrouter-advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
33 changes: 30 additions & 3 deletions docs/advanced/middleware-advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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";
Expand All @@ -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:
Expand Down Expand Up @@ -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:

Expand Down
7 changes: 4 additions & 3 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -1699,7 +1699,7 @@
"label": "Anthropic: Advanced",
"to": "adapters/anthropic-advanced",
"addedAt": "2026-10-21",
"updatedAt": "2026-10-22"
"updatedAt": "2026-11-02"
},
{
"label": "Google Gemini",
Expand Down Expand Up @@ -1794,7 +1794,8 @@
{
"label": "OpenRouter: Advanced",
"to": "adapters/openrouter-advanced",
"addedAt": "2026-10-21"
"addedAt": "2026-10-21",
"updatedAt": "2026-11-02"
},
{
"label": "Perplexity Search",
Expand Down
15 changes: 13 additions & 2 deletions packages/ai-acp/src/adapters/compatible.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import type {
Modality,
ModelMessage,
AdapterYieldChunk,
RunFinishedEvent,
TextOptions,
} from '@tanstack/ai'
import type { AcpSessionHandle } from '../session/acp-client'
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -740,7 +747,8 @@ export class AcpCompatibleTextAdapter<
threadId: string,
runId: string,
messageId = this.generateId(),
): Generator<AdapterYieldChunk> {
usage?: RunFinishedEvent['usage'],
): Generator<AdapterYieldChunk, boolean> {
try {
const object = parseJsonFromAssistantText(raw)
yield structuredOutputStartChunk({
Expand All @@ -757,6 +765,7 @@ export class AcpCompatibleTextAdapter<
object,
raw,
})
return false
} catch (error: unknown) {
const parserMessage =
error instanceof Error
Expand All @@ -772,7 +781,9 @@ export class AcpCompatibleTextAdapter<
message,
code: 'structured-output-parse-failed',
error: { message, code: 'structured-output-parse-failed' },
...(usage && { usage }),
}
return true
}
}

Expand Down
30 changes: 26 additions & 4 deletions packages/ai-acp/src/stream/translate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -110,6 +117,7 @@ export async function* translateAcpStream(
ctx: TranslateContext,
): AsyncIterable<AdapterYieldChunk> {
const { model, runId, threadId, genId, labels } = ctx
let sessionCost: { amount: number; currency: string } | undefined
const now = () => Date.now()

let runStarted = false
Expand Down Expand Up @@ -238,7 +246,9 @@ export async function* translateAcpStream(
function* handleUpdate(
update: AcpSessionUpdate,
): Generator<AdapterYieldChunk> {
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.
Expand Down Expand Up @@ -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.'
Expand All @@ -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'
Expand Down
7 changes: 7 additions & 0 deletions packages/ai-acp/src/types/acp-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -53,6 +59,7 @@ export interface AcpUsage {
outputTokens?: number | null
totalTokens?: number | null
cachedReadTokens?: number | null
cachedWriteTokens?: number | null
thoughtTokens?: number | null
}

Expand Down
41 changes: 40 additions & 1 deletion packages/ai-acp/tests/compatible.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
})
})
Loading
Loading