Skip to content

Commit d69f3a9

Browse files
committed
fix(ai): forward token usage on the structured-output fallback path
`fallbackStructuredOutputStream` — used by `chat({ outputSchema, stream: true })` whenever an adapter resolves the schema through the non-streaming `structuredOutput()` rather than a native streaming or combined path (Ollama, plus Anthropic and Gemini models that predate combined tools+schema support) — wrapped `structuredOutput()` but dropped the `usage` from its result. Consumers reading `RUN_FINISHED.usage` saw `undefined`, and the engine's `runOnUsage` middleware hook (gated on `chunk.usage`) never fired, so cost-tracking and observability layers reported zero token counts on that path. The synthesized `RUN_FINISHED` now carries the adapter-reported `usage`, matching the native streaming path. Adapters that don't report usage are unaffected — the conditional spread omits the key entirely. Adds unit coverage in chat-structured-output-stream.test.ts (usage forwarded; omitted when absent) and an e2e regression (anthropic-structured-usage) that drives the Anthropic adapter through the fallback against an aimock mount and asserts usage reaches RUN_FINISHED.usage.
1 parent 2f2d8a9 commit d69f3a9

7 files changed

Lines changed: 303 additions & 4 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@tanstack/ai': patch
3+
---
4+
5+
fix: forward token usage on the structured-output fallback path
6+
7+
`fallbackStructuredOutputStream` — used by `chat({ outputSchema, stream: true })`
8+
whenever an adapter resolves the schema through the non-streaming
9+
`structuredOutput()` rather than a native streaming or combined path (in practice
10+
Ollama, plus Anthropic and Gemini models that predate combined tools+schema
11+
support) — wrapped `structuredOutput()` but dropped the `usage` from its result.
12+
Consumers reading `RUN_FINISHED.usage` saw `undefined`, and the engine's
13+
`runOnUsage` middleware hook (gated on `chunk.usage`) never fired, so
14+
cost-tracking and observability layers reported zero token counts on that path.
15+
16+
The synthesized `RUN_FINISHED` now carries the adapter-reported `usage`, matching
17+
the native streaming path. Adapters that don't report usage are unaffected (no
18+
`usage` key is emitted).

packages/ai/src/activities/chat/index.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,11 @@ import type {
3333
ClientToolRequest,
3434
ToolResult,
3535
} from './tools/tool-calls'
36-
import type { AnyTextAdapter, StructuredOutputOptions } from './adapter'
36+
import type {
37+
AnyTextAdapter,
38+
StructuredOutputOptions,
39+
StructuredOutputResult,
40+
} from './adapter'
3741
import type {
3842
AgentLoopStrategy,
3943
AnyTool,
@@ -2861,7 +2865,7 @@ async function* fallbackStructuredOutputStream(
28612865
timestamp,
28622866
}
28632867

2864-
let result: { data: unknown; rawText: string }
2868+
let result: StructuredOutputResult<unknown>
28652869
try {
28662870
result = await adapter.structuredOutput(options)
28672871
} catch (error) {
@@ -2917,6 +2921,12 @@ async function* fallbackStructuredOutputStream(
29172921
model,
29182922
timestamp,
29192923
finishReason: 'stop',
2924+
// Forward adapter-reported token usage so consumers reading
2925+
// `RUN_FINISHED.usage` (and the engine's `runOnUsage` middleware hook) see
2926+
// it on the fallback path, mirroring the native streaming path. The
2927+
// conditional spread avoids emitting `usage: undefined` for adapters that
2928+
// don't report it. See #758.
2929+
...(result.usage ? { usage: result.usage } : {}),
29202930
}
29212931
}
29222932

packages/ai/tests/chat-structured-output-stream.test.ts

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { describe, expect, it } from 'vitest'
1818
import { z } from 'zod'
1919
import { chat } from '../src/activities/chat/index'
2020
import { EventType } from '../src/types'
21-
import type { StreamChunk } from '../src/types'
21+
import type { RunFinishedEvent, StreamChunk, TokenUsage } from '../src/types'
2222
import type { AnyTextAdapter } from '../src/activities/chat/adapter'
2323
import { collectChunks } from './test-utils'
2424

@@ -42,7 +42,9 @@ const validPerson: Person = {
4242
*/
4343
function makeAdapter(opts: {
4444
structuredOutputStream?: (o: unknown) => AsyncIterable<StreamChunk>
45-
structuredOutput?: (o: unknown) => Promise<{ data: unknown; rawText: string }>
45+
structuredOutput?: (
46+
o: unknown,
47+
) => Promise<{ data: unknown; rawText: string; usage?: TokenUsage }>
4648
}): AnyTextAdapter {
4749
return {
4850
kind: 'text' as const,
@@ -375,6 +377,72 @@ describe('chat({ outputSchema, stream: true })', () => {
375377
expect(complete).toBeDefined()
376378
expect(complete!.value.object).toEqual(invalidObject)
377379
})
380+
381+
it('forwards adapter-reported usage onto RUN_FINISHED (#758)', async () => {
382+
// Regression for #758: the fallback wraps the non-streaming
383+
// `structuredOutput`, whose `{ data, rawText, usage }` result includes
384+
// token usage. Before the fix the synthesized RUN_FINISHED dropped
385+
// `usage`, so consumers (and the `runOnUsage` middleware hook) saw
386+
// `undefined` on every fallback-path provider (Anthropic, Gemini, Ollama).
387+
const usage: TokenUsage = {
388+
promptTokens: 125,
389+
completionTokens: 1346,
390+
totalTokens: 1471,
391+
promptTokensDetails: { cachedTokens: 5760 },
392+
}
393+
const adapter = makeAdapter({
394+
structuredOutput: async () => ({
395+
data: validPerson,
396+
rawText: JSON.stringify(validPerson),
397+
usage,
398+
}),
399+
})
400+
401+
const stream = chat({
402+
adapter,
403+
messages: [{ role: 'user', content: 'extract' }],
404+
outputSchema: PersonSchema,
405+
stream: true,
406+
})
407+
408+
const chunks = await collectChunks(
409+
stream as unknown as AsyncIterable<StreamChunk>,
410+
)
411+
412+
const finished = chunks.find(
413+
(c) => c.type === EventType.RUN_FINISHED,
414+
) as RunFinishedEvent | undefined
415+
expect(finished).toBeDefined()
416+
expect(finished!.usage).toEqual(usage)
417+
})
418+
419+
it('omits usage on RUN_FINISHED when the adapter does not report it', async () => {
420+
// The conditional spread must not synthesize `usage: undefined` for
421+
// adapters whose `structuredOutput` returns no usage.
422+
const adapter = makeAdapter({
423+
structuredOutput: async () => ({
424+
data: validPerson,
425+
rawText: JSON.stringify(validPerson),
426+
}),
427+
})
428+
429+
const stream = chat({
430+
adapter,
431+
messages: [{ role: 'user', content: 'extract' }],
432+
outputSchema: PersonSchema,
433+
stream: true,
434+
})
435+
436+
const chunks = await collectChunks(
437+
stream as unknown as AsyncIterable<StreamChunk>,
438+
)
439+
440+
const finished = chunks.find(
441+
(c) => c.type === EventType.RUN_FINISHED,
442+
) as RunFinishedEvent | undefined
443+
expect(finished).toBeDefined()
444+
expect('usage' in finished!).toBe(false)
445+
})
378446
})
379447

380448
describe('lifecycle ordering', () => {

testing/e2e/global-setup.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,16 @@ export default async function globalSetup() {
7373
// `promptTokensDetails.cachedTokens` / `completionTokensDetails.reasoningTokens`.
7474
mock.mount('/openai-usage-details', openaiUsageDetailsMount())
7575

76+
// Anthropic structured-output fallback usage (#758). The Anthropic text
77+
// adapter has no native `structuredOutputStream`, so streaming structured
78+
// output runs through the activity layer's `fallbackStructuredOutputStream`,
79+
// which wraps the non-streaming `structuredOutput()`. aimock's native
80+
// Anthropic helper doesn't synthesize a tool-forced `structured_output`
81+
// response with usage, so this mount hand-crafts the non-streaming
82+
// `/v1/messages` JSON the adapter expects. The companion spec asserts the
83+
// `usage` survives onto `RUN_FINISHED.usage` on the fallback path.
84+
mock.mount('/anthropic-structured-usage', anthropicStructuredUsageMount())
85+
7686
await mock.start()
7787
console.log(`[aimock] started on port 4010`)
7888
;(globalThis as any).__aimock = mock
@@ -547,6 +557,60 @@ function openaiUsageDetailsMount(): Mountable {
547557
}
548558
}
549559

560+
/**
561+
* Mounts the non-streaming Anthropic `/v1/messages` response the text adapter's
562+
* `structuredOutput()` expects: a tool-forced `structured_output` `tool_use`
563+
* block plus a `usage` object carrying `input_tokens` / `output_tokens` /
564+
* `cache_read_input_tokens`. `buildAnthropicUsage` normalizes those into
565+
* `promptTokens` / `completionTokens` / `promptTokensDetails.cachedTokens`.
566+
* Drives the #758 fallback-path usage regression.
567+
*/
568+
function anthropicStructuredUsageMount(): Mountable {
569+
return {
570+
async handleRequest(
571+
req: http.IncomingMessage,
572+
res: http.ServerResponse,
573+
// The mount prefix (/anthropic-structured-usage) is stripped before
574+
// dispatch; the Anthropic SDK posts to <baseURL>/v1/messages and aimock
575+
// strips the ?beta=... query string from `pathname`.
576+
pathname: string,
577+
): Promise<boolean> {
578+
if (req.method !== 'POST' || !pathname.startsWith('/v1/messages')) {
579+
return false
580+
}
581+
// structuredOutput() makes a non-streaming request (stream: false), so
582+
// respond with a single JSON message rather than an SSE stream.
583+
await drainBody(req)
584+
res.statusCode = 200
585+
res.setHeader('Content-Type', 'application/json')
586+
res.end(
587+
JSON.stringify({
588+
id: 'msg_structured_usage_e2e',
589+
type: 'message',
590+
role: 'assistant',
591+
model: 'claude-opus-4-1',
592+
content: [
593+
{
594+
type: 'tool_use',
595+
id: 'toolu_structured_output',
596+
name: 'structured_output',
597+
input: { recommendation: 'Fender Stratocaster', price: 1299 },
598+
},
599+
],
600+
stop_reason: 'tool_use',
601+
stop_sequence: null,
602+
usage: {
603+
input_tokens: 125,
604+
output_tokens: 1346,
605+
cache_read_input_tokens: 5760,
606+
},
607+
}),
608+
)
609+
return true
610+
},
611+
}
612+
}
613+
550614
function buildToolPlusServerToolEvents(): Array<Record<string, unknown>> {
551615
const messageId = 'msg_bug_604'
552616
const model = 'claude-sonnet-4-5'

testing/e2e/src/routeTree.gen.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import { Route as ApiImageRouteImport } from './routes/api.image'
4343
import { Route as ApiChatRouteImport } from './routes/api.chat'
4444
import { Route as ApiAudioRouteImport } from './routes/api.audio'
4545
import { Route as ApiArktypeToolWireRouteImport } from './routes/api.arktype-tool-wire'
46+
import { Route as ApiAnthropicStructuredUsageRouteImport } from './routes/api.anthropic-structured-usage'
4647
import { Route as ApiAnthropicSkillsWireRouteImport } from './routes/api.anthropic-skills-wire'
4748
import { Route as ApiAnthropicBugTestRouteImport } from './routes/api.anthropic-bug-test'
4849
import { Route as ProviderFeatureRouteImport } from './routes/$provider/$feature'
@@ -226,6 +227,12 @@ const ApiArktypeToolWireRoute = ApiArktypeToolWireRouteImport.update({
226227
path: '/api/arktype-tool-wire',
227228
getParentRoute: () => rootRouteImport,
228229
} as any)
230+
const ApiAnthropicStructuredUsageRoute =
231+
ApiAnthropicStructuredUsageRouteImport.update({
232+
id: '/api/anthropic-structured-usage',
233+
path: '/api/anthropic-structured-usage',
234+
getParentRoute: () => rootRouteImport,
235+
} as any)
229236
const ApiAnthropicSkillsWireRoute = ApiAnthropicSkillsWireRouteImport.update({
230237
id: '/api/anthropic-skills-wire',
231238
path: '/api/anthropic-skills-wire',
@@ -282,6 +289,7 @@ export interface FileRoutesByFullPath {
282289
'/$provider/$feature': typeof ProviderFeatureRoute
283290
'/api/anthropic-bug-test': typeof ApiAnthropicBugTestRoute
284291
'/api/anthropic-skills-wire': typeof ApiAnthropicSkillsWireRoute
292+
'/api/anthropic-structured-usage': typeof ApiAnthropicStructuredUsageRoute
285293
'/api/arktype-tool-wire': typeof ApiArktypeToolWireRoute
286294
'/api/audio': typeof ApiAudioRouteWithChildren
287295
'/api/chat': typeof ApiChatRoute
@@ -326,6 +334,7 @@ export interface FileRoutesByTo {
326334
'/$provider/$feature': typeof ProviderFeatureRoute
327335
'/api/anthropic-bug-test': typeof ApiAnthropicBugTestRoute
328336
'/api/anthropic-skills-wire': typeof ApiAnthropicSkillsWireRoute
337+
'/api/anthropic-structured-usage': typeof ApiAnthropicStructuredUsageRoute
329338
'/api/arktype-tool-wire': typeof ApiArktypeToolWireRoute
330339
'/api/audio': typeof ApiAudioRouteWithChildren
331340
'/api/chat': typeof ApiChatRoute
@@ -371,6 +380,7 @@ export interface FileRoutesById {
371380
'/$provider/$feature': typeof ProviderFeatureRoute
372381
'/api/anthropic-bug-test': typeof ApiAnthropicBugTestRoute
373382
'/api/anthropic-skills-wire': typeof ApiAnthropicSkillsWireRoute
383+
'/api/anthropic-structured-usage': typeof ApiAnthropicStructuredUsageRoute
374384
'/api/arktype-tool-wire': typeof ApiArktypeToolWireRoute
375385
'/api/audio': typeof ApiAudioRouteWithChildren
376386
'/api/chat': typeof ApiChatRoute
@@ -417,6 +427,7 @@ export interface FileRouteTypes {
417427
| '/$provider/$feature'
418428
| '/api/anthropic-bug-test'
419429
| '/api/anthropic-skills-wire'
430+
| '/api/anthropic-structured-usage'
420431
| '/api/arktype-tool-wire'
421432
| '/api/audio'
422433
| '/api/chat'
@@ -461,6 +472,7 @@ export interface FileRouteTypes {
461472
| '/$provider/$feature'
462473
| '/api/anthropic-bug-test'
463474
| '/api/anthropic-skills-wire'
475+
| '/api/anthropic-structured-usage'
464476
| '/api/arktype-tool-wire'
465477
| '/api/audio'
466478
| '/api/chat'
@@ -505,6 +517,7 @@ export interface FileRouteTypes {
505517
| '/$provider/$feature'
506518
| '/api/anthropic-bug-test'
507519
| '/api/anthropic-skills-wire'
520+
| '/api/anthropic-structured-usage'
508521
| '/api/arktype-tool-wire'
509522
| '/api/audio'
510523
| '/api/chat'
@@ -550,6 +563,7 @@ export interface RootRouteChildren {
550563
ProviderFeatureRoute: typeof ProviderFeatureRoute
551564
ApiAnthropicBugTestRoute: typeof ApiAnthropicBugTestRoute
552565
ApiAnthropicSkillsWireRoute: typeof ApiAnthropicSkillsWireRoute
566+
ApiAnthropicStructuredUsageRoute: typeof ApiAnthropicStructuredUsageRoute
553567
ApiArktypeToolWireRoute: typeof ApiArktypeToolWireRoute
554568
ApiAudioRoute: typeof ApiAudioRouteWithChildren
555569
ApiChatRoute: typeof ApiChatRoute
@@ -815,6 +829,13 @@ declare module '@tanstack/react-router' {
815829
preLoaderRoute: typeof ApiArktypeToolWireRouteImport
816830
parentRoute: typeof rootRouteImport
817831
}
832+
'/api/anthropic-structured-usage': {
833+
id: '/api/anthropic-structured-usage'
834+
path: '/api/anthropic-structured-usage'
835+
fullPath: '/api/anthropic-structured-usage'
836+
preLoaderRoute: typeof ApiAnthropicStructuredUsageRouteImport
837+
parentRoute: typeof rootRouteImport
838+
}
818839
'/api/anthropic-skills-wire': {
819840
id: '/api/anthropic-skills-wire'
820841
path: '/api/anthropic-skills-wire'
@@ -947,6 +968,7 @@ const rootRouteChildren: RootRouteChildren = {
947968
ProviderFeatureRoute: ProviderFeatureRoute,
948969
ApiAnthropicBugTestRoute: ApiAnthropicBugTestRoute,
949970
ApiAnthropicSkillsWireRoute: ApiAnthropicSkillsWireRoute,
971+
ApiAnthropicStructuredUsageRoute: ApiAnthropicStructuredUsageRoute,
950972
ApiArktypeToolWireRoute: ApiArktypeToolWireRoute,
951973
ApiAudioRoute: ApiAudioRouteWithChildren,
952974
ApiChatRoute: ApiChatRoute,
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { createFileRoute } from '@tanstack/react-router'
2+
import { chat, createChatOptions } from '@tanstack/ai'
3+
import { createAnthropicChat } from '@tanstack/ai-anthropic'
4+
import { z } from 'zod'
5+
6+
const LLMOCK_DEFAULT_BASE = process.env.LLMOCK_URL || 'http://127.0.0.1:4010'
7+
const DUMMY_KEY = 'sk-e2e-test-dummy-key'
8+
9+
/**
10+
* Drives the Anthropic text adapter (`AnthropicTextAdapter`) through the
11+
* streaming structured-output path. That adapter has no native
12+
* `structuredOutputStream`, so `chat({ outputSchema, stream: true })` routes
13+
* through the activity layer's `fallbackStructuredOutputStream`, which wraps the
14+
* non-streaming `structuredOutput()`. The mounted `/anthropic-structured-usage`
15+
* aimock path returns a tool-forced `structured_output` response whose `usage`
16+
* carries `input_tokens` / `output_tokens` / `cache_read_input_tokens`.
17+
*
18+
* Regression for #758: before the fix the fallback dropped `result.usage`, so
19+
* `RUN_FINISHED.usage` was `undefined` on every fallback-path provider. The
20+
* companion spec asserts the usage now reaches `RUN_FINISHED.usage`.
21+
*/
22+
export const Route = createFileRoute('/api/anthropic-structured-usage')({
23+
server: {
24+
handlers: {
25+
POST: async () => {
26+
// `claude-opus-4-1` is intentionally a *pre-4.5* model: it is NOT in
27+
// `ANTHROPIC_COMBINED_TOOLS_AND_SCHEMA_MODELS`, so
28+
// `supportsCombinedToolsAndSchema()` is false and the engine routes
29+
// `chat({ outputSchema, stream: true })` through the non-streaming
30+
// `structuredOutput()` wrapped by `fallbackStructuredOutputStream` —
31+
// exactly the path #758 fixes. A 4.5+ model would use the native
32+
// combined path and never touch the fallback.
33+
const adapter = createAnthropicChat('claude-opus-4-1', DUMMY_KEY, {
34+
baseURL: `${LLMOCK_DEFAULT_BASE}/anthropic-structured-usage`,
35+
})
36+
37+
const options = createChatOptions({
38+
adapter,
39+
outputSchema: z.object({
40+
recommendation: z.string(),
41+
price: z.number(),
42+
}),
43+
stream: true,
44+
})
45+
46+
let usage: Record<string, unknown> | undefined
47+
try {
48+
for await (const chunk of chat({
49+
...options,
50+
messages: [{ role: 'user', content: 'recommend a guitar as json' }],
51+
})) {
52+
if (chunk.type === 'RUN_FINISHED') {
53+
usage = chunk.usage as Record<string, unknown> | undefined
54+
}
55+
}
56+
} catch (error) {
57+
return new Response(
58+
JSON.stringify({
59+
ok: false,
60+
error: error instanceof Error ? error.message : String(error),
61+
}),
62+
{ status: 200, headers: { 'Content-Type': 'application/json' } },
63+
)
64+
}
65+
66+
return new Response(JSON.stringify({ ok: true, usage }), {
67+
status: 200,
68+
headers: { 'Content-Type': 'application/json' },
69+
})
70+
},
71+
},
72+
},
73+
})

0 commit comments

Comments
 (0)