Skip to content
Open
5 changes: 5 additions & 0 deletions .changeset/streaming-structured-output-chat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai': minor
---

feat: `chat({ outputSchema, stream: true })` returns `AsyncIterable<StreamChunk>` with raw JSON deltas plus a final `CUSTOM` `structured-output.complete` event carrying the validated parsed object. The existing `chat({ outputSchema })` (non-streaming) path is unchanged. Adapters expose this via a new optional `structuredOutputStream` method on `TextAdapter`. Adapters that omit the method fall back to the activity layer's `fallbackStructuredOutputStream`, which wraps the non-streaming `structuredOutput` call so adapters without native streaming JSON support still satisfy the new combination.
5 changes: 5 additions & 0 deletions .changeset/streaming-structured-output-grok.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai-grok': minor
---

feat: native streaming structured output. `GrokTextAdapter.structuredOutputStream()` issues a single Chat Completions request with `stream: true` + `response_format: { type: 'json_schema', strict: true }`, surfacing JSON deltas as `TEXT_MESSAGE_CONTENT` chunks and a final `CUSTOM` `structured-output.complete` event with the parsed object — replacing the previous two-request (streamed text → non-streamed JSON) flow when used with `chat({ outputSchema, stream: true })`.
5 changes: 5 additions & 0 deletions .changeset/streaming-structured-output-groq.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai-groq': minor
---

feat: native streaming structured output. `GroqTextAdapter.structuredOutputStream()` issues a single Chat Completions request with `stream: true` + `response_format: { type: 'json_schema', strict: true }`, surfacing JSON deltas as `TEXT_MESSAGE_CONTENT` chunks and a final `CUSTOM` `structured-output.complete` event with the parsed object — replacing the previous two-request (streamed text → non-streamed JSON) flow when used with `chat({ outputSchema, stream: true })`.
5 changes: 5 additions & 0 deletions .changeset/streaming-structured-output-openai.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai-openai': minor
---

feat: native streaming structured output. `OpenAITextAdapter.structuredOutputStream()` issues a single Responses API request with `stream: true` + `text.format: { type: 'json_schema', strict: true }`, surfacing JSON deltas as `TEXT_MESSAGE_CONTENT` chunks and a final `CUSTOM` `structured-output.complete` event with the parsed object — replacing the previous two-request (streamed text → non-streamed JSON) flow when used with `chat({ outputSchema, stream: true })`.
5 changes: 5 additions & 0 deletions .changeset/streaming-structured-output-openrouter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai-openrouter': minor
---

feat: native streaming structured output. `OpenRouterTextAdapter.structuredOutputStream()` issues a single request with `stream: true` + `response_format: { type: 'json_schema', strict: true }`, surfacing JSON deltas as `TEXT_MESSAGE_CONTENT` chunks and a final `CUSTOM` `structured-output.complete` event with the parsed object — replacing the previous two-request (streamed text → non-streamed JSON) flow when used with `chat({ outputSchema, stream: true })`.
128 changes: 118 additions & 10 deletions examples/ts-react-chat/src/routes/api.structured-output.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { createFileRoute } from '@tanstack/react-router'
import { chat } from '@tanstack/ai'
import { chat, toServerSentEventsResponse } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { grokText } from '@tanstack/ai-grok'
import { groqText } from '@tanstack/ai-groq'
import { openRouterText } from '@tanstack/ai-openrouter'
import { z } from 'zod'
import type { AnyTextAdapter, StreamChunk } from '@tanstack/ai'

const GuitarRecommendationSchema = z.object({
title: z.string().describe('Short headline for the recommendation'),
Expand All @@ -21,23 +25,127 @@ const GuitarRecommendationSchema = z.object({
nextSteps: z.array(z.string()).describe('Practical follow-up actions'),
})

type Provider = 'openai' | 'grok' | 'groq' | 'openrouter'

const StructuredOutputRequestSchema = z.object({
prompt: z.string().min(1),
provider: z.enum(['openai', 'grok', 'groq', 'openrouter']).optional(),
model: z.string().optional(),
stream: z.boolean().optional(),
})

function adapterFor(provider: Provider, model?: string): AnyTextAdapter {
switch (provider) {
case 'openai':
return openaiText((model || 'gpt-5.2') as 'gpt-5.2')
case 'grok':
return grokText(
(model || 'grok-4-1-fast-reasoning') as 'grok-4-1-fast-reasoning',
)
case 'groq':
return groqText(
(model ||
'meta-llama/llama-4-maverick-17b-128e-instruct') as 'meta-llama/llama-4-maverick-17b-128e-instruct',
)
case 'openrouter':
return openRouterText(
(model || 'anthropic/claude-opus-4.7') as 'anthropic/claude-opus-4.7',
)
}
}

// Per-provider modelOptions to opt into reasoning surfacing. Without these,
// reasoning models reason silently and the UI never sees REASONING_* events.
function reasoningOptionsFor(
provider: Provider,
model: string | undefined,
): Record<string, unknown> | undefined {
switch (provider) {
case 'openai':
// Responses API: `reasoning.summary: 'auto'` is what makes the API emit
// `response.reasoning_summary_text.delta` events. Only valid on
// reasoning models (gpt-5.x, o-series); older models (gpt-4o) reject it.
if (
model?.startsWith('gpt-5') ||
model?.startsWith('o3') ||
model?.startsWith('o4')
) {
return { reasoning: { summary: 'auto' } }
}
return undefined
case 'groq':
// Groq's Chat Completions only streams `delta.reasoning` when
// `reasoning_format: 'parsed'`. Required for gpt-oss / qwen3 / kimi-k2
// to emit reasoning during structured output (json_schema mode).
if (
model?.startsWith('openai/gpt-oss') ||
model?.startsWith('qwen') ||
model?.startsWith('moonshotai/kimi')
) {
return { reasoning_format: 'parsed' }
}
return undefined
case 'openrouter':
// OpenRouter normalises across providers. `reasoning.effort` triggers
// the upstream model's reasoning + surfaces the deltas.
return { reasoning: { effort: 'medium' } }
case 'grok':
// xAI surfaces `delta.reasoning_content` automatically on reasoning
// models (grok-3-mini, grok-4-fast-reasoning, grok-4-1-fast-reasoning).
// No request param needed.
return undefined
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export const Route = createFileRoute('/api/structured-output')({
server: {
handlers: {
POST: async ({ request }) => {
const body = await request.json()
const { prompt, model } = body as {
prompt: string
model?: string
}

try {
const parsed = StructuredOutputRequestSchema.safeParse(
await request.json(),
)
if (!parsed.success) {
return new Response(
JSON.stringify({ error: 'Invalid request body' }),
{
status: 400,
headers: { 'Content-Type': 'application/json' },
},
)
}
const { prompt, provider, model, stream } = parsed.data
const resolvedProvider: Provider = provider || 'openrouter'
const modelOptions = reasoningOptionsFor(resolvedProvider, model)

if (stream) {
const abortController = new AbortController()
request.signal.addEventListener('abort', () =>
abortController.abort(),
)
const streamIterable = chat({
adapter: adapterFor(resolvedProvider, model),
modelOptions: modelOptions as never,
messages: [{ role: 'user', content: prompt }],
outputSchema: GuitarRecommendationSchema,
stream: true,
abortController,
}) as AsyncIterable<StreamChunk>
return toServerSentEventsResponse(streamIterable, {
abortController,
})
}

const abortController = new AbortController()
request.signal.addEventListener('abort', () =>
abortController.abort(),
)
const result = await chat({
adapter: openRouterText(
(model || 'openai/gpt-5.2') as 'openai/gpt-5.2',
),
adapter: adapterFor(resolvedProvider, model),
modelOptions: modelOptions as never,
messages: [{ role: 'user', content: prompt }],
outputSchema: GuitarRecommendationSchema,
abortController,
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return new Response(JSON.stringify({ data: result }), {
Expand Down
Loading
Loading