@tanstack/ai 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/activities/chat/adapter.d.ts +14 -0
- package/dist/esm/activities/chat/adapter.js.map +1 -1
- package/dist/esm/activities/chat/index.d.ts +16 -6
- package/dist/esm/activities/chat/index.js +235 -9
- package/dist/esm/activities/chat/index.js.map +1 -1
- package/dist/esm/activities/error-payload.d.ts +0 -8
- package/dist/esm/activities/error-payload.js +20 -2
- package/dist/esm/activities/error-payload.js.map +1 -1
- package/dist/esm/activities/generateImage/adapter.d.ts +2 -2
- package/dist/esm/activities/generateImage/adapter.js.map +1 -1
- package/dist/esm/activities/generateVideo/adapter.d.ts +2 -2
- package/dist/esm/activities/generateVideo/adapter.js.map +1 -1
- package/dist/esm/activities/index.d.ts +1 -0
- package/dist/esm/activities/index.js +2 -0
- package/dist/esm/activities/index.js.map +1 -1
- package/dist/esm/activities/stream-generation-result.js +0 -2
- package/dist/esm/activities/stream-generation-result.js.map +1 -1
- package/dist/esm/activities/summarize/adapter.d.ts +4 -4
- package/dist/esm/activities/summarize/adapter.js.map +1 -1
- package/dist/esm/activities/summarize/chat-stream-summarize.d.ts +45 -0
- package/dist/esm/activities/summarize/chat-stream-summarize.js +148 -0
- package/dist/esm/activities/summarize/chat-stream-summarize.js.map +1 -0
- package/dist/esm/activities/summarize/index.d.ts +1 -0
- package/dist/esm/activities/summarize/index.js +4 -2
- package/dist/esm/activities/summarize/index.js.map +1 -1
- package/dist/esm/types.d.ts +94 -3
- package/package.json +2 -2
- package/skills/ai-core/structured-outputs/SKILL.md +92 -1
- package/src/activities/chat/adapter.ts +17 -0
- package/src/activities/chat/index.ts +368 -26
- package/src/activities/error-payload.ts +31 -2
- package/src/activities/generateImage/adapter.ts +8 -2
- package/src/activities/generateVideo/adapter.ts +8 -2
- package/src/activities/index.ts +5 -0
- package/src/activities/stream-generation-result.ts +4 -6
- package/src/activities/summarize/adapter.ts +8 -4
- package/src/activities/summarize/chat-stream-summarize.ts +238 -0
- package/src/activities/summarize/index.ts +12 -9
- package/src/types.ts +107 -3
|
@@ -4,7 +4,9 @@ description: >
|
|
|
4
4
|
Type-safe JSON schema responses from LLMs using outputSchema on chat().
|
|
5
5
|
Supports Zod, ArkType, and Valibot schemas. The adapter handles
|
|
6
6
|
provider-specific strategies transparently — never configure structured
|
|
7
|
-
output at the provider level.
|
|
7
|
+
output at the provider level. Pass stream:true alongside outputSchema for
|
|
8
|
+
incremental JSON deltas + a terminal validated object via the
|
|
9
|
+
`structured-output.complete` event. convertSchemaToJsonSchema() for manual
|
|
8
10
|
schema conversion.
|
|
9
11
|
type: sub-skill
|
|
10
12
|
library: tanstack-ai
|
|
@@ -46,6 +48,8 @@ const stream = chat({
|
|
|
46
48
|
|
|
47
49
|
When `outputSchema` is provided, `chat()` returns `Promise<InferSchemaType<TSchema>>` instead of `AsyncIterable<StreamChunk>`. The result is fully typed based on the schema.
|
|
48
50
|
|
|
51
|
+
Adding `stream: true` switches the return to `StructuredOutputStream<InferSchemaType<TSchema>>` — incremental JSON deltas plus a terminal validated object. See **Pattern 3** below.
|
|
52
|
+
|
|
49
53
|
## Core Patterns
|
|
50
54
|
|
|
51
55
|
### Pattern 1: Basic structured output with Zod
|
|
@@ -128,8 +132,94 @@ console.log(company.employees[0].role)
|
|
|
128
132
|
console.log(company.financials?.revenue)
|
|
129
133
|
```
|
|
130
134
|
|
|
135
|
+
### Pattern 3: Streaming structured output
|
|
136
|
+
|
|
137
|
+
Pass `stream: true` alongside `outputSchema` to receive incremental JSON deltas while the model generates, plus a final validated typed object. Useful for streaming partial UI (progress views, typewriter previews, partially-filled forms).
|
|
138
|
+
|
|
139
|
+
```typescript
|
|
140
|
+
import { chat } from '@tanstack/ai'
|
|
141
|
+
import { openaiText } from '@tanstack/ai-openai'
|
|
142
|
+
import { z } from 'zod'
|
|
143
|
+
|
|
144
|
+
const PersonSchema = z.object({
|
|
145
|
+
name: z.string(),
|
|
146
|
+
age: z.number(),
|
|
147
|
+
email: z.string().email(),
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
const stream = chat({
|
|
151
|
+
adapter: openaiText('gpt-5.2'),
|
|
152
|
+
messages: [
|
|
153
|
+
{ role: 'user', content: 'Extract: John Doe is 30, john@example.com' },
|
|
154
|
+
],
|
|
155
|
+
outputSchema: PersonSchema,
|
|
156
|
+
stream: true,
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
let raw = ''
|
|
160
|
+
for await (const chunk of stream) {
|
|
161
|
+
if (chunk.type === 'TEXT_MESSAGE_CONTENT') {
|
|
162
|
+
// Partial JSON text — drive progress UI only. Do NOT JSON.parse.
|
|
163
|
+
raw += chunk.delta
|
|
164
|
+
} else if (
|
|
165
|
+
chunk.type === 'CUSTOM' &&
|
|
166
|
+
chunk.name === 'structured-output.complete'
|
|
167
|
+
) {
|
|
168
|
+
// Terminal event. `chunk.value.object` is fully validated and typed
|
|
169
|
+
// against the schema you passed in — no helper or cast required.
|
|
170
|
+
chunk.value.object.name // string
|
|
171
|
+
chunk.value.object.age // number
|
|
172
|
+
chunk.value.reasoning // string | undefined (thinking models only)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
The terminal event is a `CUSTOM` chunk: `{ type: 'CUSTOM', name: 'structured-output.complete', value: { object: T, raw: string, reasoning?: string } }`. The return type of `chat({ outputSchema, stream: true })` carries `T` through to the terminal event, so a plain discriminated narrow (`chunk.type === 'CUSTOM' && chunk.name === 'structured-output.complete'`) is enough — no type guard helper needed.
|
|
178
|
+
|
|
179
|
+
**Adapter coverage for streaming:**
|
|
180
|
+
|
|
181
|
+
| Adapter | `outputSchema` + `stream: true` |
|
|
182
|
+
| ------------------------------------------------- | --------------------------------------------------------------------------------------------- |
|
|
183
|
+
| `@tanstack/ai-openai` | Native single-request stream (Responses API) |
|
|
184
|
+
| `@tanstack/ai-openrouter` | Native single-request stream |
|
|
185
|
+
| `@tanstack/ai-grok` | Native single-request stream (Chat Completions) |
|
|
186
|
+
| `@tanstack/ai-groq` | Native single-request stream (Chat Completions) |
|
|
187
|
+
| All other adapters (anthropic, gemini, ollama, …) | Fallback: runs non-streaming `structuredOutput`, emits one `structured-output.complete` event |
|
|
188
|
+
|
|
189
|
+
The consumer code is identical across providers — always read the final object off `structured-output.complete`. You only see incremental deltas when the adapter implements `structuredOutputStream` natively.
|
|
190
|
+
|
|
131
191
|
## Common Mistakes
|
|
132
192
|
|
|
193
|
+
### HIGH: Parsing streaming JSON deltas yourself
|
|
194
|
+
|
|
195
|
+
When using `chat({ outputSchema, stream: true })`, the `TEXT_MESSAGE_CONTENT` chunks contain _partial_ JSON fragments — they are not valid JSON until the stream completes. Always read the validated object from the terminal `structured-output.complete` event. Validation runs once, on the complete payload.
|
|
196
|
+
|
|
197
|
+
```typescript
|
|
198
|
+
// WRONG -- partial JSON, throws SyntaxError mid-stream, no schema validation
|
|
199
|
+
for await (const chunk of stream) {
|
|
200
|
+
if (chunk.type === 'TEXT_MESSAGE_CONTENT') {
|
|
201
|
+
const obj = JSON.parse(chunk.delta) // ❌ partial, invalid
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// CORRECT -- accumulate deltas only for UX progress; trust the terminal event
|
|
206
|
+
let raw = ''
|
|
207
|
+
for await (const chunk of stream) {
|
|
208
|
+
if (chunk.type === 'TEXT_MESSAGE_CONTENT') {
|
|
209
|
+
raw += chunk.delta // optional: render a "streaming JSON" preview
|
|
210
|
+
} else if (
|
|
211
|
+
chunk.type === 'CUSTOM' &&
|
|
212
|
+
chunk.name === 'structured-output.complete'
|
|
213
|
+
) {
|
|
214
|
+
const result = chunk.value.object // ✅ typed and validated
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
If you need progressive _parsed_ state (e.g. show fields as they arrive), use a partial-JSON parser on the accumulated `raw` string at render time — but do NOT treat the result as schema-validated; only the terminal event is.
|
|
220
|
+
|
|
221
|
+
Source: maintainer interview
|
|
222
|
+
|
|
133
223
|
### HIGH: Trying to implement provider-specific structured output strategies
|
|
134
224
|
|
|
135
225
|
The adapter already handles provider differences (OpenAI uses `response_format`, Anthropic uses tool-based extraction, Gemini uses `responseSchema`). Never configure this yourself.
|
|
@@ -201,3 +291,4 @@ Source: maintainer interview
|
|
|
201
291
|
## Cross-References
|
|
202
292
|
|
|
203
293
|
- See also: ai-core/adapter-configuration/SKILL.md -- Adapter handles structured output strategy transparently
|
|
294
|
+
- See also: ai-core/chat-experience/SKILL.md -- Consuming `StreamChunk` events on the client (the streaming variant uses the same chunk model plus the terminal `structured-output.complete` custom event)
|
|
@@ -100,6 +100,23 @@ export interface TextAdapter<
|
|
|
100
100
|
structuredOutput: (
|
|
101
101
|
options: StructuredOutputOptions<TProviderOptions>,
|
|
102
102
|
) => Promise<StructuredOutputResult<unknown>>
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Stream structured output using the provider's native streaming structured
|
|
106
|
+
* output API (stream + response_format json_schema in a single request).
|
|
107
|
+
*
|
|
108
|
+
* Optional — adapters without native streaming JSON omit this method and the
|
|
109
|
+
* activity layer synthesizes a stream around the non-streaming
|
|
110
|
+
* `structuredOutput` call.
|
|
111
|
+
*
|
|
112
|
+
* Implementations must emit standard AG-UI lifecycle events (RUN_STARTED,
|
|
113
|
+
* TEXT_MESSAGE_*, RUN_FINISHED) carrying raw JSON text deltas, plus a final
|
|
114
|
+
* `CUSTOM` event named `structured-output.complete` whose `value` is
|
|
115
|
+
* `{ object, raw, reasoning? }`.
|
|
116
|
+
*/
|
|
117
|
+
structuredOutputStream?: (
|
|
118
|
+
options: StructuredOutputOptions<TProviderOptions>,
|
|
119
|
+
) => AsyncIterable<StreamChunk>
|
|
103
120
|
}
|
|
104
121
|
|
|
105
122
|
/**
|
|
@@ -9,6 +9,7 @@ import { devtoolsMiddleware } from '@tanstack/ai-event-client'
|
|
|
9
9
|
import { stripToSpecMiddleware } from '../../strip-to-spec-middleware'
|
|
10
10
|
import { streamToText } from '../../stream-to-response.js'
|
|
11
11
|
import { resolveDebugOption } from '../../logger/resolve'
|
|
12
|
+
import { EventType } from '../../types'
|
|
12
13
|
import { LazyToolManager } from './tools/lazy-tool-manager'
|
|
13
14
|
import {
|
|
14
15
|
MiddlewareAbortError,
|
|
@@ -28,7 +29,7 @@ import type {
|
|
|
28
29
|
ClientToolRequest,
|
|
29
30
|
ToolResult,
|
|
30
31
|
} from './tools/tool-calls'
|
|
31
|
-
import type { AnyTextAdapter } from './adapter'
|
|
32
|
+
import type { AnyTextAdapter, StructuredOutputOptions } from './adapter'
|
|
32
33
|
import type {
|
|
33
34
|
AgentLoopStrategy,
|
|
34
35
|
ConstrainedModelMessage,
|
|
@@ -38,6 +39,8 @@ import type {
|
|
|
38
39
|
RunFinishedEvent,
|
|
39
40
|
SchemaInput,
|
|
40
41
|
StreamChunk,
|
|
42
|
+
StructuredOutputCompleteEvent,
|
|
43
|
+
StructuredOutputStream,
|
|
41
44
|
TextMessageContentEvent,
|
|
42
45
|
TextOptions,
|
|
43
46
|
Tool,
|
|
@@ -226,16 +229,28 @@ export function createChatOptions<
|
|
|
226
229
|
|
|
227
230
|
/**
|
|
228
231
|
* Result type for the text activity.
|
|
229
|
-
* - If outputSchema is provided:
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
+
* - If outputSchema is provided AND stream is explicitly true:
|
|
233
|
+
* StructuredOutputStream<InferSchemaType<TSchema>> — yields raw JSON deltas
|
|
234
|
+
* via TEXT_MESSAGE_CONTENT plus a terminal StructuredOutputCompleteEvent
|
|
235
|
+
* carrying the validated object.
|
|
236
|
+
* - If outputSchema is provided without explicit stream:true:
|
|
237
|
+
* Promise<InferSchemaType<TSchema>>.
|
|
238
|
+
* - If stream is explicitly false (no schema): Promise<string>.
|
|
239
|
+
* - Otherwise (default): AsyncIterable<StreamChunk>.
|
|
240
|
+
*
|
|
241
|
+
* `[TStream] extends [true]` is used (not `TStream extends true`) so that the
|
|
242
|
+
* default `boolean` value of `TStream` does *not* match the streaming branch.
|
|
243
|
+
* Without this, plain `chat({ outputSchema })` would type as a stream while
|
|
244
|
+
* the runtime returns a Promise — see issue #526.
|
|
232
245
|
*/
|
|
233
246
|
export type TextActivityResult<
|
|
234
247
|
TSchema extends SchemaInput | undefined,
|
|
235
|
-
TStream extends boolean =
|
|
248
|
+
TStream extends boolean = boolean,
|
|
236
249
|
> = TSchema extends SchemaInput
|
|
237
|
-
?
|
|
238
|
-
|
|
250
|
+
? [TStream] extends [true]
|
|
251
|
+
? StructuredOutputStream<InferSchemaType<TSchema>>
|
|
252
|
+
: Promise<InferSchemaType<TSchema>>
|
|
253
|
+
: [TStream] extends [false]
|
|
239
254
|
? Promise<string>
|
|
240
255
|
: AsyncIterable<StreamChunk>
|
|
241
256
|
|
|
@@ -1575,38 +1590,46 @@ class TextEngine<
|
|
|
1575
1590
|
export function chat<
|
|
1576
1591
|
TAdapter extends AnyTextAdapter,
|
|
1577
1592
|
TSchema extends SchemaInput | undefined = undefined,
|
|
1578
|
-
TStream extends boolean =
|
|
1593
|
+
TStream extends boolean = boolean,
|
|
1579
1594
|
>(
|
|
1580
1595
|
options: TextActivityOptions<TAdapter, TSchema, TStream>,
|
|
1581
1596
|
): TextActivityResult<TSchema, TStream> {
|
|
1582
1597
|
const { outputSchema, stream } = options
|
|
1583
1598
|
|
|
1584
|
-
//
|
|
1599
|
+
// outputSchema + stream:true is the only branch that streams structured
|
|
1600
|
+
// output. Without an explicit `stream: true`, schema-bearing calls run the
|
|
1601
|
+
// agent loop and resolve to a typed Promise<InferSchemaType<TSchema>>.
|
|
1602
|
+
if (outputSchema && stream === true) {
|
|
1603
|
+
return runStreamingStructuredOutput({
|
|
1604
|
+
...options,
|
|
1605
|
+
outputSchema,
|
|
1606
|
+
stream,
|
|
1607
|
+
}) as TextActivityResult<TSchema, TStream>
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
// If outputSchema is provided, run agentic structured output (Promise<T>)
|
|
1585
1611
|
if (outputSchema) {
|
|
1586
|
-
return runAgenticStructuredOutput(
|
|
1587
|
-
options
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
boolean
|
|
1591
|
-
>,
|
|
1592
|
-
) as TextActivityResult<TSchema, TStream>
|
|
1612
|
+
return runAgenticStructuredOutput({
|
|
1613
|
+
...options,
|
|
1614
|
+
outputSchema,
|
|
1615
|
+
}) as TextActivityResult<TSchema, TStream>
|
|
1593
1616
|
}
|
|
1594
1617
|
|
|
1595
1618
|
// If stream is explicitly false, run non-streaming text
|
|
1596
1619
|
if (stream === false) {
|
|
1597
|
-
return runNonStreamingText(
|
|
1598
|
-
options
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
>,
|
|
1603
|
-
) as TextActivityResult<TSchema, TStream>
|
|
1620
|
+
return runNonStreamingText({
|
|
1621
|
+
...options,
|
|
1622
|
+
outputSchema: undefined,
|
|
1623
|
+
stream,
|
|
1624
|
+
}) as TextActivityResult<TSchema, TStream>
|
|
1604
1625
|
}
|
|
1605
1626
|
|
|
1606
1627
|
// Otherwise, run streaming text (default)
|
|
1607
|
-
return runStreamingText(
|
|
1608
|
-
options
|
|
1609
|
-
|
|
1628
|
+
return runStreamingText({
|
|
1629
|
+
...options,
|
|
1630
|
+
outputSchema: undefined,
|
|
1631
|
+
stream,
|
|
1632
|
+
}) as TextActivityResult<TSchema, TStream>
|
|
1610
1633
|
}
|
|
1611
1634
|
|
|
1612
1635
|
/**
|
|
@@ -1741,6 +1764,325 @@ async function runAgenticStructuredOutput<TSchema extends SchemaInput>(
|
|
|
1741
1764
|
return result.data as InferSchemaType<TSchema>
|
|
1742
1765
|
}
|
|
1743
1766
|
|
|
1767
|
+
/**
|
|
1768
|
+
* Synthesize a streaming structured-output stream by wrapping a non-streaming
|
|
1769
|
+
* `structuredOutput` call. Used when an adapter doesn't implement
|
|
1770
|
+
* `structuredOutputStream` natively.
|
|
1771
|
+
*/
|
|
1772
|
+
async function* fallbackStructuredOutputStream(
|
|
1773
|
+
adapter: AnyTextAdapter,
|
|
1774
|
+
options: StructuredOutputOptions<Record<string, unknown>>,
|
|
1775
|
+
): AsyncIterable<StreamChunk> {
|
|
1776
|
+
const { chatOptions } = options
|
|
1777
|
+
const runId = chatOptions.runId ?? `mock-${Date.now()}`
|
|
1778
|
+
const threadId = chatOptions.threadId ?? `mock-${Date.now()}`
|
|
1779
|
+
const messageId = `mock-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
1780
|
+
const model = chatOptions.model
|
|
1781
|
+
const timestamp = Date.now()
|
|
1782
|
+
|
|
1783
|
+
yield {
|
|
1784
|
+
type: EventType.RUN_STARTED,
|
|
1785
|
+
runId,
|
|
1786
|
+
threadId,
|
|
1787
|
+
model,
|
|
1788
|
+
timestamp,
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
let result: { data: unknown; rawText: string }
|
|
1792
|
+
try {
|
|
1793
|
+
result = await adapter.structuredOutput(options)
|
|
1794
|
+
} catch (error) {
|
|
1795
|
+
const message = error instanceof Error ? error.message : 'Unknown error'
|
|
1796
|
+
yield {
|
|
1797
|
+
type: EventType.RUN_ERROR,
|
|
1798
|
+
runId,
|
|
1799
|
+
model,
|
|
1800
|
+
timestamp,
|
|
1801
|
+
message,
|
|
1802
|
+
error: { message },
|
|
1803
|
+
}
|
|
1804
|
+
return
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
yield {
|
|
1808
|
+
type: EventType.TEXT_MESSAGE_START,
|
|
1809
|
+
messageId,
|
|
1810
|
+
role: 'assistant',
|
|
1811
|
+
model,
|
|
1812
|
+
timestamp,
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
yield {
|
|
1816
|
+
type: EventType.TEXT_MESSAGE_CONTENT,
|
|
1817
|
+
messageId,
|
|
1818
|
+
delta: result.rawText,
|
|
1819
|
+
model,
|
|
1820
|
+
timestamp,
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
yield {
|
|
1824
|
+
type: EventType.TEXT_MESSAGE_END,
|
|
1825
|
+
messageId,
|
|
1826
|
+
model,
|
|
1827
|
+
timestamp,
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
yield {
|
|
1831
|
+
type: EventType.CUSTOM,
|
|
1832
|
+
name: 'structured-output.complete',
|
|
1833
|
+
value: { object: result.data, raw: result.rawText },
|
|
1834
|
+
model,
|
|
1835
|
+
timestamp,
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
yield {
|
|
1839
|
+
type: EventType.RUN_FINISHED,
|
|
1840
|
+
runId,
|
|
1841
|
+
threadId,
|
|
1842
|
+
model,
|
|
1843
|
+
timestamp,
|
|
1844
|
+
finishReason: 'stop',
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
/**
|
|
1849
|
+
* Run streaming structured output:
|
|
1850
|
+
* - Without tools: call adapter.structuredOutputStream directly (single
|
|
1851
|
+
* provider request emitting JSON deltas + a final CUSTOM event).
|
|
1852
|
+
* - With tools: run the agent loop, yield its non-terminal chunks, then call
|
|
1853
|
+
* structuredOutputStream on the final messages so the structured stream's
|
|
1854
|
+
* own RUN_STARTED/RUN_FINISHED bracket the run.
|
|
1855
|
+
*
|
|
1856
|
+
* Validates the parsed object against the original Standard Schema (if
|
|
1857
|
+
* applicable) when forwarding the final `structured-output.complete` event.
|
|
1858
|
+
*
|
|
1859
|
+
* Pre-flight validation (missing schema, unconvertible schema) throws
|
|
1860
|
+
* synchronously at call time rather than as a yielded RUN_ERROR mid-stream —
|
|
1861
|
+
* those are programmer errors, not runtime conditions.
|
|
1862
|
+
*/
|
|
1863
|
+
function runStreamingStructuredOutput<TSchema extends SchemaInput>(
|
|
1864
|
+
options: TextActivityOptions<AnyTextAdapter, TSchema, true>,
|
|
1865
|
+
): StructuredOutputStream<InferSchemaType<TSchema>> {
|
|
1866
|
+
const { outputSchema } = options
|
|
1867
|
+
|
|
1868
|
+
if (!outputSchema) {
|
|
1869
|
+
throw new Error('outputSchema is required for streaming structured output')
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
// forStructuredOutput strict-converts the schema once at the activity
|
|
1873
|
+
// boundary. Adapters can re-convert if their wire format diverges, but the
|
|
1874
|
+
// default flow hands them a strict-ready schema.
|
|
1875
|
+
const jsonSchema = convertSchemaToJsonSchema(outputSchema, {
|
|
1876
|
+
forStructuredOutput: true,
|
|
1877
|
+
})
|
|
1878
|
+
if (!jsonSchema) {
|
|
1879
|
+
throw new Error('Failed to convert output schema to JSON Schema')
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
// The implementation generator yields the broader internal type
|
|
1883
|
+
// (`StreamChunk | StructuredOutputCompleteEvent<T>`) so agent-loop
|
|
1884
|
+
// CustomEvents can flow through; the public-facing type narrows to
|
|
1885
|
+
// `Exclude<StreamChunk, CustomEvent> | StructuredOutputCompleteEvent<T>`
|
|
1886
|
+
// which lets consumers narrow `chunk.value` cleanly. The widen→narrow
|
|
1887
|
+
// is contained here so consumers see only the strict type.
|
|
1888
|
+
return runStreamingStructuredOutputImpl(
|
|
1889
|
+
options,
|
|
1890
|
+
jsonSchema,
|
|
1891
|
+
) as StructuredOutputStream<InferSchemaType<TSchema>>
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
/**
|
|
1895
|
+
* Internal generator return type — broader than the public
|
|
1896
|
+
* `StructuredOutputStream<T>`. The public type pins three tagged `CUSTOM`
|
|
1897
|
+
* events (`structured-output.complete`, `approval-requested`,
|
|
1898
|
+
* `tool-input-available`) so consumers can narrow `chunk.value` cleanly by
|
|
1899
|
+
* literal `name`. At runtime, tools can also emit arbitrary user-defined
|
|
1900
|
+
* `CustomEvent`s through the `emitCustomEvent` context API; those flow
|
|
1901
|
+
* through this generator with `name: string` and are widened out at the
|
|
1902
|
+
* public boundary because keeping them would collapse the typed narrow back
|
|
1903
|
+
* to `any`. The cast inside `runStreamingStructuredOutput` is where that
|
|
1904
|
+
* widening happens.
|
|
1905
|
+
*/
|
|
1906
|
+
type StructuredOutputStreamInternal<T> = AsyncIterable<
|
|
1907
|
+
StreamChunk | StructuredOutputCompleteEvent<T>
|
|
1908
|
+
>
|
|
1909
|
+
|
|
1910
|
+
async function* runStreamingStructuredOutputImpl<TSchema extends SchemaInput>(
|
|
1911
|
+
options: TextActivityOptions<AnyTextAdapter, TSchema, true>,
|
|
1912
|
+
jsonSchema: NonNullable<ReturnType<typeof convertSchemaToJsonSchema>>,
|
|
1913
|
+
): StructuredOutputStreamInternal<InferSchemaType<TSchema>> {
|
|
1914
|
+
const { adapter, outputSchema, middleware, context, debug, ...textOptions } =
|
|
1915
|
+
options
|
|
1916
|
+
const model = adapter.model
|
|
1917
|
+
const logger = resolveDebugOption(debug)
|
|
1918
|
+
const runId = textOptions.runId
|
|
1919
|
+
|
|
1920
|
+
// Inputs may be UIMessages (from useChat) or ModelMessages (from server-side
|
|
1921
|
+
// callers). The agent-loop branch converts via TextEngine; the no-tools
|
|
1922
|
+
// branch must convert here so the adapter sees a uniform ModelMessage shape.
|
|
1923
|
+
let finalMessages = convertMessagesToModelMessages(textOptions.messages ?? [])
|
|
1924
|
+
|
|
1925
|
+
if (textOptions.tools?.length) {
|
|
1926
|
+
const engine = new TextEngine(
|
|
1927
|
+
{
|
|
1928
|
+
adapter,
|
|
1929
|
+
params: { ...textOptions, model, logger, messages: finalMessages },
|
|
1930
|
+
middleware,
|
|
1931
|
+
context,
|
|
1932
|
+
},
|
|
1933
|
+
logger,
|
|
1934
|
+
)
|
|
1935
|
+
|
|
1936
|
+
// The structured-output stream emits its own RUN_STARTED + RUN_FINISHED
|
|
1937
|
+
// pair to bracket the run — drop both from the engine's output so
|
|
1938
|
+
// consumers see exactly one terminal lifecycle pair.
|
|
1939
|
+
let agentLoopErrored = false
|
|
1940
|
+
try {
|
|
1941
|
+
for await (const chunk of engine.run()) {
|
|
1942
|
+
if (chunk.type === 'RUN_STARTED' || chunk.type === 'RUN_FINISHED') {
|
|
1943
|
+
continue
|
|
1944
|
+
}
|
|
1945
|
+
if (chunk.type === 'RUN_ERROR') {
|
|
1946
|
+
// The engine yielded RUN_ERROR without throwing (provider error mid
|
|
1947
|
+
// agent loop). Forward it once and short-circuit before invoking
|
|
1948
|
+
// structuredOutputStream — otherwise consumers would see a confusing
|
|
1949
|
+
// RUN_ERROR → RUN_STARTED → structured-output.complete sequence and
|
|
1950
|
+
// we would bill another provider call after a failed run.
|
|
1951
|
+
agentLoopErrored = true
|
|
1952
|
+
yield chunk
|
|
1953
|
+
continue
|
|
1954
|
+
}
|
|
1955
|
+
yield chunk
|
|
1956
|
+
}
|
|
1957
|
+
} catch (engineError) {
|
|
1958
|
+
const message = (engineError as Error).message || 'Agent loop failed'
|
|
1959
|
+
logger.errors('runStreamingStructuredOutput agent loop failed', {
|
|
1960
|
+
error: engineError,
|
|
1961
|
+
source: 'runStreamingStructuredOutput',
|
|
1962
|
+
})
|
|
1963
|
+
yield {
|
|
1964
|
+
type: EventType.RUN_ERROR,
|
|
1965
|
+
runId,
|
|
1966
|
+
model,
|
|
1967
|
+
timestamp: Date.now(),
|
|
1968
|
+
message,
|
|
1969
|
+
code: 'agent-loop-failed',
|
|
1970
|
+
error: { message, code: 'agent-loop-failed' },
|
|
1971
|
+
}
|
|
1972
|
+
return
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
if (agentLoopErrored) {
|
|
1976
|
+
return
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
finalMessages = engine.getMessages()
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
const {
|
|
1983
|
+
tools: _tools,
|
|
1984
|
+
agentLoopStrategy: _als,
|
|
1985
|
+
...structuredTextOptions
|
|
1986
|
+
} = textOptions
|
|
1987
|
+
|
|
1988
|
+
logger.request(
|
|
1989
|
+
`activity=chat-structured-stream provider=${adapter.name} model=${model} messages=${finalMessages.length}`,
|
|
1990
|
+
{
|
|
1991
|
+
provider: adapter.name,
|
|
1992
|
+
model,
|
|
1993
|
+
messageCount: finalMessages.length,
|
|
1994
|
+
},
|
|
1995
|
+
)
|
|
1996
|
+
|
|
1997
|
+
// Adapters consume the abort signal via `chatOptions.request?.signal` and
|
|
1998
|
+
// pass it to the underlying network call. Without this, aborting the SSE
|
|
1999
|
+
// response never cancels the upstream provider request and a terminal
|
|
2000
|
+
// structured-output.complete event still gets yielded after stop.
|
|
2001
|
+
const structuredChatOptions = {
|
|
2002
|
+
...structuredTextOptions,
|
|
2003
|
+
model,
|
|
2004
|
+
messages: finalMessages,
|
|
2005
|
+
logger,
|
|
2006
|
+
request: textOptions.abortController
|
|
2007
|
+
? { signal: textOptions.abortController.signal }
|
|
2008
|
+
: undefined,
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
// Adapters that don't implement structuredOutputStream natively fall back
|
|
2012
|
+
// to wrapping the non-streaming `structuredOutput` — `fallbackStructuredOutputStream`
|
|
2013
|
+
// synthesizes the AG-UI lifecycle events around it.
|
|
2014
|
+
const stream = adapter.structuredOutputStream
|
|
2015
|
+
? adapter.structuredOutputStream({
|
|
2016
|
+
chatOptions: structuredChatOptions,
|
|
2017
|
+
outputSchema: jsonSchema,
|
|
2018
|
+
})
|
|
2019
|
+
: fallbackStructuredOutputStream(adapter, {
|
|
2020
|
+
chatOptions: structuredChatOptions,
|
|
2021
|
+
outputSchema: jsonSchema,
|
|
2022
|
+
})
|
|
2023
|
+
|
|
2024
|
+
for await (const chunk of stream) {
|
|
2025
|
+
if (
|
|
2026
|
+
chunk.type === EventType.CUSTOM &&
|
|
2027
|
+
chunk.name === 'structured-output.complete'
|
|
2028
|
+
) {
|
|
2029
|
+
const value = chunk.value as {
|
|
2030
|
+
object: unknown
|
|
2031
|
+
raw: string
|
|
2032
|
+
reasoning?: string
|
|
2033
|
+
}
|
|
2034
|
+
if (isStandardSchema(outputSchema)) {
|
|
2035
|
+
try {
|
|
2036
|
+
const validated = parseWithStandardSchema<InferSchemaType<TSchema>>(
|
|
2037
|
+
outputSchema,
|
|
2038
|
+
value.object,
|
|
2039
|
+
)
|
|
2040
|
+
yield {
|
|
2041
|
+
...chunk,
|
|
2042
|
+
// Forward `reasoning` through schema validation so consumers that
|
|
2043
|
+
// only listen for the terminal event don't lose chain-of-thought.
|
|
2044
|
+
value: {
|
|
2045
|
+
object: validated,
|
|
2046
|
+
raw: value.raw,
|
|
2047
|
+
...(value.reasoning ? { reasoning: value.reasoning } : {}),
|
|
2048
|
+
},
|
|
2049
|
+
}
|
|
2050
|
+
continue
|
|
2051
|
+
} catch (err) {
|
|
2052
|
+
const message = (err as Error).message || 'Schema validation failed'
|
|
2053
|
+
logger.errors(
|
|
2054
|
+
'runStreamingStructuredOutput schema validation failed',
|
|
2055
|
+
{
|
|
2056
|
+
error: err,
|
|
2057
|
+
source: 'runStreamingStructuredOutput',
|
|
2058
|
+
// Include reasoning in error meta so post-mortems can recover
|
|
2059
|
+
// what the model thought through before producing invalid JSON.
|
|
2060
|
+
...(value.reasoning ? { reasoning: value.reasoning } : {}),
|
|
2061
|
+
},
|
|
2062
|
+
)
|
|
2063
|
+
yield {
|
|
2064
|
+
type: EventType.RUN_ERROR,
|
|
2065
|
+
runId,
|
|
2066
|
+
model: chunk.model ?? model,
|
|
2067
|
+
timestamp: chunk.timestamp ?? Date.now(),
|
|
2068
|
+
message,
|
|
2069
|
+
code: 'schema-validation',
|
|
2070
|
+
error: {
|
|
2071
|
+
message,
|
|
2072
|
+
code: 'schema-validation',
|
|
2073
|
+
...(value.reasoning ? { reasoning: value.reasoning } : {}),
|
|
2074
|
+
},
|
|
2075
|
+
}
|
|
2076
|
+
return
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
yield chunk
|
|
2080
|
+
continue
|
|
2081
|
+
}
|
|
2082
|
+
yield chunk
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
|
|
1744
2086
|
// Re-export adapter types
|
|
1745
2087
|
export type {
|
|
1746
2088
|
TextAdapter,
|
|
@@ -5,16 +5,45 @@
|
|
|
5
5
|
* Accepts Error instances, objects with string-ish `message`/`code`, or bare
|
|
6
6
|
* strings; always returns a shape safe to serialize. Never leaks the full
|
|
7
7
|
* error object (which may carry request/response state from an SDK).
|
|
8
|
+
*
|
|
9
|
+
* Abort-shaped errors (DOM `AbortError`, OpenAI `APIUserAbortError`,
|
|
10
|
+
* OpenRouter `RequestAbortedError`) are normalized to a stable
|
|
11
|
+
* `{ message: 'Request aborted', code: 'aborted' }` shape so callers can
|
|
12
|
+
* discriminate user-initiated cancellation from other failures without
|
|
13
|
+
* matching on provider-specific message strings.
|
|
8
14
|
*/
|
|
15
|
+
const ABORT_ERROR_NAMES = new Set([
|
|
16
|
+
'AbortError',
|
|
17
|
+
'APIUserAbortError',
|
|
18
|
+
'RequestAbortedError',
|
|
19
|
+
])
|
|
20
|
+
|
|
21
|
+
// HTTP status codes carried as numbers (e.g. `error.status = 429`) are a
|
|
22
|
+
// common variant on SDK error classes; coerce so the resulting `code` field
|
|
23
|
+
// is stable as a string for downstream consumers.
|
|
24
|
+
function normalizeCode(codeField: unknown): string | undefined {
|
|
25
|
+
if (typeof codeField === 'string') return codeField
|
|
26
|
+
if (typeof codeField === 'number' && Number.isFinite(codeField)) {
|
|
27
|
+
return String(codeField)
|
|
28
|
+
}
|
|
29
|
+
return undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
9
32
|
export function toRunErrorPayload(
|
|
10
33
|
error: unknown,
|
|
11
34
|
fallbackMessage = 'Unknown error occurred',
|
|
12
35
|
): { message: string; code: string | undefined } {
|
|
36
|
+
if (error && typeof error === 'object') {
|
|
37
|
+
const name = (error as { name?: unknown }).name
|
|
38
|
+
if (typeof name === 'string' && ABORT_ERROR_NAMES.has(name)) {
|
|
39
|
+
return { message: 'Request aborted', code: 'aborted' }
|
|
40
|
+
}
|
|
41
|
+
}
|
|
13
42
|
if (error instanceof Error) {
|
|
14
43
|
const codeField = (error as Error & { code?: unknown }).code
|
|
15
44
|
return {
|
|
16
45
|
message: error.message || fallbackMessage,
|
|
17
|
-
code:
|
|
46
|
+
code: normalizeCode(codeField),
|
|
18
47
|
}
|
|
19
48
|
}
|
|
20
49
|
if (typeof error === 'object' && error !== null) {
|
|
@@ -25,7 +54,7 @@ export function toRunErrorPayload(
|
|
|
25
54
|
typeof messageField === 'string' && messageField.length > 0
|
|
26
55
|
? messageField
|
|
27
56
|
: fallbackMessage,
|
|
28
|
-
code:
|
|
57
|
+
code: normalizeCode(codeField),
|
|
29
58
|
}
|
|
30
59
|
}
|
|
31
60
|
if (typeof error === 'string' && error.length > 0) {
|
|
@@ -34,7 +34,10 @@ export interface ImageAdapter<
|
|
|
34
34
|
TModel extends string = string,
|
|
35
35
|
TProviderOptions extends object = Record<string, unknown>,
|
|
36
36
|
TModelProviderOptionsByName extends Record<string, any> = Record<string, any>,
|
|
37
|
-
TModelSizeByName extends Record<string, string> = Record<
|
|
37
|
+
TModelSizeByName extends Record<string, string | undefined> = Record<
|
|
38
|
+
string,
|
|
39
|
+
string
|
|
40
|
+
>,
|
|
38
41
|
> {
|
|
39
42
|
/** Discriminator for adapter kind - used by generate() to determine API shape */
|
|
40
43
|
readonly kind: 'image'
|
|
@@ -76,7 +79,10 @@ export abstract class BaseImageAdapter<
|
|
|
76
79
|
TModel extends string = string,
|
|
77
80
|
TProviderOptions extends object = Record<string, unknown>,
|
|
78
81
|
TModelProviderOptionsByName extends Record<string, any> = Record<string, any>,
|
|
79
|
-
TModelSizeByName extends Record<string, string> = Record<
|
|
82
|
+
TModelSizeByName extends Record<string, string | undefined> = Record<
|
|
83
|
+
string,
|
|
84
|
+
string
|
|
85
|
+
>,
|
|
80
86
|
> implements ImageAdapter<
|
|
81
87
|
TModel,
|
|
82
88
|
TProviderOptions,
|