From d8364494a3bb9bc8ddf9e87b74c0810b066b1459 Mon Sep 17 00:00:00 2001 From: kolaworld Date: Mon, 17 Aug 2026 16:49:41 -0400 Subject: [PATCH 1/3] fix(ai): preserve structured-output timestamp order --- .../fix-structured-output-timestamps.md | 9 ++ docs/reference/interfaces/TextAdapter.md | 3 +- .../ai-bedrock/src/adapters/converse-text.ts | 23 +++-- packages/ai-byteplus/src/adapters/text.ts | 5 +- .../src/adapters/responses-text.ts | 42 +++++---- packages/ai-openrouter/src/adapters/text.ts | 36 ++++---- packages/ai/src/activities/chat/adapter.ts | 3 +- packages/ai/src/activities/chat/index.ts | 26 +++--- .../chat-structured-output-stream.test.ts | 87 ++++++++++++++++++- .../src/adapters/chat-completions-text.ts | 36 ++++---- .../src/adapters/responses-text.ts | 40 ++++----- ...mpletions-structured-output-stream.test.ts | 36 ++++++++ ...responses-structured-output-stream.test.ts | 37 ++++++++ .../routes/api.anthropic-structured-usage.ts | 31 ++++++- .../tests/anthropic-structured-usage.spec.ts | 29 +++++-- 15 files changed, 320 insertions(+), 123 deletions(-) create mode 100644 .changeset/fix-structured-output-timestamps.md diff --git a/.changeset/fix-structured-output-timestamps.md b/.changeset/fix-structured-output-timestamps.md new file mode 100644 index 0000000000..749508495a --- /dev/null +++ b/.changeset/fix-structured-output-timestamps.md @@ -0,0 +1,9 @@ +--- +'@tanstack/ai': patch +'@tanstack/ai-bedrock': patch +'@tanstack/ai-byteplus': patch +'@tanstack/ai-openrouter': patch +'@tanstack/openai-base': patch +--- + +Timestamp native and fallback structured-output events when they are emitted so their lifecycle remains chronologically ordered. diff --git a/docs/reference/interfaces/TextAdapter.md b/docs/reference/interfaces/TextAdapter.md index 0b8fb4c6e7..1bf52ccab0 100644 --- a/docs/reference/interfaces/TextAdapter.md +++ b/docs/reference/interfaces/TextAdapter.md @@ -226,7 +226,8 @@ activity layer synthesizes a stream around the non-streaming Implementations must emit standard AG-UI lifecycle events (RUN_STARTED, TEXT_MESSAGE_*, RUN_FINISHED) carrying raw JSON text deltas, plus a final `CUSTOM` event named `structured-output.complete` whose `value` is -`{ object, raw, reasoning? }`. +`{ object, raw, reasoning? }`. Events must be timestamped when emitted so +their timestamps follow stream order. #### Parameters diff --git a/packages/ai-bedrock/src/adapters/converse-text.ts b/packages/ai-bedrock/src/adapters/converse-text.ts index 2c629582de..eaa9059b70 100644 --- a/packages/ai-bedrock/src/adapters/converse-text.ts +++ b/packages/ai-bedrock/src/adapters/converse-text.ts @@ -293,7 +293,6 @@ export class BedrockConverseTextAdapter< options: StructuredOutputOptions, ): AsyncIterable { const { chatOptions, outputSchema } = options - const timestamp = Date.now() const runId = this.generateId() const threadId = chatOptions.threadId ?? this.generateId() const messageId = this.generateId() @@ -326,7 +325,7 @@ export class BedrockConverseTextAdapter< runId, threadId, model: chatOptions.model, - timestamp, + timestamp: Date.now(), parentRunId: chatOptions.parentRunId, } } @@ -347,7 +346,7 @@ export class BedrockConverseTextAdapter< messageId, role: 'assistant', model: chatOptions.model, - timestamp, + timestamp: Date.now(), } } accumulatedRaw += fragment @@ -357,7 +356,7 @@ export class BedrockConverseTextAdapter< delta: fragment, content: accumulatedRaw, model: chatOptions.model, - timestamp, + timestamp: Date.now(), } } continue @@ -385,7 +384,7 @@ export class BedrockConverseTextAdapter< runId, threadId, model: chatOptions.model, - timestamp, + timestamp: Date.now(), parentRunId: chatOptions.parentRunId, } } @@ -395,7 +394,7 @@ export class BedrockConverseTextAdapter< type: EventType.TEXT_MESSAGE_END, messageId, model: chatOptions.model, - timestamp, + timestamp: Date.now(), } } @@ -404,7 +403,7 @@ export class BedrockConverseTextAdapter< type: EventType.RUN_ERROR, runId, model: chatOptions.model, - timestamp, + timestamp: Date.now(), message: `${this.name}.structuredOutputStream: response contained no content`, code: 'empty-response', error: { @@ -423,7 +422,7 @@ export class BedrockConverseTextAdapter< type: EventType.RUN_ERROR, runId, model: chatOptions.model, - timestamp, + timestamp: Date.now(), message: `Failed to parse structured output as JSON. Content: ${accumulatedRaw.slice(0, 200)}${accumulatedRaw.length > 200 ? '...' : ''}`, code: 'parse-error', error: { @@ -442,7 +441,7 @@ export class BedrockConverseTextAdapter< raw: accumulatedRaw, }, model: chatOptions.model, - timestamp, + timestamp: Date.now(), } yield { @@ -450,7 +449,7 @@ export class BedrockConverseTextAdapter< runId, threadId, model: chatOptions.model, - timestamp, + timestamp: Date.now(), finishReason, } } catch (error: unknown) { @@ -461,7 +460,7 @@ export class BedrockConverseTextAdapter< runId, threadId, model: chatOptions.model, - timestamp, + timestamp: Date.now(), parentRunId: chatOptions.parentRunId, } } @@ -477,7 +476,7 @@ export class BedrockConverseTextAdapter< type: EventType.RUN_ERROR, runId, model: chatOptions.model, - timestamp, + timestamp: Date.now(), message: errorPayload.message, ...(errorPayload.code !== undefined && { code: errorPayload.code }), error: { diff --git a/packages/ai-byteplus/src/adapters/text.ts b/packages/ai-byteplus/src/adapters/text.ts index d9d53684ec..fb466927c0 100644 --- a/packages/ai-byteplus/src/adapters/text.ts +++ b/packages/ai-byteplus/src/adapters/text.ts @@ -334,21 +334,20 @@ export class BytePlusTextAdapter< // Mirror the base's contract: failures inside structuredOutputStream // surface as a RUN_STARTED → RUN_ERROR pair rather than a throw, so // consumers keep a single error-handling path. - const timestamp = Date.now() const runId = generateId(this.name) yield { type: EventType.RUN_STARTED, runId, threadId: options.chatOptions.threadId ?? generateId(this.name), model: options.chatOptions.model, - timestamp, + timestamp: Date.now(), parentRunId: options.chatOptions.parentRunId, } yield { type: EventType.RUN_ERROR, runId, model: options.chatOptions.model, - timestamp, + timestamp: Date.now(), message: unsupported, code: 'unsupported-structured-output', error: { message: unsupported, code: 'unsupported-structured-output' }, diff --git a/packages/ai-openrouter/src/adapters/responses-text.ts b/packages/ai-openrouter/src/adapters/responses-text.ts index 828b941be6..35538ef762 100644 --- a/packages/ai-openrouter/src/adapters/responses-text.ts +++ b/packages/ai-openrouter/src/adapters/responses-text.ts @@ -321,12 +321,10 @@ export class OpenRouterResponsesTextAdapter< outputSchema.required, ) - const timestamp = Date.now() const aguiState = { runId: generateId(this.name), threadId: chatOptions.threadId ?? generateId(this.name), messageId: generateId(this.name), - timestamp, hasEmittedRunStarted: false, } @@ -354,13 +352,13 @@ export class OpenRouterResponsesTextAdapter< type: EventType.REASONING_MESSAGE_END, messageId: reasoningMessageId, model, - timestamp, + timestamp: Date.now(), } yield { type: EventType.REASONING_END, messageId: reasoningMessageId, model, - timestamp, + timestamp: Date.now(), } if (stepId) { yield { @@ -368,7 +366,7 @@ export class OpenRouterResponsesTextAdapter< stepName: stepId, stepId, model, - timestamp, + timestamp: Date.now(), content: accumulatedReasoning, } } @@ -385,21 +383,21 @@ export class OpenRouterResponsesTextAdapter< type: EventType.REASONING_START, messageId: reasoningMessageId, model, - timestamp, + timestamp: Date.now(), } yield { type: EventType.REASONING_MESSAGE_START, messageId: reasoningMessageId, role: 'reasoning' as const, model, - timestamp, + timestamp: Date.now(), } yield { type: EventType.STEP_STARTED, stepName: stepId, stepId, model, - timestamp, + timestamp: Date.now(), stepType: 'thinking', } }.bind(this) @@ -446,7 +444,7 @@ export class OpenRouterResponsesTextAdapter< runId: aguiState.runId, threadId: aguiState.threadId, model, - timestamp, + timestamp: Date.now(), parentRunId: chatOptions.parentRunId, } } @@ -465,7 +463,7 @@ export class OpenRouterResponsesTextAdapter< type: EventType.RUN_ERROR, runId: aguiState.runId, model, - timestamp, + timestamp: Date.now(), message: `Model refused: ${delta}`, code: 'refusal', error: { message: `Model refused: ${delta}`, code: 'refusal' }, @@ -493,7 +491,7 @@ export class OpenRouterResponsesTextAdapter< messageId: reasoningMessageId, delta: reasoningDelta, model, - timestamp, + timestamp: Date.now(), } continue } @@ -514,7 +512,7 @@ export class OpenRouterResponsesTextAdapter< type: EventType.TEXT_MESSAGE_START, messageId: aguiState.messageId, model, - timestamp, + timestamp: Date.now(), role: 'assistant', } } @@ -523,7 +521,7 @@ export class OpenRouterResponsesTextAdapter< type: EventType.TEXT_MESSAGE_CONTENT, messageId: aguiState.messageId, model, - timestamp, + timestamp: Date.now(), delta: textDelta, content: accumulatedContent, } @@ -554,7 +552,7 @@ export class OpenRouterResponsesTextAdapter< type: EventType.RUN_ERROR, runId: aguiState.runId, model, - timestamp, + timestamp: Date.now(), message, ...(code !== undefined && { code }), // Forward the provider's structured error body when the failure @@ -575,7 +573,7 @@ export class OpenRouterResponsesTextAdapter< type: EventType.RUN_ERROR, runId: aguiState.runId, model, - timestamp, + timestamp: Date.now(), message, ...(code !== undefined && { code }), error: { @@ -594,7 +592,7 @@ export class OpenRouterResponsesTextAdapter< type: EventType.TEXT_MESSAGE_END, messageId: aguiState.messageId, model, - timestamp, + timestamp: Date.now(), } } @@ -603,7 +601,7 @@ export class OpenRouterResponsesTextAdapter< type: EventType.RUN_ERROR, runId: aguiState.runId, model, - timestamp, + timestamp: Date.now(), message: `${this.name}.structuredOutputStream: response contained no content`, code: 'empty-response', error: { @@ -622,7 +620,7 @@ export class OpenRouterResponsesTextAdapter< type: EventType.RUN_ERROR, runId: aguiState.runId, model, - timestamp, + timestamp: Date.now(), message: `Failed to parse structured output as JSON. Content: ${accumulatedContent.slice(0, 200)}${accumulatedContent.length > 200 ? '...' : ''}`, code: 'parse-error', error: { @@ -644,7 +642,7 @@ export class OpenRouterResponsesTextAdapter< ...(accumulatedReasoning ? { reasoning: accumulatedReasoning } : {}), }, model, - timestamp, + timestamp: Date.now(), } yield { @@ -652,7 +650,7 @@ export class OpenRouterResponsesTextAdapter< runId: aguiState.runId, threadId: aguiState.threadId, model, - timestamp, + timestamp: Date.now(), finishReason: 'stop', ...(usage && { usage: { @@ -671,7 +669,7 @@ export class OpenRouterResponsesTextAdapter< runId: aguiState.runId, threadId: aguiState.threadId, model, - timestamp, + timestamp: Date.now(), parentRunId: chatOptions.parentRunId, } } @@ -697,7 +695,7 @@ export class OpenRouterResponsesTextAdapter< type: EventType.RUN_ERROR, runId: aguiState.runId, model, - timestamp, + timestamp: Date.now(), message: errorPayload.message, ...(resolvedCode !== undefined && { code: resolvedCode }), ...(rawEvent !== undefined && { rawEvent }), diff --git a/packages/ai-openrouter/src/adapters/text.ts b/packages/ai-openrouter/src/adapters/text.ts index b9ccaf24e1..3fb55f1388 100644 --- a/packages/ai-openrouter/src/adapters/text.ts +++ b/packages/ai-openrouter/src/adapters/text.ts @@ -326,12 +326,10 @@ export class OpenRouterTextAdapter< outputSchema.required, ) - const timestamp = Date.now() const aguiState = { runId: generateId(this.name), threadId: chatOptions.threadId ?? generateId(this.name), messageId: generateId(this.name), - timestamp, hasEmittedRunStarted: false, } @@ -353,13 +351,13 @@ export class OpenRouterTextAdapter< type: EventType.REASONING_MESSAGE_END, messageId: reasoningMessageId, model: lastModel || chatOptions.model, - timestamp, + timestamp: Date.now(), } yield { type: EventType.REASONING_END, messageId: reasoningMessageId, model: lastModel || chatOptions.model, - timestamp, + timestamp: Date.now(), } if (stepId) { yield { @@ -367,7 +365,7 @@ export class OpenRouterTextAdapter< stepName: stepId, stepId, model: lastModel || chatOptions.model, - timestamp, + timestamp: Date.now(), content: accumulatedReasoning, } } @@ -429,7 +427,7 @@ export class OpenRouterTextAdapter< runId: aguiState.runId, threadId: aguiState.threadId, model: chunk.model || chatOptions.model, - timestamp, + timestamp: Date.now(), parentRunId: chatOptions.parentRunId, } } @@ -443,21 +441,21 @@ export class OpenRouterTextAdapter< type: EventType.REASONING_START, messageId: reasoningMessageId, model: chunk.model || chatOptions.model, - timestamp, + timestamp: Date.now(), } yield { type: EventType.REASONING_MESSAGE_START, messageId: reasoningMessageId, role: 'reasoning' as const, model: chunk.model || chatOptions.model, - timestamp, + timestamp: Date.now(), } yield { type: EventType.STEP_STARTED, stepName: stepId, stepId, model: chunk.model || chatOptions.model, - timestamp, + timestamp: Date.now(), stepType: 'thinking', } } @@ -467,7 +465,7 @@ export class OpenRouterTextAdapter< messageId: reasoningMessageId, delta: reasoningText, model: chunk.model || chatOptions.model, - timestamp, + timestamp: Date.now(), } } @@ -484,7 +482,7 @@ export class OpenRouterTextAdapter< type: EventType.TEXT_MESSAGE_START, messageId: aguiState.messageId, model: chunk.model || chatOptions.model, - timestamp, + timestamp: Date.now(), role: 'assistant', } } @@ -495,7 +493,7 @@ export class OpenRouterTextAdapter< type: EventType.TEXT_MESSAGE_CONTENT, messageId: aguiState.messageId, model: chunk.model || chatOptions.model, - timestamp, + timestamp: Date.now(), delta: deltaContent, content: accumulatedContent, } @@ -509,7 +507,7 @@ export class OpenRouterTextAdapter< type: EventType.TEXT_MESSAGE_END, messageId: aguiState.messageId, model: lastModel || chatOptions.model, - timestamp, + timestamp: Date.now(), } } @@ -518,7 +516,7 @@ export class OpenRouterTextAdapter< type: EventType.RUN_ERROR, runId: aguiState.runId, model: lastModel || chatOptions.model, - timestamp, + timestamp: Date.now(), message: `${this.name}.structuredOutputStream: response contained no content`, code: 'empty-response', error: { @@ -537,7 +535,7 @@ export class OpenRouterTextAdapter< type: EventType.RUN_ERROR, runId: aguiState.runId, model: lastModel || chatOptions.model, - timestamp, + timestamp: Date.now(), message: `Failed to parse structured output as JSON. Content: ${accumulatedContent.slice(0, 200)}${accumulatedContent.length > 200 ? '...' : ''}`, code: 'parse-error', error: { @@ -559,7 +557,7 @@ export class OpenRouterTextAdapter< ...(accumulatedReasoning ? { reasoning: accumulatedReasoning } : {}), }, model: lastModel || chatOptions.model, - timestamp, + timestamp: Date.now(), } const finalUsage = buildOpenRouterUsage(lastUsage) @@ -569,7 +567,7 @@ export class OpenRouterTextAdapter< runId: aguiState.runId, threadId: aguiState.threadId, model: lastModel || chatOptions.model, - timestamp, + timestamp: Date.now(), finishReason: 'stop', ...(finalUsage && { usage: { ...finalUsage, ...extractUsageCost(lastUsage) }, @@ -583,7 +581,7 @@ export class OpenRouterTextAdapter< runId: aguiState.runId, threadId: aguiState.threadId, model: chatOptions.model, - timestamp, + timestamp: Date.now(), parentRunId: chatOptions.parentRunId, } } @@ -609,7 +607,7 @@ export class OpenRouterTextAdapter< type: EventType.RUN_ERROR, runId: aguiState.runId, model: lastModel || chatOptions.model, - timestamp, + timestamp: Date.now(), message: errorPayload.message, ...(resolvedCode !== undefined && { code: resolvedCode }), ...(rawEvent !== undefined && { rawEvent }), diff --git a/packages/ai/src/activities/chat/adapter.ts b/packages/ai/src/activities/chat/adapter.ts index d461afb669..ce3ac7e110 100644 --- a/packages/ai/src/activities/chat/adapter.ts +++ b/packages/ai/src/activities/chat/adapter.ts @@ -131,7 +131,8 @@ export interface TextAdapter< * Implementations must emit standard AG-UI lifecycle events (RUN_STARTED, * TEXT_MESSAGE_*, RUN_FINISHED) carrying raw JSON text deltas, plus a final * `CUSTOM` event named `structured-output.complete` whose `value` is - * `{ object, raw, reasoning? }`. + * `{ object, raw, reasoning? }`. Events must be timestamped when emitted so + * their timestamps follow stream order. */ structuredOutputStream?: ( options: StructuredOutputOptions, diff --git a/packages/ai/src/activities/chat/index.ts b/packages/ai/src/activities/chat/index.ts index 8c7da926c6..06a23c9cf4 100644 --- a/packages/ai/src/activities/chat/index.ts +++ b/packages/ai/src/activities/chat/index.ts @@ -2913,7 +2913,9 @@ class TextEngine< return null } - const buildSynthesizedStart = (): StreamChunk => { + // The synthetic event is inserted before its trigger, so share that + // trigger's timestamp rather than making the earlier event sort later. + const buildSynthesizedStart = (timestamp = Date.now()): StreamChunk => { const idForStart = structuredMessageId ?? generateMessageId() structuredMessageId = idForStart return { @@ -2921,7 +2923,7 @@ class TextEngine< name: 'structured-output.start', value: { messageId: idForStart }, model: this.params.model, - timestamp: Date.now(), + timestamp, threadId: this.threadId, ...(this.runIdOverride ? { runId: this.runIdOverride } : {}), } @@ -2971,7 +2973,7 @@ class TextEngine< chunk.type === EventType.TEXT_MESSAGE_END) ) { startEmitted = true - const synthStart = buildSynthesizedStart() + const synthStart = buildSynthesizedStart(chunk.timestamp) const synthOutputs = await pipeThroughMiddleware(synthStart) for (const outputChunk of synthOutputs) { yield outputChunk @@ -2984,7 +2986,7 @@ class TextEngine< // of a silent UI. if (!startEmitted && chunk.type === EventType.RUN_ERROR) { startEmitted = true - const synthStart = buildSynthesizedStart() + const synthStart = buildSynthesizedStart(chunk.timestamp) const synthOutputs = await pipeThroughMiddleware(synthStart) for (const outputChunk of synthOutputs) { yield outputChunk @@ -4084,14 +4086,14 @@ async function* fallbackStructuredOutputStream( chatOptions.threadId ?? `fallback-${Date.now()}-${fallbackRand}` const messageId = `fallback-${Date.now()}-${fallbackRand}` const model = chatOptions.model - const timestamp = Date.now() + const startedAt = Date.now() yield { type: EventType.RUN_STARTED, runId, threadId, model, - timestamp, + timestamp: startedAt, } let result: StructuredOutputResult @@ -4105,7 +4107,7 @@ async function* fallbackStructuredOutputStream( runId, threadId, model, - timestamp, + timestamp: Date.now(), message, error: { message }, } @@ -4117,7 +4119,7 @@ async function* fallbackStructuredOutputStream( messageId, role: 'assistant', model, - timestamp, + timestamp: Date.now(), } yield { @@ -4125,14 +4127,14 @@ async function* fallbackStructuredOutputStream( messageId, delta: result.rawText, model, - timestamp, + timestamp: Date.now(), } yield { type: EventType.TEXT_MESSAGE_END, messageId, model, - timestamp, + timestamp: Date.now(), } yield { @@ -4140,7 +4142,7 @@ async function* fallbackStructuredOutputStream( name: 'structured-output.complete', value: { object: result.data, raw: result.rawText }, model, - timestamp, + timestamp: Date.now(), } yield { @@ -4148,7 +4150,7 @@ async function* fallbackStructuredOutputStream( runId, threadId, model, - timestamp, + timestamp: Date.now(), finishReason: 'stop', // Forward adapter-reported token usage so consumers reading // `RUN_FINISHED.usage` (and the engine's `runOnUsage` middleware hook) see diff --git a/packages/ai/tests/chat-structured-output-stream.test.ts b/packages/ai/tests/chat-structured-output-stream.test.ts index 52e4b8eacd..4e2012844e 100644 --- a/packages/ai/tests/chat-structured-output-stream.test.ts +++ b/packages/ai/tests/chat-structured-output-stream.test.ts @@ -14,7 +14,7 @@ * and the e2e suite. This file is the orchestrator-only fixture. */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { z } from 'zod' import { chat } from '../src/activities/chat/index' import { EventType } from '../src/types' @@ -332,6 +332,54 @@ describe('chat({ outputSchema, stream: true })', () => { expect(complete!.value.raw).toBe(JSON.stringify(validPerson)) }) + it('timestamps fallback completion events after the provider settles', async () => { + vi.useFakeTimers() + try { + vi.setSystemTime(1_000) + const adapter = makeAdapter({ + structuredOutput: async () => { + vi.setSystemTime(2_000) + return { + data: validPerson, + rawText: JSON.stringify(validPerson), + } + }, + }) + + const chunks = await collectChunks( + chat({ + adapter, + messages: [{ role: 'user', content: 'extract' }], + outputSchema: PersonSchema, + stream: true, + }), + ) + const started = chunks.find( + (chunk) => chunk.type === EventType.RUN_STARTED, + ) + const start = chunks.find( + (chunk) => + chunk.type === EventType.CUSTOM && + chunk.name === 'structured-output.start', + ) + const complete = chunks.find( + (chunk) => + chunk.type === EventType.CUSTOM && + chunk.name === 'structured-output.complete', + ) + const finished = chunks.find( + (chunk) => chunk.type === EventType.RUN_FINISHED, + ) + + expect(started?.timestamp).toBe(1_000) + expect(start?.timestamp).toBe(2_000) + expect(complete?.timestamp).toBeGreaterThanOrEqual(start!.timestamp!) + expect(finished?.timestamp).toBeGreaterThanOrEqual(complete!.timestamp!) + } finally { + vi.useRealTimers() + } + }) + it('forwards the fallback-synthesized structured-output.complete event without orchestrator-side schema validation', async () => { // Same invariant as the native-stream variant: schema validation is // the consumer's responsibility. The fallback synthesizes an @@ -520,6 +568,43 @@ describe('chat({ outputSchema, stream: true })', () => { expect(startChunk.value.messageId.length).toBeGreaterThan(0) }) + it('timestamps fallback errors at the synthesized start boundary', async () => { + vi.useFakeTimers() + try { + vi.setSystemTime(1_000) + const adapter = makeAdapter({ + structuredOutput: async () => { + vi.setSystemTime(2_000) + throw new Error('upstream auth failed') + }, + }) + + const chunks = await collectChunks( + chat({ + adapter, + messages: [{ role: 'user', content: 'extract' }], + outputSchema: PersonSchema, + stream: true, + }), + ) + const started = chunks.find( + (chunk) => chunk.type === EventType.RUN_STARTED, + ) + const start = chunks.find( + (chunk) => + chunk.type === EventType.CUSTOM && + chunk.name === 'structured-output.start', + ) + const error = chunks.find((chunk) => chunk.type === EventType.RUN_ERROR) + + expect(started?.timestamp).toBe(1_000) + expect(start?.timestamp).toBe(2_000) + expect(error?.timestamp).toBeGreaterThanOrEqual(start!.timestamp!) + } finally { + vi.useRealTimers() + } + }) + it('forwards adapter-emitted lifecycle ordering (TEXT_MESSAGE_CONTENT precedes structured-output.complete)', async () => { // The new streaming orchestrator delegates lifecycle emission to the // engine + adapter pipeline. The engine still synthesizes a diff --git a/packages/openai-base/src/adapters/chat-completions-text.ts b/packages/openai-base/src/adapters/chat-completions-text.ts index 3d735b8ea6..9c263244bf 100644 --- a/packages/openai-base/src/adapters/chat-completions-text.ts +++ b/packages/openai-base/src/adapters/chat-completions-text.ts @@ -256,12 +256,10 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter< outputSchema.required, ) - const timestamp = Date.now() const aguiState = { runId: generateId(this.name), threadId: chatOptions.threadId ?? generateId(this.name), messageId: generateId(this.name), - timestamp, hasEmittedRunStarted: false, } @@ -285,13 +283,13 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter< type: EventType.REASONING_MESSAGE_END, messageId: reasoningMessageId, model: lastModel || chatOptions.model, - timestamp, + timestamp: Date.now(), } yield { type: EventType.REASONING_END, messageId: reasoningMessageId, model: lastModel || chatOptions.model, - timestamp, + timestamp: Date.now(), } if (stepId) { yield { @@ -299,7 +297,7 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter< stepName: stepId, stepId, model: lastModel || chatOptions.model, - timestamp, + timestamp: Date.now(), content: accumulatedReasoning, } } @@ -364,7 +362,7 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter< runId: aguiState.runId, threadId: aguiState.threadId, model: chunk.model || chatOptions.model, - timestamp, + timestamp: Date.now(), parentRunId: chatOptions.parentRunId, } } @@ -379,21 +377,21 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter< type: EventType.REASONING_START, messageId: reasoningMessageId, model: chunk.model || chatOptions.model, - timestamp, + timestamp: Date.now(), } yield { type: EventType.REASONING_MESSAGE_START, messageId: reasoningMessageId, role: 'reasoning' as const, model: chunk.model || chatOptions.model, - timestamp, + timestamp: Date.now(), } yield { type: EventType.STEP_STARTED, stepName: stepId, stepId, model: chunk.model || chatOptions.model, - timestamp, + timestamp: Date.now(), stepType: 'thinking', } } @@ -403,7 +401,7 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter< messageId: reasoningMessageId, delta: reasoning.text, model: chunk.model || chatOptions.model, - timestamp, + timestamp: Date.now(), } } @@ -420,7 +418,7 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter< type: EventType.TEXT_MESSAGE_START, messageId: aguiState.messageId, model: chunk.model || chatOptions.model, - timestamp, + timestamp: Date.now(), role: 'assistant', } } @@ -431,7 +429,7 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter< type: EventType.TEXT_MESSAGE_CONTENT, messageId: aguiState.messageId, model: chunk.model || chatOptions.model, - timestamp, + timestamp: Date.now(), delta: deltaContent, content: accumulatedContent, } @@ -448,7 +446,7 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter< type: EventType.TEXT_MESSAGE_END, messageId: aguiState.messageId, model: lastModel || chatOptions.model, - timestamp, + timestamp: Date.now(), } } @@ -457,7 +455,7 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter< type: EventType.RUN_ERROR, runId: aguiState.runId, model: lastModel || chatOptions.model, - timestamp, + timestamp: Date.now(), message: `${this.name}.structuredOutputStream: response contained no content`, code: 'empty-response', error: { @@ -476,7 +474,7 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter< type: EventType.RUN_ERROR, runId: aguiState.runId, model: lastModel || chatOptions.model, - timestamp, + timestamp: Date.now(), message: `Failed to parse structured output as JSON. Content: ${accumulatedContent.slice(0, 200)}${accumulatedContent.length > 200 ? '...' : ''}`, code: 'parse-error', error: { @@ -498,7 +496,7 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter< ...(accumulatedReasoning ? { reasoning: accumulatedReasoning } : {}), }, model: lastModel || chatOptions.model, - timestamp, + timestamp: Date.now(), } yield { @@ -506,7 +504,7 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter< runId: aguiState.runId, threadId: aguiState.threadId, model: lastModel || chatOptions.model, - timestamp, + timestamp: Date.now(), finishReason: 'stop', ...(lastUsage && { usage: buildChatCompletionsUsage(lastUsage), @@ -520,7 +518,7 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter< runId: aguiState.runId, threadId: aguiState.threadId, model: chatOptions.model, - timestamp, + timestamp: Date.now(), parentRunId: chatOptions.parentRunId, } } @@ -540,7 +538,7 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter< type: EventType.RUN_ERROR, runId: aguiState.runId, model: lastModel || chatOptions.model, - timestamp, + timestamp: Date.now(), message: errorPayload.message, ...(resolvedCode !== undefined && { code: resolvedCode }), ...(rawEvent !== undefined && { rawEvent }), diff --git a/packages/openai-base/src/adapters/responses-text.ts b/packages/openai-base/src/adapters/responses-text.ts index 1e218fd699..408342b810 100644 --- a/packages/openai-base/src/adapters/responses-text.ts +++ b/packages/openai-base/src/adapters/responses-text.ts @@ -303,12 +303,10 @@ export abstract class OpenAIBaseResponsesTextAdapter< outputSchema.required, ) - const timestamp = Date.now() const aguiState = { runId: generateId(this.name), threadId: chatOptions.threadId ?? generateId(this.name), messageId: generateId(this.name), - timestamp, hasEmittedRunStarted: false, } @@ -330,13 +328,13 @@ export abstract class OpenAIBaseResponsesTextAdapter< type: EventType.REASONING_MESSAGE_END, messageId: reasoningMessageId, model, - timestamp, + timestamp: Date.now(), } yield { type: EventType.REASONING_END, messageId: reasoningMessageId, model, - timestamp, + timestamp: Date.now(), } if (stepId) { yield { @@ -344,7 +342,7 @@ export abstract class OpenAIBaseResponsesTextAdapter< stepName: stepId, stepId, model, - timestamp, + timestamp: Date.now(), content: accumulatedReasoning, } } @@ -361,21 +359,21 @@ export abstract class OpenAIBaseResponsesTextAdapter< type: EventType.REASONING_START, messageId: reasoningMessageId, model, - timestamp, + timestamp: Date.now(), } yield { type: EventType.REASONING_MESSAGE_START, messageId: reasoningMessageId, role: 'reasoning' as const, model, - timestamp, + timestamp: Date.now(), } yield { type: EventType.STEP_STARTED, stepName: stepId, stepId, model, - timestamp, + timestamp: Date.now(), stepType: 'thinking', } }.bind(this) @@ -418,7 +416,7 @@ export abstract class OpenAIBaseResponsesTextAdapter< runId: aguiState.runId, threadId: aguiState.threadId, model, - timestamp, + timestamp: Date.now(), parentRunId: chatOptions.parentRunId, } } @@ -442,7 +440,7 @@ export abstract class OpenAIBaseResponsesTextAdapter< type: EventType.RUN_ERROR, runId: aguiState.runId, model, - timestamp, + timestamp: Date.now(), message: `Model refused: ${delta}`, code: 'refusal', error: { message: `Model refused: ${delta}`, code: 'refusal' }, @@ -471,7 +469,7 @@ export abstract class OpenAIBaseResponsesTextAdapter< messageId: reasoningMessageId, delta: reasoningDelta, model, - timestamp, + timestamp: Date.now(), } continue } @@ -493,7 +491,7 @@ export abstract class OpenAIBaseResponsesTextAdapter< type: EventType.TEXT_MESSAGE_START, messageId: aguiState.messageId, model, - timestamp, + timestamp: Date.now(), role: 'assistant', } } @@ -502,7 +500,7 @@ export abstract class OpenAIBaseResponsesTextAdapter< type: EventType.TEXT_MESSAGE_CONTENT, messageId: aguiState.messageId, model, - timestamp, + timestamp: Date.now(), delta: textDelta, content: accumulatedContent, } @@ -531,7 +529,7 @@ export abstract class OpenAIBaseResponsesTextAdapter< type: EventType.RUN_ERROR, runId: aguiState.runId, model, - timestamp, + timestamp: Date.now(), message, ...(code !== undefined && { code }), error: { message, ...(code !== undefined && { code }) }, @@ -547,7 +545,7 @@ export abstract class OpenAIBaseResponsesTextAdapter< type: EventType.TEXT_MESSAGE_END, messageId: aguiState.messageId, model, - timestamp, + timestamp: Date.now(), } } @@ -556,7 +554,7 @@ export abstract class OpenAIBaseResponsesTextAdapter< type: EventType.RUN_ERROR, runId: aguiState.runId, model, - timestamp, + timestamp: Date.now(), message: `${this.name}.structuredOutputStream: response contained no content`, code: 'empty-response', error: { @@ -575,7 +573,7 @@ export abstract class OpenAIBaseResponsesTextAdapter< type: EventType.RUN_ERROR, runId: aguiState.runId, model, - timestamp, + timestamp: Date.now(), message: `Failed to parse structured output as JSON. Content: ${accumulatedContent.slice(0, 200)}${accumulatedContent.length > 200 ? '...' : ''}`, code: 'parse-error', error: { @@ -600,7 +598,7 @@ export abstract class OpenAIBaseResponsesTextAdapter< ...(accumulatedReasoning ? { reasoning: accumulatedReasoning } : {}), }, model, - timestamp, + timestamp: Date.now(), } yield { @@ -608,7 +606,7 @@ export abstract class OpenAIBaseResponsesTextAdapter< runId: aguiState.runId, threadId: aguiState.threadId, model, - timestamp, + timestamp: Date.now(), finishReason: 'stop', ...(usage && { usage: buildResponsesUsage(usage), @@ -622,7 +620,7 @@ export abstract class OpenAIBaseResponsesTextAdapter< runId: aguiState.runId, threadId: aguiState.threadId, model, - timestamp, + timestamp: Date.now(), parentRunId: chatOptions.parentRunId, } } @@ -641,7 +639,7 @@ export abstract class OpenAIBaseResponsesTextAdapter< type: EventType.RUN_ERROR, runId: aguiState.runId, model, - timestamp, + timestamp: Date.now(), message: errorPayload.message, ...(resolvedCode !== undefined && { code: resolvedCode }), ...(rawEvent !== undefined && { rawEvent }), diff --git a/packages/openai-base/tests/chat-completions-structured-output-stream.test.ts b/packages/openai-base/tests/chat-completions-structured-output-stream.test.ts index f0a68951eb..6f204eb932 100644 --- a/packages/openai-base/tests/chat-completions-structured-output-stream.test.ts +++ b/packages/openai-base/tests/chat-completions-structured-output-stream.test.ts @@ -147,6 +147,42 @@ describe('OpenAIBaseChatCompletionsTextAdapter.structuredOutputStream', () => { expect(complete!.value.raw).toBe(json) }) + it('timestamps lifecycle events when they are emitted', async () => { + vi.useFakeTimers() + try { + vi.setSystemTime(1_000) + setupStreamingMock([ + deltaChunk('{"name":"John","age":30}'), + finishChunk(), + ]) + const adapter = new TestAdapter() + const chunks: Array = [] + + for await (const chunk of adapter.structuredOutputStream!({ + chatOptions: { + model: 'test-model', + messages: [{ role: 'user', content: 'extract' }], + logger: testLogger, + }, + outputSchema: personSchema, + })) { + chunks.push(chunk) + vi.advanceTimersByTime(1) + } + + const timestamps = chunks + .map((chunk) => chunk.timestamp) + .filter( + (timestamp): timestamp is number => typeof timestamp === 'number', + ) + expect(timestamps).toHaveLength(chunks.length) + expect(timestamps).toEqual([...timestamps].sort((a, b) => a - b)) + expect(timestamps.at(-1)).toBeGreaterThan(timestamps[0]!) + } finally { + vi.useRealTimers() + } + }) + it('passes provider nulls through unchanged (engine un-widens, not the adapter)', async () => { // Mirror of the non-streaming `transformStructuredOutput` passthrough test // (`chat-completions-text.test.ts`) for the STREAMING path: the adapter no diff --git a/packages/openai-base/tests/responses-structured-output-stream.test.ts b/packages/openai-base/tests/responses-structured-output-stream.test.ts index f1b62a3f36..cc48a88978 100644 --- a/packages/openai-base/tests/responses-structured-output-stream.test.ts +++ b/packages/openai-base/tests/responses-structured-output-stream.test.ts @@ -149,6 +149,43 @@ describe('OpenAIBaseResponsesTextAdapter.structuredOutputStream', () => { expect(complete!.value.raw).toBe(json) }) + it('timestamps lifecycle events when they are emitted', async () => { + vi.useFakeTimers() + try { + vi.setSystemTime(1_000) + setupStreamingMock([ + eventCreated(), + eventOutputTextDelta('{"name":"John","age":30}'), + eventCompleted(), + ]) + const adapter = new TestAdapter() + const chunks: Array = [] + + for await (const chunk of adapter.structuredOutputStream!({ + chatOptions: { + model: 'test-model', + messages: [{ role: 'user', content: 'extract' }], + logger: testLogger, + }, + outputSchema: personSchema, + })) { + chunks.push(chunk) + vi.advanceTimersByTime(1) + } + + const timestamps = chunks + .map((chunk) => chunk.timestamp) + .filter( + (timestamp): timestamp is number => typeof timestamp === 'number', + ) + expect(timestamps).toHaveLength(chunks.length) + expect(timestamps).toEqual([...timestamps].sort((a, b) => a - b)) + expect(timestamps.at(-1)).toBeGreaterThan(timestamps[0]!) + } finally { + vi.useRealTimers() + } + }) + it('sends text.format: { type: "json_schema", strict: true } in the request', async () => { setupStreamingMock([ eventCreated(), diff --git a/testing/e2e/src/routes/api.anthropic-structured-usage.ts b/testing/e2e/src/routes/api.anthropic-structured-usage.ts index 96b8346291..0f4d9e5e8d 100644 --- a/testing/e2e/src/routes/api.anthropic-structured-usage.ts +++ b/testing/e2e/src/routes/api.anthropic-structured-usage.ts @@ -15,9 +15,8 @@ const DUMMY_KEY = 'sk-e2e-test-dummy-key' * aimock path returns a tool-forced `structured_output` response whose `usage` * carries `input_tokens` / `output_tokens` / `cache_read_input_tokens`. * - * Regression for #758: before the fix the fallback dropped `result.usage`, so - * `RUN_FINISHED.usage` was `undefined` on every fallback-path provider. The - * companion spec asserts the usage now reaches `RUN_FINISHED.usage`. + * Regressions for #758 and #1125: the companion spec asserts that usage reaches + * `RUN_FINISHED.usage` and fallback timestamps follow stream order. */ export const Route = createFileRoute('/api/anthropic-structured-usage')({ server: { @@ -44,13 +43,37 @@ export const Route = createFileRoute('/api/anthropic-structured-usage')({ }) let usage: Record | undefined + const timestamps: Record = {} try { for await (const chunk of chat({ ...options, messages: [{ role: 'user', content: 'recommend a guitar as json' }], })) { + if ( + chunk.type === 'RUN_STARTED' && + typeof chunk.timestamp === 'number' + ) { + timestamps.runStarted = chunk.timestamp + } + if ( + chunk.type === 'CUSTOM' && + chunk.name === 'structured-output.start' && + typeof chunk.timestamp === 'number' + ) { + timestamps.structuredOutputStart = chunk.timestamp + } + if ( + chunk.type === 'CUSTOM' && + chunk.name === 'structured-output.complete' && + typeof chunk.timestamp === 'number' + ) { + timestamps.structuredOutputComplete = chunk.timestamp + } if (chunk.type === 'RUN_FINISHED') { usage = chunk.usage as Record | undefined + if (typeof chunk.timestamp === 'number') { + timestamps.runFinished = chunk.timestamp + } } } } catch (error) { @@ -63,7 +86,7 @@ export const Route = createFileRoute('/api/anthropic-structured-usage')({ ) } - return new Response(JSON.stringify({ ok: true, usage }), { + return new Response(JSON.stringify({ ok: true, usage, timestamps }), { status: 200, headers: { 'Content-Type': 'application/json' }, }) diff --git a/testing/e2e/tests/anthropic-structured-usage.spec.ts b/testing/e2e/tests/anthropic-structured-usage.spec.ts index 12051ca515..44b77e4b5f 100644 --- a/testing/e2e/tests/anthropic-structured-usage.spec.ts +++ b/testing/e2e/tests/anthropic-structured-usage.spec.ts @@ -1,7 +1,7 @@ import { test, expect } from './fixtures' /** - * Regression for #758. `AnthropicTextAdapter` has no native + * Regressions for #758 and #1125. `AnthropicTextAdapter` has no native * `structuredOutputStream`, so `chat({ outputSchema, stream: true })` runs * through the activity layer's `fallbackStructuredOutputStream`. That wrapper * used to drop the `usage` returned by `structuredOutput()`, so consumers @@ -11,17 +11,15 @@ import { test, expect } from './fixtures' * The `/api/anthropic-structured-usage` route drives the adapter against an * aimock mount whose tool-forced `structured_output` response carries * `input_tokens` / `output_tokens` / `cache_read_input_tokens`. This is the - * end-to-end proof that usage now survives the fallback path onto - * `RUN_FINISHED.usage`. + * end-to-end proof that usage survives the fallback path and its timestamps + * follow stream order. */ -test.describe('anthropic — structured-output fallback usage (#758)', () => { - test('usage reaches RUN_FINISHED.usage on the fallback path', async ({ - request, - }) => { +test.describe('anthropic — structured-output fallback', () => { + test('preserves usage and timestamp ordering', async ({ request }) => { const res = await request.post('/api/anthropic-structured-usage') expect(res.ok()).toBe(true) - const { ok, usage, error } = (await res.json()) as { + const { ok, usage, timestamps, error } = (await res.json()) as { ok: boolean error?: string usage?: { @@ -30,6 +28,12 @@ test.describe('anthropic — structured-output fallback usage (#758)', () => { totalTokens?: number promptTokensDetails?: { cachedTokens?: number } } + timestamps: { + runStarted: number + structuredOutputStart: number + structuredOutputComplete: number + runFinished: number + } } expect(error ?? null).toBeNull() @@ -40,5 +44,14 @@ test.describe('anthropic — structured-output fallback usage (#758)', () => { totalTokens: 1471, promptTokensDetails: { cachedTokens: 5760 }, }) + expect(timestamps.structuredOutputStart).toBeGreaterThanOrEqual( + timestamps.runStarted, + ) + expect(timestamps.structuredOutputComplete).toBeGreaterThanOrEqual( + timestamps.structuredOutputStart, + ) + expect(timestamps.runFinished).toBeGreaterThanOrEqual( + timestamps.structuredOutputComplete, + ) }) }) From ac6913bc8ba5f8b3caed9969c4e81f1db2b8c2d6 Mon Sep 17 00:00:00 2001 From: kolaworld Date: Mon, 17 Aug 2026 16:49:53 -0400 Subject: [PATCH 2/3] docs(ai): correct structured-output streaming guidance --- docs/structured-outputs/streaming.md | 20 ++++++++++--------- .../ai-core/structured-outputs/SKILL.md | 2 ++ 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/docs/structured-outputs/streaming.md b/docs/structured-outputs/streaming.md index 01c3f5bb77..9a01942d4c 100644 --- a/docs/structured-outputs/streaming.md +++ b/docs/structured-outputs/streaming.md @@ -2,7 +2,7 @@ title: Streaming Structured Output UIs id: structured-outputs-streaming order: 3 -description: "Build a UI that fills in field by field as the model streams structured JSON. chat({ outputSchema, stream: true }) on the server, useChat({ outputSchema }) on the client — progressive partial state plus a validated terminal object." +description: "Build a UI that fills in field by field as the model streams structured JSON. chat({ outputSchema, stream: true }) on the server, useChat({ outputSchema }) on the client — progressive partial state plus a typed terminal object." keywords: - tanstack ai - structured outputs @@ -16,7 +16,7 @@ keywords: You have an existing chat-style endpoint and you want the structured response to populate a UI _while_ the model is generating — a form filling in field by field, a card whose ingredients list grows as JSON streams in, a typewriter preview of a JSON-typed report. Blocking on `await chat({ outputSchema })` would leave the UI dark until the whole object is ready; this guide is the alternative. -By the end you'll have a server endpoint streaming structured JSON as Server-Sent Events, and a client that reads a typed `partial` (progressive object) and `final` (validated terminal object) from `useChat`. +By the end you'll have a server endpoint streaming structured JSON as Server-Sent Events, and a client that reads a typed `partial` (progressive object) and `final` (completed terminal object) from `useChat`. > **Note:** This is the streaming counterpart of [One-Shot Extraction](./one-shot). If you don't need progressive UI updates, the one-shot path is simpler. If you want users to iterate on the object across multiple turns and keep history, see [Multi-Turn Chat](./multi-turn). @@ -48,11 +48,11 @@ export async function POST(request: Request) { } ``` -That's the entire server side. `chat({ outputSchema, stream: true })` returns a `StructuredOutputStream>` — an `AsyncIterable` of standard streaming events plus a terminal `structured-output.complete` event carrying the validated object. `toServerSentEventsResponse` knows what to do with it. +That's the entire server side. `chat({ outputSchema, stream: true })` returns a `StructuredOutputStream>` — an `AsyncIterable` of standard streaming events plus a terminal `structured-output.complete` event carrying the completed object. `toServerSentEventsResponse` knows what to do with it. ## Client with `useChat` -Pass the same schema to `useChat`. The hook gives you a progressively-parsed `partial` and a validated `final`: +Pass the same schema to `useChat`. The hook gives you a progressively-parsed `partial` and a typed `final`: ```tsx import { useChat, fetchServerSentEvents } from "@tanstack/ai-react"; @@ -82,7 +82,7 @@ function PersonExtractor() {

Name: {partial.name ?? "…"}

Age: {partial.age ?? "…"}

Email: {partial.email ?? "…"}

- {final &&
Validated: {JSON.stringify(final, null, 2)}
} + {final &&
Completed: {JSON.stringify(final, null, 2)}
} ); } @@ -91,8 +91,8 @@ function PersonExtractor() { What the hook does for you: - **`partial`** is `DeepPartial>` — every property optional, every nested array element optional. Updated from `TEXT_MESSAGE_CONTENT` deltas via the runtime's partial-JSON parser. The hook derives it from the latest assistant message's `structured-output` part (see [Multi-Turn Chat](./multi-turn) for why that distinction matters), so it reads `{}` between `sendMessage()` and the first chunk without any extra reset state. -- **`final`** is `z.infer | null` — the validated terminal payload from the `structured-output.complete` event. `null` until the run completes successfully. -- **`outputSchema`** is used purely for client-side TypeScript inference. Validation still runs on the server against the schema you pass to `chat({ outputSchema })` on the server route — the client doesn't re-validate. +- **`final`** is `z.infer | null` — the completed terminal payload from the `structured-output.complete` event. `null` until the run completes successfully. +- **`outputSchema`** is used purely for client-side TypeScript inference. The streaming path does not run Standard Schema validation; validate the completed object in the consumer when required. - The same shape works for **non-streaming adapters too**. If an adapter (Anthropic, Gemini, Ollama) returns a single `structured-output.complete` event with no incremental deltas, `partial` stays `{}` and `final` populates when the event arrives. Same consumer code. Claude Code and Codex emit `structured-output.complete` from the harness event. OpenCode, Grok Build, and `acpCompatible` parse the last assistant text at the end. In both cases `partial` stays empty until `final` is set. See [Harness Agents](./harnesses). `outputSchema` is optional: omit it and `useChat` returns its standard shape without `partial` / `final`. @@ -163,7 +163,7 @@ The `structured-output` part fields: type: "CUSTOM", name: "structured-output.complete", value: { - object: T; // validated, parsed, typed + object: T; // completed, parsed, typed raw: string; // full accumulated JSON text reasoning?: string; // present only for thinking/reasoning models }, @@ -183,6 +183,8 @@ Streaming structured output works with **every adapter**, but only some support | `@tanstack/ai-openrouter` | Native single-request stream (`response_format: json_schema`) | | `@tanstack/ai-grok` | Native single-request stream (Chat Completions, `response_format: json_schema`) | | `@tanstack/ai-groq` | Native single-request stream (Chat Completions, `response_format: json_schema`) | +| `@tanstack/ai-bedrock` | Native stream through Converse or an OpenAI-compatible API | +| `@tanstack/ai-byteplus` | Native single-request stream on supported models; unsupported models emit `RUN_ERROR` | | Other adapters (anthropic, gemini, ollama, …) | Fallback: runs non-streaming `structuredOutput` and emits the final object as one `structured-output.complete` event | The fallback path keeps the consumer code identical across providers — you always read the final object off `structured-output.complete` — but you won't see incremental deltas unless the adapter implements `structuredOutputStream` natively. @@ -211,7 +213,7 @@ const stream = chat({ for await (const chunk of stream) { if (chunk.type === "CUSTOM" && chunk.name === "structured-output.complete") { - // Validated and typed against PersonSchema. + // Typed against PersonSchema. Validate here when required. console.log(chunk.value.object.name); console.log(chunk.value.object.age); } diff --git a/packages/ai/skills/ai-core/structured-outputs/SKILL.md b/packages/ai/skills/ai-core/structured-outputs/SKILL.md index a041a261a7..929fa9bff0 100644 --- a/packages/ai/skills/ai-core/structured-outputs/SKILL.md +++ b/packages/ai/skills/ai-core/structured-outputs/SKILL.md @@ -191,6 +191,8 @@ The terminal event is a `CUSTOM` chunk: `{ type: 'CUSTOM', name: 'structured-out | `@tanstack/ai-grok` (Grok 4 family only) | **Native combined mode (#605)** — `response_format: json_schema` + `tools`. Grok 2 / 3 fall back | | `@tanstack/ai-openrouter` | Native single-request stream (legacy `structuredOutputStream` path; per-call combined-mode lookup is a follow-up) | | `@tanstack/ai-groq` | Legacy `structuredOutputStream` only (no tools — Groq's API rejects schema + tools + stream) | +| `@tanstack/ai-bedrock` | Separate native `structuredOutputStream` finalization through Converse or an OpenAI-compatible API | +| `@tanstack/ai-byteplus` | Native combined mode on supported models; unsupported models emit `RUN_ERROR` | | `@tanstack/ai-claude-code` | Combined + event source — `--json-schema` on the same harness turn. Read `useChat().final`. See Pattern 6. | | `@tanstack/ai-codex` | Combined + event source — `--output-schema` on the same harness turn. Read `useChat().final`. See Pattern 6. | | `@tanstack/ai-opencode` | Combined + event source — prompt-and-parse. Read `useChat().final`. See Pattern 6. | From ae72e95c8fa8fce4d0838eae0a5aa311d14a9998 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:26:53 +0000 Subject: [PATCH 3/3] ci: apply automated fixes --- packages/ai/skills/ai-core/structured-outputs/SKILL.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ai/skills/ai-core/structured-outputs/SKILL.md b/packages/ai/skills/ai-core/structured-outputs/SKILL.md index 929fa9bff0..b4a10856c4 100644 --- a/packages/ai/skills/ai-core/structured-outputs/SKILL.md +++ b/packages/ai/skills/ai-core/structured-outputs/SKILL.md @@ -191,8 +191,8 @@ The terminal event is a `CUSTOM` chunk: `{ type: 'CUSTOM', name: 'structured-out | `@tanstack/ai-grok` (Grok 4 family only) | **Native combined mode (#605)** — `response_format: json_schema` + `tools`. Grok 2 / 3 fall back | | `@tanstack/ai-openrouter` | Native single-request stream (legacy `structuredOutputStream` path; per-call combined-mode lookup is a follow-up) | | `@tanstack/ai-groq` | Legacy `structuredOutputStream` only (no tools — Groq's API rejects schema + tools + stream) | -| `@tanstack/ai-bedrock` | Separate native `structuredOutputStream` finalization through Converse or an OpenAI-compatible API | -| `@tanstack/ai-byteplus` | Native combined mode on supported models; unsupported models emit `RUN_ERROR` | +| `@tanstack/ai-bedrock` | Separate native `structuredOutputStream` finalization through Converse or an OpenAI-compatible API | +| `@tanstack/ai-byteplus` | Native combined mode on supported models; unsupported models emit `RUN_ERROR` | | `@tanstack/ai-claude-code` | Combined + event source — `--json-schema` on the same harness turn. Read `useChat().final`. See Pattern 6. | | `@tanstack/ai-codex` | Combined + event source — `--output-schema` on the same harness turn. Read `useChat().final`. See Pattern 6. | | `@tanstack/ai-opencode` | Combined + event source — prompt-and-parse. Read `useChat().final`. See Pattern 6. |