Skip to content
Open
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
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-base.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/openai-base': minor
---

feat: centralised `structuredOutputStream` on both `OpenAICompatibleChatCompletionsTextAdapter` and `OpenAICompatibleResponsesTextAdapter`. Subclasses (`@tanstack/ai-openai`, `@tanstack/ai-grok`, `@tanstack/ai-groq`, `@tanstack/ai-openrouter`) inherit a single-request streaming structured-output path — Chat Completions uses `response_format: json_schema` + `stream: true`; Responses uses `text.format: json_schema` + `stream: true`. Reasoning is surfaced via the existing `extractReasoning` hook (Chat Completions) or Responses-API event-type discrimination (Responses), and final parsed JSON flows through the existing `transformStructuredOutput` hook. A new protected `isAbortError(error)` hook on both bases duck-types abort detection so `RUN_ERROR { code: 'aborted' }` is emitted consistently across SDK error types — subclasses with proprietary error classes (e.g. `@openrouter/sdk`'s `RequestAbortedError`) override.
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.6') as 'anthropic/claude-opus-4.6',
)
}
}

// 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