@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.
Files changed (39) hide show
  1. package/dist/esm/activities/chat/adapter.d.ts +14 -0
  2. package/dist/esm/activities/chat/adapter.js.map +1 -1
  3. package/dist/esm/activities/chat/index.d.ts +16 -6
  4. package/dist/esm/activities/chat/index.js +235 -9
  5. package/dist/esm/activities/chat/index.js.map +1 -1
  6. package/dist/esm/activities/error-payload.d.ts +0 -8
  7. package/dist/esm/activities/error-payload.js +20 -2
  8. package/dist/esm/activities/error-payload.js.map +1 -1
  9. package/dist/esm/activities/generateImage/adapter.d.ts +2 -2
  10. package/dist/esm/activities/generateImage/adapter.js.map +1 -1
  11. package/dist/esm/activities/generateVideo/adapter.d.ts +2 -2
  12. package/dist/esm/activities/generateVideo/adapter.js.map +1 -1
  13. package/dist/esm/activities/index.d.ts +1 -0
  14. package/dist/esm/activities/index.js +2 -0
  15. package/dist/esm/activities/index.js.map +1 -1
  16. package/dist/esm/activities/stream-generation-result.js +0 -2
  17. package/dist/esm/activities/stream-generation-result.js.map +1 -1
  18. package/dist/esm/activities/summarize/adapter.d.ts +4 -4
  19. package/dist/esm/activities/summarize/adapter.js.map +1 -1
  20. package/dist/esm/activities/summarize/chat-stream-summarize.d.ts +45 -0
  21. package/dist/esm/activities/summarize/chat-stream-summarize.js +148 -0
  22. package/dist/esm/activities/summarize/chat-stream-summarize.js.map +1 -0
  23. package/dist/esm/activities/summarize/index.d.ts +1 -0
  24. package/dist/esm/activities/summarize/index.js +4 -2
  25. package/dist/esm/activities/summarize/index.js.map +1 -1
  26. package/dist/esm/types.d.ts +94 -3
  27. package/package.json +2 -2
  28. package/skills/ai-core/structured-outputs/SKILL.md +92 -1
  29. package/src/activities/chat/adapter.ts +17 -0
  30. package/src/activities/chat/index.ts +368 -26
  31. package/src/activities/error-payload.ts +31 -2
  32. package/src/activities/generateImage/adapter.ts +8 -2
  33. package/src/activities/generateVideo/adapter.ts +8 -2
  34. package/src/activities/index.ts +5 -0
  35. package/src/activities/stream-generation-result.ts +4 -6
  36. package/src/activities/summarize/adapter.ts +8 -4
  37. package/src/activities/summarize/chat-stream-summarize.ts +238 -0
  38. package/src/activities/summarize/index.ts +12 -9
  39. 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. convertSchemaToJsonSchema() for manual
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: Promise<InferSchemaType<TSchema>>
230
- * - If stream is false: Promise<string>
231
- * - Otherwise (stream is true, default): AsyncIterable<StreamChunk>
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 = true,
248
+ TStream extends boolean = boolean,
236
249
  > = TSchema extends SchemaInput
237
- ? Promise<InferSchemaType<TSchema>>
238
- : TStream extends false
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 = true,
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
- // If outputSchema is provided, run agentic structured output
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 as unknown as TextActivityOptions<
1588
- AnyTextAdapter,
1589
- SchemaInput,
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 as unknown as TextActivityOptions<
1599
- AnyTextAdapter,
1600
- undefined,
1601
- false
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 as unknown as TextActivityOptions<AnyTextAdapter, undefined, true>,
1609
- ) as TextActivityResult<TSchema, TStream>
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: typeof codeField === 'string' ? codeField : undefined,
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: typeof codeField === 'string' ? codeField : undefined,
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<string, string>,
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<string, string>,
82
+ TModelSizeByName extends Record<string, string | undefined> = Record<
83
+ string,
84
+ string
85
+ >,
80
86
  > implements ImageAdapter<
81
87
  TModel,
82
88
  TProviderOptions,