@tanstack/ai 0.52.3 → 0.54.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/README.md +14 -13
- package/dist/esm/activities/chat/index.js +5 -3
- package/dist/esm/activities/chat/index.js.map +1 -1
- package/dist/esm/activities/generateLiveVideo/adapter.d.ts +69 -0
- package/dist/esm/activities/generateLiveVideo/adapter.js +23 -0
- package/dist/esm/activities/generateLiveVideo/adapter.js.map +1 -0
- package/dist/esm/activities/generateLiveVideo/index.d.ts +99 -0
- package/dist/esm/activities/generateLiveVideo/index.js +162 -0
- package/dist/esm/activities/generateLiveVideo/index.js.map +1 -0
- package/dist/esm/activities/generateVideo/index.js +3 -1
- package/dist/esm/activities/generateVideo/index.js.map +1 -1
- package/dist/esm/activities/generateWorld/adapter.d.ts +69 -0
- package/dist/esm/activities/generateWorld/adapter.js +23 -0
- package/dist/esm/activities/generateWorld/adapter.js.map +1 -0
- package/dist/esm/activities/generateWorld/index.d.ts +99 -0
- package/dist/esm/activities/generateWorld/index.js +162 -0
- package/dist/esm/activities/generateWorld/index.js.map +1 -0
- package/dist/esm/activities/index.d.ts +8 -2
- package/dist/esm/activities/index.js +11 -7
- package/dist/esm/activities/middleware/types.d.ts +1 -1
- package/dist/esm/activities/summarize/chat-stream-summarize.js +2 -1
- package/dist/esm/activities/summarize/chat-stream-summarize.js.map +1 -1
- package/dist/esm/byok/define-provider.d.ts +6 -0
- package/dist/esm/byok/define-provider.js +2 -1
- package/dist/esm/byok/define-provider.js.map +1 -1
- package/dist/esm/byok/get-key.d.ts +7 -0
- package/dist/esm/byok/get-key.js +8 -1
- package/dist/esm/byok/get-key.js.map +1 -1
- package/dist/esm/byok/server.d.ts +1 -1
- package/dist/esm/byok/server.js +2 -2
- package/dist/esm/client.d.ts +4 -2
- package/dist/esm/client.js +3 -1
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/index.d.ts +4 -2
- package/dist/esm/index.js +3 -1
- package/dist/esm/middlewares/otel.js +3 -1
- package/dist/esm/middlewares/otel.js.map +1 -1
- package/dist/esm/types.d.ts +112 -0
- package/package.json +2 -2
- package/skills/ai-core/adapter-configuration/SKILL.md +103 -54
- package/skills/ai-core/adapter-configuration/references/anthropic-adapter.md +39 -21
- package/skills/ai-core/adapter-configuration/references/byteplus-adapter.md +5 -0
- package/skills/ai-core/adapter-configuration/references/gemini-adapter.md +14 -6
- package/skills/ai-core/adapter-configuration/references/grok-adapter.md +33 -25
- package/skills/ai-core/adapter-configuration/references/groq-adapter.md +7 -2
- package/skills/ai-core/adapter-configuration/references/ollama-adapter.md +25 -12
- package/skills/ai-core/adapter-configuration/references/openai-adapter.md +19 -9
- package/skills/ai-core/adapter-configuration/references/openrouter-adapter.md +34 -21
- package/skills/ai-core/ag-ui-protocol/SKILL.md +16 -10
- package/skills/ai-core/chat-experience/SKILL.md +228 -108
- package/skills/ai-core/client-persistence/SKILL.md +21 -9
- package/skills/ai-core/custom-backend-integration/SKILL.md +86 -52
- package/skills/ai-core/debug-logging/SKILL.md +100 -18
- package/skills/ai-core/locks/SKILL.md +35 -7
- package/skills/ai-core/media-generation/SKILL.md +114 -49
- package/skills/ai-core/middleware/SKILL.md +174 -69
- package/skills/ai-core/structured-outputs/SKILL.md +99 -49
- package/skills/ai-core/tool-calling/SKILL.md +245 -158
- package/src/activities/chat/index.ts +6 -7
- package/src/activities/generateLiveVideo/adapter.ts +99 -0
- package/src/activities/generateLiveVideo/index.ts +339 -0
- package/src/activities/generateVideo/index.ts +3 -4
- package/src/activities/generateWorld/adapter.ts +96 -0
- package/src/activities/generateWorld/index.ts +339 -0
- package/src/activities/index.ts +44 -0
- package/src/activities/middleware/types.ts +2 -0
- package/src/activities/summarize/chat-stream-summarize.ts +2 -0
- package/src/byok/define-provider.ts +7 -0
- package/src/byok/get-key.ts +18 -0
- package/src/byok/server.ts +1 -1
- package/src/client.ts +8 -0
- package/src/index.ts +8 -0
- package/src/middlewares/otel.ts +2 -0
- package/src/types.ts +128 -0
|
@@ -24,26 +24,31 @@ sources:
|
|
|
24
24
|
```typescript
|
|
25
25
|
import { chat, toServerSentEventsResponse } from '@tanstack/ai'
|
|
26
26
|
import { openaiText } from '@tanstack/ai-openai'
|
|
27
|
+
import { trackAnalytics, reportError } from './analytics'
|
|
27
28
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
29
|
+
export async function POST(request: Request) {
|
|
30
|
+
const { messages } = await request.json()
|
|
31
|
+
|
|
32
|
+
const stream = chat({
|
|
33
|
+
adapter: openaiText('gpt-5.5'),
|
|
34
|
+
messages,
|
|
35
|
+
middleware: [
|
|
36
|
+
{
|
|
37
|
+
onStart: (ctx) => {
|
|
38
|
+
console.log('Chat started:', ctx.model)
|
|
39
|
+
},
|
|
40
|
+
onFinish: (ctx, info) => {
|
|
41
|
+
trackAnalytics({ model: ctx.model, tokens: info.usage?.totalTokens })
|
|
42
|
+
},
|
|
43
|
+
onError: (ctx, info) => {
|
|
44
|
+
reportError(info.error)
|
|
45
|
+
},
|
|
41
46
|
},
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
})
|
|
47
|
+
],
|
|
48
|
+
})
|
|
45
49
|
|
|
46
|
-
return toServerSentEventsResponse(stream)
|
|
50
|
+
return toServerSentEventsResponse(stream)
|
|
51
|
+
}
|
|
47
52
|
```
|
|
48
53
|
|
|
49
54
|
## Hooks Reference
|
|
@@ -119,19 +124,30 @@ specific config changes that should not affect the agent-loop adapter calls.
|
|
|
119
124
|
**Signature:**
|
|
120
125
|
|
|
121
126
|
```ts
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
127
|
+
import type {
|
|
128
|
+
ChatMiddlewareContext,
|
|
129
|
+
StructuredOutputMiddlewareConfig,
|
|
130
|
+
} from '@tanstack/ai'
|
|
131
|
+
|
|
132
|
+
// Excerpt of the `ChatMiddleware` interface exported by '@tanstack/ai'
|
|
133
|
+
interface ChatMiddleware {
|
|
134
|
+
onStructuredOutputConfig?: (
|
|
135
|
+
ctx: ChatMiddlewareContext,
|
|
136
|
+
config: StructuredOutputMiddlewareConfig,
|
|
137
|
+
) =>
|
|
138
|
+
| void
|
|
139
|
+
| null
|
|
140
|
+
| Partial<StructuredOutputMiddlewareConfig>
|
|
141
|
+
| Promise<void | null | Partial<StructuredOutputMiddlewareConfig>>
|
|
142
|
+
}
|
|
130
143
|
```
|
|
131
144
|
|
|
132
145
|
**`StructuredOutputMiddlewareConfig` shape:**
|
|
133
146
|
|
|
134
147
|
```ts
|
|
148
|
+
import type { ChatMiddlewareConfig, JSONSchema } from '@tanstack/ai'
|
|
149
|
+
|
|
150
|
+
// As exported by '@tanstack/ai'
|
|
135
151
|
interface StructuredOutputMiddlewareConfig extends Omit<
|
|
136
152
|
ChatMiddlewareConfig,
|
|
137
153
|
'tools'
|
|
@@ -203,13 +219,17 @@ const analytics: ChatMiddleware = {
|
|
|
203
219
|
},
|
|
204
220
|
}
|
|
205
221
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
222
|
+
export async function POST(request: Request) {
|
|
223
|
+
const { messages } = await request.json()
|
|
224
|
+
|
|
225
|
+
const stream = chat({
|
|
226
|
+
adapter: openaiText('gpt-5.5'),
|
|
227
|
+
messages,
|
|
228
|
+
middleware: [analytics],
|
|
229
|
+
})
|
|
211
230
|
|
|
212
|
-
return toServerSentEventsResponse(stream)
|
|
231
|
+
return toServerSentEventsResponse(stream)
|
|
232
|
+
}
|
|
213
233
|
```
|
|
214
234
|
|
|
215
235
|
### Pattern 2: Tool Interception Middleware
|
|
@@ -278,11 +298,14 @@ native-combined schema.
|
|
|
278
298
|
|
|
279
299
|
```typescript
|
|
280
300
|
import type { ChatMiddleware } from '@tanstack/ai'
|
|
301
|
+
import { trace } from '@opentelemetry/api'
|
|
281
302
|
|
|
282
303
|
const tracing: ChatMiddleware = {
|
|
283
304
|
name: 'tracing',
|
|
284
305
|
onChunk(ctx, chunk) {
|
|
285
|
-
|
|
306
|
+
trace
|
|
307
|
+
.getActiveSpan()
|
|
308
|
+
?.addEvent('chunk', { phase: ctx.phase, type: chunk.type })
|
|
286
309
|
},
|
|
287
310
|
}
|
|
288
311
|
```
|
|
@@ -296,6 +319,7 @@ the native-combined path, it observes the structured stream with
|
|
|
296
319
|
|
|
297
320
|
```typescript
|
|
298
321
|
import type { ChatMiddleware } from '@tanstack/ai'
|
|
322
|
+
import { sharedDefs } from './defs'
|
|
299
323
|
|
|
300
324
|
const injectDefs: ChatMiddleware = {
|
|
301
325
|
name: 'inject-defs',
|
|
@@ -317,9 +341,27 @@ Middleware executes in array order (left-to-right). Ordering matters for hooks t
|
|
|
317
341
|
pipe or short-circuit:
|
|
318
342
|
|
|
319
343
|
```typescript
|
|
320
|
-
import {
|
|
344
|
+
import {
|
|
345
|
+
chat,
|
|
346
|
+
toolDefinition,
|
|
347
|
+
toServerSentEventsResponse,
|
|
348
|
+
type ChatMiddleware,
|
|
349
|
+
} from '@tanstack/ai'
|
|
321
350
|
import { toolCacheMiddleware } from '@tanstack/ai/middlewares'
|
|
322
351
|
import { openaiText } from '@tanstack/ai-openai'
|
|
352
|
+
import { z } from 'zod'
|
|
353
|
+
|
|
354
|
+
const weatherTool = toolDefinition({
|
|
355
|
+
name: 'getWeather',
|
|
356
|
+
description: 'Get the current weather for a city',
|
|
357
|
+
inputSchema: z.object({ city: z.string() }),
|
|
358
|
+
}).server(async ({ city }) => ({ city, tempC: 21 }))
|
|
359
|
+
|
|
360
|
+
const stockTool = toolDefinition({
|
|
361
|
+
name: 'getStock',
|
|
362
|
+
description: 'Get the latest price for a ticker symbol',
|
|
363
|
+
inputSchema: z.object({ symbol: z.string() }),
|
|
364
|
+
}).server(async ({ symbol }) => ({ symbol, price: 123.45 }))
|
|
323
365
|
|
|
324
366
|
const logging: ChatMiddleware = {
|
|
325
367
|
name: 'logging',
|
|
@@ -347,16 +389,22 @@ const configTransform: ChatMiddleware = {
|
|
|
347
389
|
},
|
|
348
390
|
}
|
|
349
391
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
392
|
+
export async function POST(request: Request) {
|
|
393
|
+
const { messages } = await request.json()
|
|
394
|
+
|
|
395
|
+
const stream = chat({
|
|
396
|
+
adapter: openaiText('gpt-5.5'),
|
|
397
|
+
messages,
|
|
398
|
+
tools: [weatherTool, stockTool],
|
|
399
|
+
middleware: [
|
|
400
|
+
logging, // Runs first
|
|
401
|
+
configTransform, // Transforms config second
|
|
402
|
+
toolCacheMiddleware({ ttl: 60_000 }), // Caches tool results third
|
|
403
|
+
],
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
return toServerSentEventsResponse(stream)
|
|
407
|
+
}
|
|
360
408
|
```
|
|
361
409
|
|
|
362
410
|
**Composition rules by hook:**
|
|
@@ -378,7 +426,21 @@ Not a built-in. Cap fan-out with `onBeforeToolCall` skip + `onShouldContinue`.
|
|
|
378
426
|
See `docs/chat/agentic-cycle.md` ("Tool-call budgets").
|
|
379
427
|
|
|
380
428
|
```typescript
|
|
381
|
-
import {
|
|
429
|
+
import {
|
|
430
|
+
chat,
|
|
431
|
+
maxIterations,
|
|
432
|
+
toolDefinition,
|
|
433
|
+
toServerSentEventsResponse,
|
|
434
|
+
type ChatMiddleware,
|
|
435
|
+
} from '@tanstack/ai'
|
|
436
|
+
import { openaiText } from '@tanstack/ai-openai'
|
|
437
|
+
import { z } from 'zod'
|
|
438
|
+
|
|
439
|
+
const weatherTool = toolDefinition({
|
|
440
|
+
name: 'getWeather',
|
|
441
|
+
description: 'Get the current weather for a city',
|
|
442
|
+
inputSchema: z.object({ city: z.string() }),
|
|
443
|
+
}).server(async ({ city }) => ({ city, tempC: 21 }))
|
|
382
444
|
|
|
383
445
|
function toolCallBudget(opts: {
|
|
384
446
|
max?: number
|
|
@@ -409,13 +471,19 @@ function toolCallBudget(opts: {
|
|
|
409
471
|
}
|
|
410
472
|
}
|
|
411
473
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
474
|
+
export async function POST(request: Request) {
|
|
475
|
+
const { messages } = await request.json()
|
|
476
|
+
|
|
477
|
+
const stream = chat({
|
|
478
|
+
adapter: openaiText('gpt-5.5'),
|
|
479
|
+
messages,
|
|
480
|
+
tools: [weatherTool],
|
|
481
|
+
agentLoopStrategy: maxIterations(20),
|
|
482
|
+
middleware: [toolCallBudget({ maxPerTurn: 10, max: 20 })],
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
return toServerSentEventsResponse(stream)
|
|
486
|
+
}
|
|
419
487
|
```
|
|
420
488
|
|
|
421
489
|
## Built-in: toolCacheMiddleware
|
|
@@ -423,21 +491,35 @@ chat({
|
|
|
423
491
|
Caches tool call results by name + arguments. Import from `@tanstack/ai/middlewares`:
|
|
424
492
|
|
|
425
493
|
```typescript
|
|
426
|
-
import { chat } from '@tanstack/ai'
|
|
494
|
+
import { chat, toolDefinition, toServerSentEventsResponse } from '@tanstack/ai'
|
|
427
495
|
import { toolCacheMiddleware } from '@tanstack/ai/middlewares'
|
|
496
|
+
import { openaiText } from '@tanstack/ai-openai'
|
|
497
|
+
import { z } from 'zod'
|
|
428
498
|
|
|
429
|
-
const
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
499
|
+
const weatherTool = toolDefinition({
|
|
500
|
+
name: 'getWeather',
|
|
501
|
+
description: 'Get the current weather for a city',
|
|
502
|
+
inputSchema: z.object({ city: z.string() }),
|
|
503
|
+
}).server(async ({ city }) => ({ city, tempC: 21 }))
|
|
504
|
+
|
|
505
|
+
export async function POST(request: Request) {
|
|
506
|
+
const { messages } = await request.json()
|
|
507
|
+
|
|
508
|
+
const stream = chat({
|
|
509
|
+
adapter: openaiText('gpt-5.5'),
|
|
510
|
+
messages,
|
|
511
|
+
tools: [weatherTool],
|
|
512
|
+
middleware: [
|
|
513
|
+
toolCacheMiddleware({
|
|
514
|
+
ttl: 60_000, // Cache entries expire after 60 seconds
|
|
515
|
+
maxSize: 50, // Max 50 entries (LRU eviction)
|
|
516
|
+
toolNames: ['getWeather'], // Only cache specific tools
|
|
517
|
+
}),
|
|
518
|
+
],
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
return toServerSentEventsResponse(stream)
|
|
522
|
+
}
|
|
441
523
|
```
|
|
442
524
|
|
|
443
525
|
Options: `maxSize` (default 100), `ttl` (default Infinity), `toolNames` (default all),
|
|
@@ -550,7 +632,12 @@ implement, and what `@tanstack/ai-sandbox`'s run driver resolves per run — its
|
|
|
550
632
|
`snapshot()` method alongside `append`, `read`, and `close`:
|
|
551
633
|
|
|
552
634
|
```ts
|
|
553
|
-
|
|
635
|
+
import type { StreamChunk } from '@tanstack/ai'
|
|
636
|
+
|
|
637
|
+
// Excerpt of the `StreamDurability` interface exported by '@tanstack/ai'
|
|
638
|
+
interface StreamDurability<TOffset extends string = string> {
|
|
639
|
+
snapshot: () => Promise<Array<{ offset: TOffset; chunk: StreamChunk }>>
|
|
640
|
+
}
|
|
554
641
|
```
|
|
555
642
|
|
|
556
643
|
It returns everything stored for a run right now, in append order, then
|
|
@@ -695,11 +782,15 @@ Source: docs/sandbox/observability.md
|
|
|
695
782
|
### a. MEDIUM: Trying to modify StreamChunks in middleware
|
|
696
783
|
|
|
697
784
|
```typescript
|
|
785
|
+
import type { ChatMiddleware } from '@tanstack/ai'
|
|
786
|
+
|
|
698
787
|
// WRONG -- mutating the chunk object directly
|
|
699
788
|
const broken: ChatMiddleware = {
|
|
700
789
|
name: 'broken',
|
|
701
790
|
onChunk: (ctx, chunk) => {
|
|
702
|
-
chunk.
|
|
791
|
+
if (chunk.type === 'TEXT_MESSAGE_CONTENT') {
|
|
792
|
+
chunk.delta = 'modified' // Mutation does nothing; chunk is not modified in-place
|
|
793
|
+
}
|
|
703
794
|
},
|
|
704
795
|
}
|
|
705
796
|
|
|
@@ -736,6 +827,9 @@ middleware had decided to reject. A throw from either fails the whole stream. Th
|
|
|
736
827
|
is where an unhandled error actually costs you a response:
|
|
737
828
|
|
|
738
829
|
```typescript
|
|
830
|
+
import type { ChatMiddleware } from '@tanstack/ai'
|
|
831
|
+
import { logChunk, requireEnv } from './logging'
|
|
832
|
+
|
|
739
833
|
// WRONG -- an unhandled error in onChunk kills the entire streaming response
|
|
740
834
|
const fragile: ChatMiddleware = {
|
|
741
835
|
name: 'fragile-chunk-logger',
|
|
@@ -745,7 +839,12 @@ const fragile: ChatMiddleware = {
|
|
|
745
839
|
},
|
|
746
840
|
onConfig: (ctx, config) => {
|
|
747
841
|
// Same for a config transform that reads an env var that is not set
|
|
748
|
-
return {
|
|
842
|
+
return {
|
|
843
|
+
modelOptions: {
|
|
844
|
+
...config.modelOptions,
|
|
845
|
+
temperature: Number(requireEnv('TEMPERATURE')),
|
|
846
|
+
},
|
|
847
|
+
}
|
|
749
848
|
},
|
|
750
849
|
}
|
|
751
850
|
|
|
@@ -761,9 +860,15 @@ const resilient: ChatMiddleware = {
|
|
|
761
860
|
// Return void to pass through
|
|
762
861
|
},
|
|
763
862
|
onConfig: (ctx, config) => {
|
|
764
|
-
const
|
|
863
|
+
const temperature = process.env.TEMPERATURE
|
|
765
864
|
// Decide, do not throw: no override means no transform.
|
|
766
|
-
|
|
865
|
+
if (temperature === undefined) return undefined
|
|
866
|
+
return {
|
|
867
|
+
modelOptions: {
|
|
868
|
+
...config.modelOptions,
|
|
869
|
+
temperature: Number(temperature),
|
|
870
|
+
},
|
|
871
|
+
}
|
|
767
872
|
},
|
|
768
873
|
onFinish: (ctx, info) => {
|
|
769
874
|
// Already guarded by core — but prefer ctx.defer() anyway, so a slow
|
|
@@ -139,7 +139,7 @@ const company = await chat({
|
|
|
139
139
|
|
|
140
140
|
// Full type safety on nested properties
|
|
141
141
|
console.log(company.headquarters.city)
|
|
142
|
-
console.log(company.employees[0]
|
|
142
|
+
console.log(company.employees[0]?.role)
|
|
143
143
|
console.log(company.financials?.revenue)
|
|
144
144
|
```
|
|
145
145
|
|
|
@@ -147,7 +147,7 @@ console.log(company.financials?.revenue)
|
|
|
147
147
|
|
|
148
148
|
Pass `stream: true` alongside `outputSchema` to get an async iterable of standard streaming chunks plus a completed typed object. Use this when you're a single process end-to-end — Node script, CLI, test, or a server endpoint that responds with one JSON blob. For the in-browser progressive-UI case, jump to Pattern 4 instead.
|
|
149
149
|
|
|
150
|
-
```typescript
|
|
150
|
+
```typescript group=person-stream
|
|
151
151
|
import { chat } from '@tanstack/ai'
|
|
152
152
|
import { openaiText } from '@tanstack/ai-openai'
|
|
153
153
|
import { z } from 'zod'
|
|
@@ -192,6 +192,7 @@ The terminal event is a `CUSTOM` chunk: `{ type: 'CUSTOM', name: 'structured-out
|
|
|
192
192
|
| `@tanstack/ai-groq` | Legacy `structuredOutputStream` only (no tools — Groq's API rejects schema + tools + stream) |
|
|
193
193
|
| `@tanstack/ai-bedrock` | Separate native `structuredOutputStream` finalization through Converse or an OpenAI-compatible API |
|
|
194
194
|
| `@tanstack/ai-byteplus` | Native combined mode on supported models; unsupported models emit `RUN_ERROR` |
|
|
195
|
+
| `@tanstack/ai-cloudflare` | Native `structuredOutputStream` without tools; with tools, a separate finalization call (Workers AI models answer the tool turn in prose) |
|
|
195
196
|
| `@tanstack/ai-claude-code` | Combined + event source — `--json-schema` on the same harness turn. Read `useChat().final`. See Pattern 6. |
|
|
196
197
|
| `@tanstack/ai-codex` | Combined + event source — `--output-schema` on the same harness turn. Read `useChat().final`. See Pattern 6. |
|
|
197
198
|
| `@tanstack/ai-opencode` | Combined + event source — prompt-and-parse. Read `useChat().final`. See Pattern 6. |
|
|
@@ -315,7 +316,7 @@ function RecipeBuilder() {
|
|
|
315
316
|
.filter((p) => p.type === 'text')
|
|
316
317
|
.map((p) => p.content)
|
|
317
318
|
.join('')
|
|
318
|
-
return <
|
|
319
|
+
return <p key={m.id}>{text}</p>
|
|
319
320
|
}
|
|
320
321
|
if (m.role === 'assistant') {
|
|
321
322
|
// `data` is `Recipe` because the schema generic flows from
|
|
@@ -337,8 +338,8 @@ function RecipeBuilder() {
|
|
|
337
338
|
function RecipeCard({ part }: { part: RecipePart }) {
|
|
338
339
|
// `data` lands on complete, `partial` fills in while streaming.
|
|
339
340
|
// Both are typed against the schema. No casts.
|
|
340
|
-
const recipe = part.data ?? part.partial
|
|
341
|
-
return <h3>{recipe
|
|
341
|
+
const recipe = part.data ?? part.partial
|
|
342
|
+
return <h3>{recipe?.title ?? 'Plating up…'}</h3>
|
|
342
343
|
}
|
|
343
344
|
```
|
|
344
345
|
|
|
@@ -397,12 +398,19 @@ const ReportSchema = z.object({
|
|
|
397
398
|
oneLiner: z.string(),
|
|
398
399
|
})
|
|
399
400
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
401
|
+
function RepoReport() {
|
|
402
|
+
const { final, sendMessage } = useChat({
|
|
403
|
+
connection: fetchServerSentEvents('/api/repo-report'),
|
|
404
|
+
outputSchema: ReportSchema,
|
|
405
|
+
})
|
|
404
406
|
|
|
405
|
-
|
|
407
|
+
return (
|
|
408
|
+
<div>
|
|
409
|
+
<button onClick={() => sendMessage('Describe this repo')}>Report</button>
|
|
410
|
+
{final && <h2>{final.name}</h2>}
|
|
411
|
+
</div>
|
|
412
|
+
)
|
|
413
|
+
}
|
|
406
414
|
```
|
|
407
415
|
|
|
408
416
|
- Claude Code: `--json-schema`. Codex: `--output-schema`. OpenCode, Grok Build, and `acpCompatible`: prompt-and-parse.
|
|
@@ -418,28 +426,51 @@ final?.name
|
|
|
418
426
|
|
|
419
427
|
Earlier versions of the library routed structured-output JSON deltas through `TextPart`, so renderers had to filter them out:
|
|
420
428
|
|
|
421
|
-
```tsx
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
429
|
+
```tsx group=recipe-renderer
|
|
430
|
+
import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'
|
|
431
|
+
import { z } from 'zod'
|
|
432
|
+
import { ReasoningView, ToolCallView, RecipeCard } from './views'
|
|
433
|
+
|
|
434
|
+
const RecipeSchema = z.object({
|
|
435
|
+
title: z.string(),
|
|
436
|
+
steps: z.array(z.string()),
|
|
427
437
|
})
|
|
438
|
+
|
|
439
|
+
function useRecipeChat() {
|
|
440
|
+
return useChat({
|
|
441
|
+
connection: fetchServerSentEvents('/api/recipes'),
|
|
442
|
+
outputSchema: RecipeSchema,
|
|
443
|
+
})
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function ObsoleteRenderer() {
|
|
447
|
+
const { messages } = useRecipeChat()
|
|
448
|
+
const last = messages.at(-1)
|
|
449
|
+
// OBSOLETE — this guard was needed only because JSON used to land in a TextPart
|
|
450
|
+
return last?.parts.map((part, i) => {
|
|
451
|
+
if (part.type === 'text') return null // ❌ hides the structured JSON
|
|
452
|
+
return <pre key={i}>{JSON.stringify(part)}</pre>
|
|
453
|
+
})
|
|
454
|
+
}
|
|
428
455
|
```
|
|
429
456
|
|
|
430
457
|
That hack is **gone**. With `outputSchema` set, `TEXT_MESSAGE_CONTENT` deltas now route into a dedicated `StructuredOutputPart` (with `raw`, `partial`, `data`, `status`, optional `errorMessage`). Render the structured part directly; let real `TextPart`s through.
|
|
431
458
|
|
|
432
|
-
```tsx
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
}
|
|
459
|
+
```tsx group=recipe-renderer
|
|
460
|
+
function RecipeRenderer() {
|
|
461
|
+
const { messages } = useRecipeChat()
|
|
462
|
+
const last = messages.at(-1)
|
|
463
|
+
// CORRECT — find the structured-output part directly; let actual TextParts render
|
|
464
|
+
return last?.parts.map((part, i) => {
|
|
465
|
+
if (part.type === 'thinking')
|
|
466
|
+
return <ReasoningView key={i} text={part.content} />
|
|
467
|
+
if (part.type === 'tool-call') return <ToolCallView key={i} part={part} />
|
|
468
|
+
if (part.type === 'structured-output')
|
|
469
|
+
return <RecipeCard key={i} part={part} />
|
|
470
|
+
if (part.type === 'text') return <p key={i}>{part.content}</p> // ← real text, not JSON
|
|
471
|
+
return null
|
|
472
|
+
})
|
|
473
|
+
}
|
|
443
474
|
```
|
|
444
475
|
|
|
445
476
|
If you still have an `if (part.type === 'text') return null` line in a structured-output renderer specifically for "hiding the JSON," delete it.
|
|
@@ -455,18 +486,24 @@ Source: PR #577 — structured-output became a typed UIMessage part.
|
|
|
455
486
|
|
|
456
487
|
To render history, walk `messages` directly (see Pattern 5). Use `partial` / `final` for a sticky summary of the **most recent** turn only.
|
|
457
488
|
|
|
458
|
-
```tsx
|
|
459
|
-
|
|
460
|
-
{
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
)
|
|
489
|
+
```tsx group=recipe-renderer
|
|
490
|
+
function RecipeHistory() {
|
|
491
|
+
const { messages, final } = useRecipeChat()
|
|
492
|
+
|
|
493
|
+
return (
|
|
494
|
+
<>
|
|
495
|
+
{/* WRONG — `final` only reflects the latest turn; earlier recipes vanish from this view */}
|
|
496
|
+
{final && <h3>{final.title}</h3>}
|
|
497
|
+
|
|
498
|
+
{/* CORRECT for history — walk messages, render each structured-output part */}
|
|
499
|
+
{messages.map((m) => {
|
|
500
|
+
if (m.role !== 'assistant') return null
|
|
501
|
+
const part = m.parts.find((p) => p.type === 'structured-output')
|
|
502
|
+
return part ? <RecipeCard key={m.id} part={part} /> : null
|
|
503
|
+
})}
|
|
504
|
+
</>
|
|
505
|
+
)
|
|
506
|
+
}
|
|
470
507
|
```
|
|
471
508
|
|
|
472
509
|
Source: PR #577 — partial/final derive from the most recent structured-output part after the latest user message.
|
|
@@ -475,7 +512,7 @@ Source: PR #577 — partial/final derive from the most recent structured-output
|
|
|
475
512
|
|
|
476
513
|
When iterating `chat({ outputSchema, stream: true })` directly (Pattern 3), the `TEXT_MESSAGE_CONTENT` chunks contain _partial_ JSON fragments — they are not valid JSON until the stream completes. Read the completed typed object from the terminal `structured-output.complete` event. Standard Schema validation remains the consumer's responsibility.
|
|
477
514
|
|
|
478
|
-
```typescript
|
|
515
|
+
```typescript group=person-stream
|
|
479
516
|
// WRONG -- partial JSON, throws SyntaxError mid-stream, no schema validation
|
|
480
517
|
for await (const chunk of stream) {
|
|
481
518
|
if (chunk.type === 'TEXT_MESSAGE_CONTENT') {
|
|
@@ -499,8 +536,9 @@ Source: maintainer interview
|
|
|
499
536
|
|
|
500
537
|
The adapter already handles provider differences (OpenAI uses `response_format`, Anthropic uses tool-based extraction, Gemini uses `responseSchema`). Never configure this yourself.
|
|
501
538
|
|
|
502
|
-
```typescript
|
|
539
|
+
```typescript ignore
|
|
503
540
|
// WRONG -- do not set provider-specific response format
|
|
541
|
+
// (this does not compile: modelOptions has no response-format field)
|
|
504
542
|
chat({
|
|
505
543
|
adapter,
|
|
506
544
|
messages,
|
|
@@ -508,11 +546,17 @@ chat({
|
|
|
508
546
|
responseFormat: { type: 'json_schema', json_schema: mySchema },
|
|
509
547
|
},
|
|
510
548
|
})
|
|
549
|
+
```
|
|
511
550
|
|
|
551
|
+
```typescript
|
|
512
552
|
// CORRECT -- just pass outputSchema, the adapter handles the rest
|
|
513
|
-
chat
|
|
514
|
-
|
|
515
|
-
|
|
553
|
+
import { chat } from '@tanstack/ai'
|
|
554
|
+
import { openaiText } from '@tanstack/ai-openai'
|
|
555
|
+
import { z } from 'zod'
|
|
556
|
+
|
|
557
|
+
const person = await chat({
|
|
558
|
+
adapter: openaiText('gpt-5.2'),
|
|
559
|
+
messages: [{ role: 'user', content: 'John Doe, 30' }],
|
|
516
560
|
outputSchema: z.object({ name: z.string(), age: z.number() }),
|
|
517
561
|
})
|
|
518
562
|
```
|
|
@@ -528,8 +572,15 @@ of using the schema validation library already in the project (Zod, ArkType,
|
|
|
528
572
|
Valibot). Always check what the project uses and match it.
|
|
529
573
|
|
|
530
574
|
```typescript
|
|
531
|
-
|
|
532
|
-
|
|
575
|
+
import { chat } from '@tanstack/ai'
|
|
576
|
+
import { openaiText } from '@tanstack/ai-openai'
|
|
577
|
+
import { z } from 'zod'
|
|
578
|
+
|
|
579
|
+
const adapter = openaiText('gpt-5.2')
|
|
580
|
+
const messages = [{ role: 'user' as const, content: 'John Doe, 30' }]
|
|
581
|
+
|
|
582
|
+
// WRONG -- raw schema object, no schema-library type inference (result is unknown)
|
|
583
|
+
const untyped = await chat({
|
|
533
584
|
adapter,
|
|
534
585
|
messages,
|
|
535
586
|
outputSchema: {
|
|
@@ -544,9 +595,7 @@ chat({
|
|
|
544
595
|
})
|
|
545
596
|
|
|
546
597
|
// CORRECT -- use the project's schema library (e.g. Zod)
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
chat({
|
|
598
|
+
const person = await chat({
|
|
550
599
|
adapter,
|
|
551
600
|
messages,
|
|
552
601
|
outputSchema: z.object({
|
|
@@ -554,6 +603,7 @@ chat({
|
|
|
554
603
|
age: z.number(),
|
|
555
604
|
}),
|
|
556
605
|
})
|
|
606
|
+
person.name // string
|
|
557
607
|
```
|
|
558
608
|
|
|
559
609
|
Using the project's schema library gives you TypeScript type inference and
|