diff --git a/.changeset/structured-output-finalization-usage.md b/.changeset/structured-output-finalization-usage.md new file mode 100644 index 000000000..10a8ab58b --- /dev/null +++ b/.changeset/structured-output-finalization-usage.md @@ -0,0 +1,11 @@ +--- +'@tanstack/ai': patch +'@tanstack/ai-anthropic': patch +'@tanstack/ai-gemini': patch +'@tanstack/ai-ollama': patch +'@tanstack/ai-openrouter': patch +--- + +Adapters now report token `usage` from the non-streaming `structuredOutput()` call, and `fallbackStructuredOutputStream` forwards it onto the synthesized `RUN_FINISHED` event. Previously the legacy finalization round-trip was invisible to the chat middleware `onUsage` hook — any cost-tracking middleware silently under-counted by exactly one call whenever an adapter without a native `structuredOutputStream` (Anthropic, Gemini, Ollama, OpenRouter) ran agentic structured output through the legacy path. + +`StructuredOutputResult` gains an optional `usage: { promptTokens, completionTokens, totalTokens }` field. Adapters without a token count on the wire (or that fail before usage is known) leave it `undefined`, which the engine treats as "no usage to report" — same as before. No consumer-visible behavior change beyond accurate `onUsage` totals. diff --git a/packages/typescript/ai-anthropic/src/adapters/text.ts b/packages/typescript/ai-anthropic/src/adapters/text.ts index f6b5b9d2d..0847e91a0 100644 --- a/packages/typescript/ai-anthropic/src/adapters/text.ts +++ b/packages/typescript/ai-anthropic/src/adapters/text.ts @@ -276,9 +276,16 @@ export class AnthropicTextAdapter< } } + const inputTokens = response.usage?.input_tokens ?? 0 + const outputTokens = response.usage?.output_tokens ?? 0 return { data: parsed, rawText, + usage: { + promptTokens: inputTokens, + completionTokens: outputTokens, + totalTokens: inputTokens + outputTokens, + }, } } catch (error: unknown) { const err = error as Error diff --git a/packages/typescript/ai-gemini/src/adapters/text.ts b/packages/typescript/ai-gemini/src/adapters/text.ts index 8f9ee636a..179ec983b 100644 --- a/packages/typescript/ai-gemini/src/adapters/text.ts +++ b/packages/typescript/ai-gemini/src/adapters/text.ts @@ -196,9 +196,17 @@ export class GeminiTextAdapter< ) } + const usageMetadata = result.usageMetadata return { data: parsed, rawText, + ...(usageMetadata && { + usage: { + promptTokens: usageMetadata.promptTokenCount ?? 0, + completionTokens: usageMetadata.candidatesTokenCount ?? 0, + totalTokens: usageMetadata.totalTokenCount ?? 0, + }, + }), } } catch (error) { logger.errors('gemini.structuredOutput fatal', { diff --git a/packages/typescript/ai-ollama/src/adapters/text.ts b/packages/typescript/ai-ollama/src/adapters/text.ts index 16aa0f1ed..4a9afbb49 100644 --- a/packages/typescript/ai-ollama/src/adapters/text.ts +++ b/packages/typescript/ai-ollama/src/adapters/text.ts @@ -201,9 +201,16 @@ export class OllamaTextAdapter extends BaseTextAdapter< ) } + const promptTokens = response.prompt_eval_count ?? 0 + const completionTokens = response.eval_count ?? 0 return { data: parsed, rawText, + usage: { + promptTokens, + completionTokens, + totalTokens: promptTokens + completionTokens, + }, } } catch (error: unknown) { const err = error as Error diff --git a/packages/typescript/ai-openrouter/src/adapters/text.ts b/packages/typescript/ai-openrouter/src/adapters/text.ts index c48394953..a98e012ec 100644 --- a/packages/typescript/ai-openrouter/src/adapters/text.ts +++ b/packages/typescript/ai-openrouter/src/adapters/text.ts @@ -260,9 +260,17 @@ export class OpenRouterTextAdapter< // this). const transformed = this.transformStructuredOutput(parsed) + const usage = response.usage return { data: transformed, rawText, + ...(usage && { + usage: { + promptTokens: usage.promptTokens, + completionTokens: usage.completionTokens, + totalTokens: usage.totalTokens, + }, + }), } } catch (error: unknown) { // Narrow before logging: raw SDK errors can carry request metadata diff --git a/packages/typescript/ai/src/activities/chat/adapter.ts b/packages/typescript/ai/src/activities/chat/adapter.ts index 648e7e6bf..5facc7d43 100644 --- a/packages/typescript/ai/src/activities/chat/adapter.ts +++ b/packages/typescript/ai/src/activities/chat/adapter.ts @@ -40,6 +40,17 @@ export interface StructuredOutputResult { data: T /** The raw text response from the model before parsing */ rawText: string + /** + * Token usage reported by the provider for this call, when available. + * Forwarded by `fallbackStructuredOutputStream` onto the synthesized + * RUN_FINISHED so middleware `onUsage` hooks can account for the + * finalization round-trip. + */ + usage?: { + promptTokens: number + completionTokens: number + totalTokens: number + } } /** diff --git a/packages/typescript/ai/src/activities/chat/index.ts b/packages/typescript/ai/src/activities/chat/index.ts index 26e3153fc..7d7f670c0 100644 --- a/packages/typescript/ai/src/activities/chat/index.ts +++ b/packages/typescript/ai/src/activities/chat/index.ts @@ -2630,7 +2630,15 @@ async function* fallbackStructuredOutputStream( timestamp, } - let result: { data: unknown; rawText: string } + let result: { + data: unknown + rawText: string + usage?: { + promptTokens: number + completionTokens: number + totalTokens: number + } + } try { result = await adapter.structuredOutput(options) } catch (error) { @@ -2686,6 +2694,7 @@ async function* fallbackStructuredOutputStream( model, timestamp, finishReason: 'stop', + ...(result.usage ? { usage: result.usage } : {}), } }