diff --git a/.changeset/svelte-callback-propagation.md b/.changeset/svelte-callback-propagation.md new file mode 100644 index 000000000..7174c1aed --- /dev/null +++ b/.changeset/svelte-callback-propagation.md @@ -0,0 +1,7 @@ +--- +'@tanstack/ai-svelte': patch +--- + +fix(ai-svelte): propagate `createChat` callback changes uniformly + +`onResponse`, `onChunk`, and `onCustomEvent` were passed as direct references to the underlying `ChatClient`, while `onFinish` and `onError` were wrapped to read from `options.onX?.(...)` at call time. This meant callers who mutated the options object in-place (or invoked `client.updateOptions(...)`) would see their replacement propagate for the latter two but silently miss for the former three. All five user-supplied callbacks now go through the same indirection, matching the React / Preact / Vue / Solid sibling wrappers. diff --git a/docs/adapters/openai.md b/docs/adapters/openai.md index e780a9a0e..def33f30b 100644 --- a/docs/adapters/openai.md +++ b/docs/adapters/openai.md @@ -6,7 +6,7 @@ description: "Use OpenAI models with TanStack AI — GPT-4o, GPT-5, DALL-E image keywords: - tanstack ai - openai - - gpt-4o + - gpt-5.2 - gpt-5 - dall-e - whisper diff --git a/docs/adapters/openrouter.md b/docs/adapters/openrouter.md index 856ac1065..7a60e8005 100644 --- a/docs/adapters/openrouter.md +++ b/docs/adapters/openrouter.md @@ -126,7 +126,7 @@ const stream = chat({ messages, modelOptions: { models: [ - "openai/gpt-4o", + "openai/gpt-5.2", "anthropic/claude-3.5-sonnet", "google/gemini-pro", ], diff --git a/docs/advanced/debug-logging.md b/docs/advanced/debug-logging.md index 3ebec740b..7f4b25ee7 100644 --- a/docs/advanced/debug-logging.md +++ b/docs/advanced/debug-logging.md @@ -27,7 +27,7 @@ import { chat } from "@tanstack/ai"; import { openaiText } from "@tanstack/ai-openai"; const stream = chat({ - adapter: openaiText("gpt-4o"), + adapter: openaiText("gpt-5.2"), messages: [{ role: "user", content: "Hello" }], debug: true, }); @@ -36,7 +36,7 @@ const stream = chat({ Every internal event now prints to the console with a `[tanstack-ai:]` prefix: ``` -[tanstack-ai:request] activity=chat provider=openai model=gpt-4o messages=1 tools=0 stream=true +[tanstack-ai:request] activity=chat provider=openai model=gpt-5.2 messages=1 tools=0 stream=true [tanstack-ai:agentLoop] run started [tanstack-ai:provider] provider=openai type=response.output_text.delta [tanstack-ai:output] type=TEXT_MESSAGE_CONTENT @@ -49,7 +49,7 @@ Pass a `DebugConfig` object instead of `true`. Every unspecified category defaul ```typescript chat({ - adapter: openaiText("gpt-4o"), + adapter: openaiText("gpt-5.2"), messages, debug: { middleware: false }, // everything except middleware }); @@ -59,7 +59,7 @@ If you want to see ONLY a specific set of categories, set the rest to `false` ex ```typescript chat({ - adapter: openaiText("gpt-4o"), + adapter: openaiText("gpt-5.2"), messages, debug: { provider: true, @@ -91,7 +91,7 @@ const logger: Logger = { }; chat({ - adapter: openaiText("gpt-4o"), + adapter: openaiText("gpt-5.2"), messages, debug: { logger }, // all categories on, piped to pino }); diff --git a/docs/advanced/extend-adapter.md b/docs/advanced/extend-adapter.md index 145432b21..1662762fe 100644 --- a/docs/advanced/extend-adapter.md +++ b/docs/advanced/extend-adapter.md @@ -35,7 +35,7 @@ const myOpenai = extendAdapter(openaiText, [ ]) // Use with original models - full type inference preserved -const gpt4Adapter = myOpenai('gpt-4o') +const gpt4Adapter = myOpenai('gpt-5.2') // Use with custom models - your custom types are applied const customAdapter = myOpenai('my-fine-tuned-gpt4') @@ -109,7 +109,7 @@ import { openaiText } from '@tanstack/ai-openai' const myOpenai = extendAdapter(openaiText, [createModel('custom-model', ['text'])]) // ✅ Original models work with their original types -const a1 = myOpenai('gpt-4o') +const a1 = myOpenai('gpt-5.2') // ✅ Custom models work with your defined types const a2 = myOpenai('custom-model') diff --git a/docs/advanced/middleware.md b/docs/advanced/middleware.md index 3d7a7b02f..e5cfdc1d4 100644 --- a/docs/advanced/middleware.md +++ b/docs/advanced/middleware.md @@ -43,7 +43,7 @@ const logger: ChatMiddleware = { }; const stream = chat({ - adapter: openaiText("gpt-4o"), + adapter: openaiText("gpt-5.2"), messages: [{ role: "user", content: "Hello" }], middleware: [logger], }); @@ -444,7 +444,7 @@ Middleware execute in array order. The ordering matters for hooks that pipe or s ```typescript const stream = chat({ - adapter: openaiText("gpt-4o"), + adapter: openaiText("gpt-5.2"), messages, middleware: [authMiddleware, loggingMiddleware, cachingMiddleware], }); @@ -474,7 +474,7 @@ import { chat } from "@tanstack/ai"; import { toolCacheMiddleware } from "@tanstack/ai/middlewares"; const stream = chat({ - adapter: openaiText("gpt-4o"), + adapter: openaiText("gpt-5.2"), messages, tools: [weatherTool, stockTool], middleware: [ diff --git a/docs/advanced/otel.md b/docs/advanced/otel.md index a8c14673b..002d08fb1 100644 --- a/docs/advanced/otel.md +++ b/docs/advanced/otel.md @@ -38,7 +38,7 @@ const otel = otelMiddleware({ }) const result = await chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages: [{ role: 'user', content: 'hi' }], middleware: [otel], stream: false, @@ -50,11 +50,11 @@ const result = await chat({ ### Spans ```text -chat gpt-4o (root, kind: INTERNAL) -├── chat gpt-4o #0 (iteration, kind: CLIENT) +chat gpt-5.2 (root, kind: INTERNAL) +├── chat gpt-5.2 #0 (iteration, kind: CLIENT) │ ├── execute_tool get_weather │ └── execute_tool get_time -└── chat gpt-4o #1 (iteration, kind: CLIENT) +└── chat gpt-5.2 #1 (iteration, kind: CLIENT) ``` Iteration spans are numbered (`#0`, `#1`, ...) so distinct iterations of the same chat are easy to pick apart in trace viewers. diff --git a/docs/api/ai-preact.md b/docs/api/ai-preact.md index 17dd706eb..afd687a64 100644 --- a/docs/api/ai-preact.md +++ b/docs/api/ai-preact.md @@ -66,7 +66,7 @@ Extends `ChatClientOptions` from `@tanstack/ai-client`: - `initialMessages?` - Initial messages array - `id?` - Unique identifier for this chat instance - `threadId?` - Thread ID for AG-UI run correlation. Persists across sends; auto-generated if omitted -- `forwardedProps?` - Arbitrary client-controlled JSON forwarded to the server in the AG-UI `RunAgentInput.forwardedProps` field (e.g., `{ provider: 'openai', model: 'gpt-4o' }`) +- `forwardedProps?` - Arbitrary client-controlled JSON forwarded to the server in the AG-UI `RunAgentInput.forwardedProps` field (e.g., `{ provider: 'openai', model: 'gpt-5.2' }`) - `body?` - **Deprecated.** Use `forwardedProps` instead. Still works for backward compatibility; values are merged into `forwardedProps` on the wire - `onResponse?` - Callback when response is received - `onChunk?` - Callback when stream chunk is received diff --git a/docs/api/ai-react.md b/docs/api/ai-react.md index ac10e1667..ed7e607aa 100644 --- a/docs/api/ai-react.md +++ b/docs/api/ai-react.md @@ -66,7 +66,7 @@ Extends `ChatClientOptions` from `@tanstack/ai-client`: - `initialMessages?` - Initial messages array - `id?` - Unique identifier for this chat instance - `threadId?` - Thread ID for AG-UI run correlation. Persists across sends; auto-generated if omitted -- `forwardedProps?` - Arbitrary client-controlled JSON forwarded to the server in the AG-UI `RunAgentInput.forwardedProps` field (e.g., `{ provider: 'openai', model: 'gpt-4o' }`) +- `forwardedProps?` - Arbitrary client-controlled JSON forwarded to the server in the AG-UI `RunAgentInput.forwardedProps` field (e.g., `{ provider: 'openai', model: 'gpt-5.2' }`) - `body?` - **Deprecated.** Use `forwardedProps` instead. Still works for backward compatibility; values are merged into `forwardedProps` on the wire - `onResponse?` - Callback when response is received - `onChunk?` - Callback when stream chunk is received diff --git a/docs/api/ai-solid.md b/docs/api/ai-solid.md index 8bf22bf7a..36c162617 100644 --- a/docs/api/ai-solid.md +++ b/docs/api/ai-solid.md @@ -67,7 +67,7 @@ Extends `ChatClientOptions` from `@tanstack/ai-client`: - `initialMessages?` - Initial messages array - `id?` - Unique identifier for this chat instance - `threadId?` - Thread ID for AG-UI run correlation. Persists across sends; auto-generated if omitted -- `forwardedProps?` - Arbitrary client-controlled JSON forwarded to the server in the AG-UI `RunAgentInput.forwardedProps` field (e.g., `{ provider: 'openai', model: 'gpt-4o' }`) +- `forwardedProps?` - Arbitrary client-controlled JSON forwarded to the server in the AG-UI `RunAgentInput.forwardedProps` field (e.g., `{ provider: 'openai', model: 'gpt-5.2' }`) - `body?` - **Deprecated.** Use `forwardedProps` instead. Still works for backward compatibility; values are merged into `forwardedProps` on the wire - `onResponse?` - Callback when response is received - `onChunk?` - Callback when stream chunk is received diff --git a/docs/api/ai-svelte.md b/docs/api/ai-svelte.md index 770e3d90f..f4f866762 100644 --- a/docs/api/ai-svelte.md +++ b/docs/api/ai-svelte.md @@ -62,7 +62,7 @@ Extends `ChatClientOptions` from `@tanstack/ai-client` (minus internal state cal - `initialMessages?` - Initial messages array - `id?` - Unique identifier for this chat instance - `threadId?` - Thread ID for AG-UI run correlation. Persists across sends; auto-generated if omitted -- `forwardedProps?` - Arbitrary client-controlled JSON forwarded to the server in the AG-UI `RunAgentInput.forwardedProps` field (e.g., `{ provider: 'openai', model: 'gpt-4o' }`) +- `forwardedProps?` - Arbitrary client-controlled JSON forwarded to the server in the AG-UI `RunAgentInput.forwardedProps` field (e.g., `{ provider: 'openai', model: 'gpt-5.2' }`) - `body?` - **Deprecated.** Use `forwardedProps` instead. Still works for backward compatibility; values are merged into `forwardedProps` on the wire - `live?` - Enable live subscription mode (subscribes on creation) - `onResponse?` - Callback when response is received diff --git a/docs/api/ai.md b/docs/api/ai.md index 0f50d74cd..9a02359f3 100644 --- a/docs/api/ai.md +++ b/docs/api/ai.md @@ -205,7 +205,7 @@ import { openaiText } from "@tanstack/ai-openai"; export async function POST(req: Request) { const params = await chatParamsFromRequest(req); const stream = chat({ - adapter: openaiText("gpt-4o"), + adapter: openaiText("gpt-5.2"), messages: params.messages, tools: serverTools, }); @@ -246,7 +246,7 @@ import { chat, chatParamsFromRequest, mergeAgentTools } from "@tanstack/ai"; const params = await chatParamsFromRequest(req); const stream = chat({ - adapter: openaiText("gpt-4o"), + adapter: openaiText("gpt-5.2"), messages: params.messages, tools: mergeAgentTools(serverTools, params.tools), }); diff --git a/docs/chat/streaming.md b/docs/chat/streaming.md index a11bd2ca2..018310391 100644 --- a/docs/chat/streaming.md +++ b/docs/chat/streaming.md @@ -87,6 +87,71 @@ TanStack AI implements the [AG-UI Protocol](https://docs.ag-ui.com/introduction) > **Tip:** Some models expose their internal reasoning as thinking content that streams before the response. See [Thinking & Reasoning](./thinking-content). +### Type-Safe Tool Call Events + +When you pass typed tools (defined with `toolDefinition()` and Zod schemas) to `chat()`, the stream chunks automatically carry type information for tool call events. The `toolName` field narrows to the union of your tool name literals, and the `input` field on `TOOL_CALL_END` events is typed as the union of your tool input schemas: + +```typescript +import { chat, toolDefinition } from "@tanstack/ai"; +import { openaiText } from "@tanstack/ai-openai"; +import { z } from "zod"; + +const weatherTool = toolDefinition({ + name: "get_weather", + description: "Get weather for a location", + inputSchema: z.object({ + location: z.string(), + unit: z.enum(["celsius", "fahrenheit"]).optional(), + }), +}); + +const stream = chat({ + adapter: openaiText("gpt-5.2"), + messages, + tools: [weatherTool], +}); + +for await (const chunk of stream) { + if (chunk.type === "TOOL_CALL_END") { + chunk.toolName; // ✅ typed as "get_weather" (not string) + chunk.input; // ✅ typed as { location: string; unit?: "celsius" | "fahrenheit" } | undefined + } +} +``` + +Without typed tools, `toolName` defaults to `string` and `input` defaults to `unknown` — the same behavior as before. The type narrowing is automatic when you use `toolDefinition()` with Zod schemas. + +When multiple tools are provided, tool call events form a **discriminated union** — checking `toolName` narrows `input` to that specific tool's type: + +```typescript +const searchTool = toolDefinition({ + name: "search", + description: "Search the web", + inputSchema: z.object({ query: z.string() }), +}); + +const stream = chat({ + adapter: openaiText("gpt-5.2"), + messages, + tools: [weatherTool, searchTool], +}); + +for await (const chunk of stream) { + if (chunk.type === "TOOL_CALL_END") { + if (chunk.toolName === "get_weather") { + // ✅ input is narrowed to { location: string; unit?: "celsius" | "fahrenheit" } + console.log(`Weather in ${chunk.input?.location}`); + } + if (chunk.toolName === "search") { + // ✅ input is narrowed to { query: string } + console.log(`Searched for: ${chunk.input?.query}`); + } + } +} +``` + +> **Tip:** The typed stream chunk type is exported as `TypedStreamChunk` if you need to annotate variables or function parameters. When used without type arguments, `TypedStreamChunk` is equivalent to `StreamChunk`. + ### Thinking Chunks Thinking/reasoning is represented by AG-UI events `STEP_STARTED` and `STEP_FINISHED`. They stream separately from the final response text: diff --git a/docs/code-mode/code-mode-with-skills.md b/docs/code-mode/code-mode-with-skills.md index a77a0b736..1201d8999 100644 --- a/docs/code-mode/code-mode-with-skills.md +++ b/docs/code-mode/code-mode-with-skills.md @@ -101,7 +101,7 @@ const { toolsRegistry, systemPrompt, selectedSkills } = await codeModeWithSkills }) const stream = chat({ - adapter: openaiText('gpt-4o'), // strong model for reasoning + adapter: openaiText('gpt-5.2'), // strong model for reasoning toolRegistry: toolsRegistry, messages, systemPrompts: ['You are a helpful assistant.', systemPrompt], @@ -189,7 +189,7 @@ const skillsPrompt = createSkillsSystemPrompt({ // 5. Assemble and call chat() const stream = chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), tools: [codeModeTool, ...managementTools, ...skillTools], messages, systemPrompts: [BASE_PROMPT, codeModePrompt, skillsPrompt], diff --git a/docs/code-mode/code-mode.md b/docs/code-mode/code-mode.md index 5cad9fa1d..75b4cbec0 100644 --- a/docs/code-mode/code-mode.md +++ b/docs/code-mode/code-mode.md @@ -100,7 +100,7 @@ import { chat } from "@tanstack/ai"; import { openaiText } from "@tanstack/ai-openai"; const result = await chat({ - adapter: openaiText("gpt-4o"), + adapter: openaiText("gpt-5.2"), systemPrompts: [ "You are a helpful weather assistant.", systemPrompt, diff --git a/docs/community-adapters/cencori.md b/docs/community-adapters/cencori.md index f1de31217..c3a89e740 100644 --- a/docs/community-adapters/cencori.md +++ b/docs/community-adapters/cencori.md @@ -27,7 +27,7 @@ npm install @cencori/ai-sdk import { chat } from "@tanstack/ai"; import { cencori } from "@cencori/ai-sdk/tanstack"; -const adapter = cencori("gpt-4o"); +const adapter = cencori("gpt-5.2"); for await (const chunk of chat({ adapter, @@ -49,7 +49,7 @@ const cencori = createCencori({ baseUrl: "https://cencori.com", // Optional }); -const adapter = cencori("gpt-4o"); +const adapter = cencori("gpt-5.2"); ``` ## Streaming @@ -79,7 +79,7 @@ for await (const chunk of chat({ import { chat } from "@tanstack/ai"; import { cencori } from "@cencori/ai-sdk/tanstack"; -const adapter = cencori("gpt-4o"); +const adapter = cencori("gpt-5.2"); for await (const chunk of chat({ adapter, @@ -110,7 +110,7 @@ Switch between providers with a single parameter: import { cencori } from "@cencori/ai-sdk/tanstack"; // OpenAI -const openai = cencori("gpt-4o"); +const openai = cencori("gpt-5.2"); // Anthropic const anthropic = cencori("claude-3-5-sonnet"); @@ -131,7 +131,7 @@ All responses use the same unified format regardless of provider. | Provider | Models | |----------|--------| -| OpenAI | `gpt-5`, `gpt-4o`, `gpt-4o-mini`, `o3`, `o1` | +| OpenAI | `gpt-5`, `gpt-5.2`, `gpt-4o-mini`, `o3`, `o1` | | Anthropic | `claude-opus-4`, `claude-sonnet-4`, `claude-3-5-sonnet` | | Google | `gemini-3-pro`, `gemini-2.5-flash`, `gemini-2.0-flash` | | xAI | `grok-4`, `grok-3` | @@ -168,7 +168,7 @@ Creates a Cencori adapter using environment variables. **Parameters:** -- `model` - Model name (e.g., `"gpt-4o"`, `"claude-3-5-sonnet"`, `"gemini-2.5-flash"`) +- `model` - Model name (e.g., `"gpt-5.2"`, `"claude-3-5-sonnet"`, `"gemini-2.5-flash"`) **Returns:** A Cencori TanStack AI adapter instance. diff --git a/docs/community-adapters/cloudflare.md b/docs/community-adapters/cloudflare.md index a34e54860..c7b3a0f99 100644 --- a/docs/community-adapters/cloudflare.md +++ b/docs/community-adapters/cloudflare.md @@ -202,7 +202,7 @@ import { createOpenRouterChat, } from "@cloudflare/tanstack-ai"; -const openai = createOpenAiChat("gpt-4o", { +const openai = createOpenAiChat("gpt-5.2", { binding: env.AI.gateway("my-gateway-id"), }); @@ -214,7 +214,7 @@ const grok = createGrokChat("grok-4", { binding: env.AI.gateway("my-gateway-id"), }); -const openrouter = createOpenRouterChat("openai/gpt-4o", { +const openrouter = createOpenRouterChat("openai/gpt-5.2", { binding: env.AI.gateway("my-gateway-id"), }); ``` @@ -224,7 +224,7 @@ Or use credentials for non-Worker environments: ```typescript import { createOpenAiChat } from "@cloudflare/tanstack-ai"; -const adapter = createOpenAiChat("gpt-4o", { +const adapter = createOpenAiChat("gpt-5.2", { accountId: "your-account-id", gatewayId: "your-gateway-id", cfApiKey: "your-cf-api-key", @@ -237,7 +237,7 @@ const adapter = createOpenAiChat("gpt-4o", { Both binding and credentials modes support cache configuration: ```typescript -const adapter = createOpenAiChat("gpt-4o", { +const adapter = createOpenAiChat("gpt-5.2", { binding: env.AI.gateway("my-gateway-id"), skipCache: false, cacheTtl: 3600, diff --git a/docs/comparison/vercel-ai-sdk.md b/docs/comparison/vercel-ai-sdk.md index 415a4731c..904aa3efd 100644 --- a/docs/comparison/vercel-ai-sdk.md +++ b/docs/comparison/vercel-ai-sdk.md @@ -462,7 +462,7 @@ const getWeatherClient = getWeather.client(async ({ city }) => { import { generateText } from 'ai' const result = await generateText({ - model: openai('gpt-4o'), + model: openai('gpt-5.2'), tools: { getWeather: { description: 'Get current weather for a location', @@ -504,7 +504,7 @@ const stream = chat({ import { generateText } from 'ai' const result = await generateText({ - model: openai('gpt-4o'), + model: openai('gpt-5.2'), tools, maxSteps: 10, prompt: 'Help me plan a trip.', diff --git a/docs/getting-started/quick-start-server.md b/docs/getting-started/quick-start-server.md index 6ef617671..c46d1d6c2 100644 --- a/docs/getting-started/quick-start-server.md +++ b/docs/getting-started/quick-start-server.md @@ -37,7 +37,7 @@ import { chat, streamToText } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' const stream = chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages: [{ role: 'user', content: 'Hello!' }], }) @@ -63,7 +63,7 @@ app.post('/api/chat', async (req, res) => { const { messages } = req.body const stream = chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages, }) @@ -112,7 +112,7 @@ const getWeather = toolDefinition({ }) const stream = chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages: [{ role: 'user', content: 'Weather in Tokyo?' }], tools: [getWeather], }) @@ -134,7 +134,7 @@ import { chat, toHttpResponse } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' const stream = chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages, }) const response = toHttpResponse(stream) diff --git a/docs/getting-started/quick-start-svelte.md b/docs/getting-started/quick-start-svelte.md index 39c15189e..57e56ec4a 100644 --- a/docs/getting-started/quick-start-svelte.md +++ b/docs/getting-started/quick-start-svelte.md @@ -52,7 +52,7 @@ export const POST: RequestHandler = async ({ request }) => { // `chat()` uses the AG-UI `threadId` for devtools correlation // when available — no need to plumb `conversationId` manually. const stream = chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages: body.messages, }) diff --git a/docs/getting-started/quick-start-vue.md b/docs/getting-started/quick-start-vue.md index 38dd22d49..6a4c07e74 100644 --- a/docs/getting-started/quick-start-vue.md +++ b/docs/getting-started/quick-start-vue.md @@ -52,7 +52,7 @@ app.post('/api/chat', async (req, res) => { // `chat()` uses the AG-UI `threadId` for devtools correlation // when available — no need to plumb `conversationId` manually. const stream = chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages, }) diff --git a/docs/migration/ag-ui-compliance.md b/docs/migration/ag-ui-compliance.md index 73e97a445..b237c28c0 100644 --- a/docs/migration/ag-ui-compliance.md +++ b/docs/migration/ag-ui-compliance.md @@ -115,7 +115,7 @@ export async function POST(req: Request) { // const provider = body.forwardedProps?.provider const stream = chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages: body.messages, // AG-UI mixed shape — works directly tools: serverTools, }) @@ -144,7 +144,7 @@ import { openaiText } from '@tanstack/ai-openai/adapters' export async function POST(req: Request) { const params = await chatParamsFromRequest(req) const stream = chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages: params.messages, tools: serverTools, }) @@ -176,7 +176,7 @@ import { openaiText } from '@tanstack/ai-openai/adapters' export async function POST(req: Request) { const params = await chatParamsFromRequest(req) const stream = chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages: params.messages, tools: mergeAgentTools(serverTools, params.tools), // ← merges client-declared tools }) @@ -195,7 +195,7 @@ Skip this section if you're on Tier 1. `forwardedProps` is only surfaced when yo ```ts // 🚫 UNSAFE — a client could override `adapter`, `model`, `tools`, system prompts, anything chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), ...params, ...params.forwardedProps, }) @@ -206,7 +206,7 @@ Always destructure the specific fields you intend to forward: ```ts // ✅ SAFE — explicit allowlist chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages: params.messages, tools: mergeAgentTools(serverTools, params.tools), temperature: @@ -232,13 +232,13 @@ The `body` option on `useChat` / `ChatClient` is now `@deprecated` in favor of ` // Before — still works, but deprecated useChat({ connection: fetchServerSentEvents('/api/chat'), - body: { provider: 'openai', model: 'gpt-4o' }, + body: { provider: 'openai', model: 'gpt-5.2' }, }) // After — recommended useChat({ connection: fetchServerSentEvents('/api/chat'), - forwardedProps: { provider: 'openai', model: 'gpt-4o' }, + forwardedProps: { provider: 'openai', model: 'gpt-5.2' }, }) ``` diff --git a/docs/migration/migration-from-vercel-ai.md b/docs/migration/migration-from-vercel-ai.md index f83cd6e0b..cee06f179 100644 --- a/docs/migration/migration-from-vercel-ai.md +++ b/docs/migration/migration-from-vercel-ai.md @@ -77,7 +77,7 @@ export async function POST(request: Request) { const { messages } = await request.json() const result = streamText({ - model: openai('gpt-4o'), + model: openai('gpt-5.2'), messages: convertToModelMessages(messages), }) @@ -96,7 +96,7 @@ export async function POST(request: Request) { const { messages } = await request.json() const stream = chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages, }) @@ -111,7 +111,7 @@ export async function POST(request: Request) { | `streamText()` | `chat()` | Main text generation function | | `generateText()` | `chat({ stream: false })` | Returns `Promise` | | `generateObject()` / `streamObject()` / `Output.object()` | `chat({ outputSchema })` | Returns `Promise` — see [Structured Output](#structured-output) | -| `openai('gpt-4o')` | `openaiText('gpt-4o')` | Activity-specific adapters | +| `openai('gpt-5.2')` | `openaiText('gpt-5.2')` | Activity-specific adapters | | `result.toUIMessageStreamResponse()` / `.toTextStreamResponse()` | `toServerSentEventsResponse(stream)` / `toHttpResponse(stream)` | Separate utility functions | | `model` parameter | `adapter` parameter | Model baked into adapter | @@ -121,7 +121,7 @@ Options accepted by `streamText` as of AI SDK v6, and where each lives in TanSta | `streamText` option | `chat()` equivalent | Notes | |--------------------|--------------------|-------| -| `model: openai('gpt-4o')` | `adapter: openaiText('gpt-4o')` | Activity-specific adapters | +| `model: openai('gpt-5.2')` | `adapter: openaiText('gpt-5.2')` | Activity-specific adapters | | `prompt: 'Hello'` | `messages: [{ role: 'user', content: 'Hello' }]` | TanStack is messages-only | | `messages` | `messages` | Same concept; content parts differ (see [Multimodal](#multimodal-content)) | | `system: 'You are…'` | `systemPrompts: ['You are…']` | Root-level `string[]` | @@ -185,7 +185,7 @@ TanStack AI promotes a small, cross-provider set of options to the top level (`t ```typescript const result = streamText({ - model: openai('gpt-4o'), + model: openai('gpt-5.2'), messages, temperature: 0.7, maxOutputTokens: 1000, // (v5+); `maxTokens` on v4 @@ -208,12 +208,12 @@ const result = streamText({ ```typescript const stream = chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages, temperature: 0.7, maxTokens: 1000, topP: 0.9, - // Everything else lives under modelOptions — typed for gpt-4o specifically + // Everything else lives under modelOptions — typed for gpt-5.2 specifically modelOptions: { topK: 40, presencePenalty: 0.1, @@ -225,7 +225,7 @@ const stream = chat({ }) ``` -> Autocomplete in `modelOptions` reflects the **exact** adapter and model you passed. Swap `openaiText('gpt-4o')` for `anthropicText('claude-sonnet-4-5')` and the shape changes to match Anthropic's options. +> Autocomplete in `modelOptions` reflects the **exact** adapter and model you passed. Swap `openaiText('gpt-5.2')` for `anthropicText('claude-sonnet-4-5')` and the shape changes to match Anthropic's options. ### System Messages @@ -235,7 +235,7 @@ TanStack AI accepts system prompts at the **root level** via the `systemPrompts` ```typescript const result = streamText({ - model: openai('gpt-4o'), + model: openai('gpt-5.2'), system: 'You are a helpful assistant.', messages, }) @@ -245,7 +245,7 @@ const result = streamText({ ```typescript const stream = chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), systemPrompts: ['You are a helpful assistant.'], messages, }) @@ -255,7 +255,7 @@ Multiple system prompts are supported — useful for composing persona, policies ```typescript const stream = chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), systemPrompts: [ 'You are a helpful assistant.', 'Respond in concise, plain English.', @@ -495,7 +495,7 @@ import { openai } from '@ai-sdk/openai' import { z } from 'zod' const result = streamText({ - model: openai('gpt-4o'), + model: openai('gpt-5.2'), messages, tools: { getWeather: tool({ @@ -540,7 +540,7 @@ const getWeather = getWeatherDef.server(async ({ location }) => { // Step 3: Use in chat const stream = chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages, tools: [getWeather], }) @@ -689,7 +689,7 @@ import { openai } from '@ai-sdk/openai' import { z } from 'zod' const { output } = await generateText({ - model: openai('gpt-4o'), + model: openai('gpt-5.2'), prompt: 'Extract the user profile from this bio…', output: Output.object({ schema: z.object({ @@ -710,7 +710,7 @@ import { openaiText } from '@tanstack/ai-openai' import { z } from 'zod' const profile = await chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages: [{ role: 'user', content: 'Extract the user profile from this bio…' }], outputSchema: z.object({ name: z.string(), @@ -749,7 +749,7 @@ import { streamText, stepCountIs } from 'ai' import { openai } from '@ai-sdk/openai' const result = streamText({ - model: openai('gpt-4o'), + model: openai('gpt-5.2'), messages, tools: { getWeather }, stopWhen: stepCountIs(10), @@ -773,7 +773,7 @@ import { import { openaiText } from '@tanstack/ai-openai' const stream = chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages, tools: [getWeather], agentLoopStrategy: combineStrategies([ @@ -802,7 +802,7 @@ const stream = chat({ ```typescript // Stage 1: heavy model for the opening turn const firstPass = await chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages, agentLoopStrategy: maxIterations(1), stream: false, @@ -843,7 +843,7 @@ const loggingMiddleware = { } const wrapped = wrapLanguageModel({ - model: openai('gpt-4o'), + model: openai('gpt-5.2'), middleware: [loggingMiddleware], }) @@ -866,7 +866,7 @@ const loggingMiddleware: ChatMiddleware = { } const stream = chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages, middleware: [loggingMiddleware], context: { userId: 'u_123' }, // passed to every hook as ctx.context @@ -902,7 +902,7 @@ import { chat } from '@tanstack/ai' import { toolCacheMiddleware } from '@tanstack/ai/middlewares' const stream = chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages, tools: [searchDocs, getWeather], middleware: [ @@ -948,7 +948,7 @@ TanStack AI uses activity-specific adapters for optimal tree-shaking. import { openai } from '@ai-sdk/openai' // Chat -streamText({ model: openai('gpt-4o'), ... }) +streamText({ model: openai('gpt-5.2'), ... }) // Embeddings embed({ model: openai.embedding('text-embedding-3-small'), ... }) @@ -963,7 +963,7 @@ generateImage({ model: openai.image('dall-e-3'), ... }) import { openaiText, openaiImage, openaiSpeech } from '@tanstack/ai-openai' // Chat -chat({ adapter: openaiText('gpt-4o'), ... }) +chat({ adapter: openaiText('gpt-5.2'), ... }) // Image generation generateImage({ adapter: openaiImage('dall-e-3'), ... }) @@ -1035,7 +1035,7 @@ import { toHttpStream, } from '@tanstack/ai' -const stream = chat({ adapter: openaiText('gpt-4o'), messages }) +const stream = chat({ adapter: openaiText('gpt-5.2'), messages }) // SSE response (recommended; pairs with fetchServerSentEvents on the client). // Both response helpers accept a ResponseInit with an optional abortController @@ -1091,7 +1091,7 @@ useChat({ ```typescript const result = streamText({ - model: openai('gpt-4o'), + model: openai('gpt-5.2'), messages, abortSignal: controller.signal, }) @@ -1105,7 +1105,7 @@ TanStack AI takes an `AbortController` (not a bare signal) so helpers like `toSe const abortController = new AbortController() const stream = chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages, abortController, }) @@ -1150,7 +1150,7 @@ TanStack AI also lets you hook into the **server-side** stream lifecycle by subs ```typescript streamText({ - model: openai('gpt-4o'), + model: openai('gpt-5.2'), messages: [ { role: 'user', @@ -1167,7 +1167,7 @@ streamText({ ```typescript chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages: [ { role: 'user', @@ -1190,7 +1190,7 @@ chat({ ```typescript const providers = { - openai: openai('gpt-4o'), + openai: openai('gpt-5.2'), anthropic: anthropic('claude-sonnet-4-5-20250514'), } @@ -1204,7 +1204,7 @@ streamText({ ```typescript const adapters = { - openai: () => openaiText('gpt-4o'), + openai: () => openaiText('gpt-5.2'), anthropic: () => anthropicText('claude-sonnet-4-5-20250514'), } @@ -1242,13 +1242,13 @@ type ChatMessages = InferChatMessages ### Per-Model Type Safety ```typescript -const adapter = openaiText('gpt-4o') +const adapter = openaiText('gpt-5.2') chat({ adapter, messages, modelOptions: { - // TypeScript autocompletes options specific to gpt-4o + // TypeScript autocompletes options specific to gpt-5.2 responseFormat: { type: 'json_object' }, logitBias: { '123': 1.0 }, }, @@ -1264,7 +1264,7 @@ import { chat } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' const text = await chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages: [{ role: 'user', content: 'Summarize TanStack AI in one sentence.' }], stream: false, }) @@ -1276,7 +1276,7 @@ If you already have a stream for another reason, `streamToText(stream)` collects ```typescript import { chat, streamToText } from '@tanstack/ai' -const stream = chat({ adapter: openaiText('gpt-4o'), messages }) +const stream = chat({ adapter: openaiText('gpt-5.2'), messages }) const text = await streamToText(stream) ``` @@ -1324,7 +1324,7 @@ export async function POST(request: Request) { const { messages } = await request.json() const result = streamText({ - model: openai('gpt-4o'), + model: openai('gpt-5.2'), system: 'You are a helpful assistant.', messages: convertToModelMessages(messages), temperature: 0.7, @@ -1400,7 +1400,7 @@ export async function POST(request: Request) { const { messages } = await request.json() const stream = chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), systemPrompts: ['You are a helpful assistant.'], messages, temperature: 0.7, diff --git a/docs/protocol/chunk-definitions.md b/docs/protocol/chunk-definitions.md index 3b24b9207..23632070a 100644 --- a/docs/protocol/chunk-definitions.md +++ b/docs/protocol/chunk-definitions.md @@ -70,7 +70,7 @@ interface RunStartedEvent extends BaseAGUIEvent { { "type": "RUN_STARTED", "runId": "run_abc123", - "model": "gpt-4o", + "model": "gpt-5.2", "timestamp": 1701234567890 } ``` @@ -99,7 +99,7 @@ interface RunFinishedEvent extends BaseAGUIEvent { { "type": "RUN_FINISHED", "runId": "run_abc123", - "model": "gpt-4o", + "model": "gpt-5.2", "timestamp": 1701234567900, "finishReason": "stop", "usage": { @@ -132,7 +132,7 @@ interface RunErrorEvent extends BaseAGUIEvent { { "type": "RUN_ERROR", "runId": "run_abc123", - "model": "gpt-4o", + "model": "gpt-5.2", "timestamp": 1701234567890, "error": { "message": "Rate limit exceeded", @@ -175,7 +175,7 @@ interface TextMessageContentEvent extends BaseAGUIEvent { { "type": "TEXT_MESSAGE_CONTENT", "messageId": "msg_abc123", - "model": "gpt-4o", + "model": "gpt-5.2", "timestamp": 1701234567890, "delta": "Hello", "content": "Hello" diff --git a/docs/reference/functions/chat.md b/docs/reference/functions/chat.md index 2fa5295f1..79a844a36 100644 --- a/docs/reference/functions/chat.md +++ b/docs/reference/functions/chat.md @@ -50,7 +50,7 @@ import { chat } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' for await (const chunk of chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages: [{ role: 'user', content: 'What is the weather?' }], tools: [weatherTool] })) { @@ -62,7 +62,7 @@ for await (const chunk of chat({ ```ts for await (const chunk of chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages: [{ role: 'user', content: 'Hello!' }] })) { console.log(chunk) @@ -71,7 +71,7 @@ for await (const chunk of chat({ ```ts const text = await chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages: [{ role: 'user', content: 'Hello!' }], stream: false }) @@ -82,7 +82,7 @@ const text = await chat({ import { z } from 'zod' const result = await chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages: [{ role: 'user', content: 'Research and summarize the topic' }], tools: [researchTool, analyzeTool], outputSchema: z.object({ diff --git a/docs/reference/functions/combineStrategies.md b/docs/reference/functions/combineStrategies.md index 9cf8ab0e7..041958650 100644 --- a/docs/reference/functions/combineStrategies.md +++ b/docs/reference/functions/combineStrategies.md @@ -33,7 +33,7 @@ AgentLoopStrategy that continues only if all strategies return true ```typescript const stream = chat({ adapter: openaiText(), - model: "gpt-4o", + model: "gpt-5.2", messages: [...], tools: [weatherTool], agentLoopStrategy: combineStrategies([ diff --git a/docs/reference/functions/extendAdapter.md b/docs/reference/functions/extendAdapter.md index fe0f38ebb..95c2ffe2f 100644 --- a/docs/reference/functions/extendAdapter.md +++ b/docs/reference/functions/extendAdapter.md @@ -79,7 +79,7 @@ const customModels = [ const myOpenai = extendAdapter(openaiText, customModels) // Use with original models - full type inference preserved -const gpt4 = myOpenai('gpt-4o') +const gpt4 = myOpenai('gpt-5.2') // Use with custom models const custom = myOpenai('my-fine-tuned-gpt4') diff --git a/docs/reference/functions/maxIterations.md b/docs/reference/functions/maxIterations.md index 4348dd42e..6f55ca8c5 100644 --- a/docs/reference/functions/maxIterations.md +++ b/docs/reference/functions/maxIterations.md @@ -32,7 +32,7 @@ AgentLoopStrategy that stops after max iterations ```typescript const stream = chat({ adapter: openaiText(), - model: "gpt-4o", + model: "gpt-5.2", messages: [...], tools: [weatherTool], agentLoopStrategy: maxIterations(3), // Max 3 iterations diff --git a/docs/reference/functions/streamToText.md b/docs/reference/functions/streamToText.md index dab52a6a4..c6f686466 100644 --- a/docs/reference/functions/streamToText.md +++ b/docs/reference/functions/streamToText.md @@ -35,7 +35,7 @@ Promise - The accumulated text content ```typescript const stream = chat({ adapter: openaiText(), - model: 'gpt-4o', + model: 'gpt-5.2', messages: [{ role: 'user', content: 'Hello!' }] }); const text = await streamToText(stream); diff --git a/docs/reference/functions/toHttpResponse.md b/docs/reference/functions/toHttpResponse.md index 44c713caf..4a86ac8a3 100644 --- a/docs/reference/functions/toHttpResponse.md +++ b/docs/reference/functions/toHttpResponse.md @@ -42,6 +42,6 @@ Response in HTTP stream format (newline-delimited JSON) ## Example ```typescript -const stream = chat({ adapter: openaiText(), model: "gpt-4o", messages: [...] }); +const stream = chat({ adapter: openaiText(), model: "gpt-5.2", messages: [...] }); return toHttpResponse(stream, { abortController }); ``` diff --git a/docs/reference/functions/toHttpStream.md b/docs/reference/functions/toHttpStream.md index f2f343c2a..5c1194a90 100644 --- a/docs/reference/functions/toHttpStream.md +++ b/docs/reference/functions/toHttpStream.md @@ -42,7 +42,7 @@ ReadableStream in HTTP stream format (newline-delimited JSON) ## Example ```typescript -const stream = chat({ adapter: openaiText(), model: "gpt-4o", messages: [...] }); +const stream = chat({ adapter: openaiText(), model: "gpt-5.2", messages: [...] }); const readableStream = toHttpStream(stream); // Use with Response for HTTP streaming (not SSE) return new Response(readableStream, { diff --git a/docs/reference/functions/toServerSentEventsResponse.md b/docs/reference/functions/toServerSentEventsResponse.md index 482295fdb..593970462 100644 --- a/docs/reference/functions/toServerSentEventsResponse.md +++ b/docs/reference/functions/toServerSentEventsResponse.md @@ -41,6 +41,6 @@ Response in Server-Sent Events format ## Example ```typescript -const stream = chat({ adapter: openaiText(), model: "gpt-4o", messages: [...] }); +const stream = chat({ adapter: openaiText(), model: "gpt-5.2", messages: [...] }); return toServerSentEventsResponse(stream, { abortController }); ``` diff --git a/docs/reference/functions/untilFinishReason.md b/docs/reference/functions/untilFinishReason.md index 05fd875be..f35dd8d05 100644 --- a/docs/reference/functions/untilFinishReason.md +++ b/docs/reference/functions/untilFinishReason.md @@ -32,7 +32,7 @@ AgentLoopStrategy that stops on specific finish reasons ```typescript const stream = chat({ adapter: openaiText(), - model: "gpt-4o", + model: "gpt-5.2", messages: [...], tools: [weatherTool], agentLoopStrategy: untilFinishReason(["stop", "length"]), diff --git a/docs/reference/interfaces/ChatMiddlewareContext.md b/docs/reference/interfaces/ChatMiddlewareContext.md index bc289a593..c25e80628 100644 --- a/docs/reference/interfaces/ChatMiddlewareContext.md +++ b/docs/reference/interfaces/ChatMiddlewareContext.md @@ -203,7 +203,7 @@ model: string; Defined in: [packages/ai/src/activities/chat/middleware/types.ts:78](https://github.com/TanStack/ai/blob/main/packages/ai/src/activities/chat/middleware/types.ts#L78) -Model identifier (e.g., 'gpt-4o') +Model identifier (e.g., 'gpt-5.2') *** diff --git a/docs/reference/interfaces/SummarizeAdapter.md b/docs/reference/interfaces/SummarizeAdapter.md index 1ea03b8ac..d74d0d1ee 100644 --- a/docs/reference/interfaces/SummarizeAdapter.md +++ b/docs/reference/interfaces/SummarizeAdapter.md @@ -13,7 +13,7 @@ An adapter is created by a provider function: `provider('model')` → `adapter` All type resolution happens at the provider call site, not in this interface. Generic parameters: -- TModel: The specific model name (e.g., 'gpt-4o') +- TModel: The specific model name (e.g., 'gpt-5.2') - TProviderOptions: Provider-specific options (already resolved) ## Type Parameters diff --git a/docs/reference/interfaces/TextAdapter.md b/docs/reference/interfaces/TextAdapter.md index 4b507e780..044e95419 100644 --- a/docs/reference/interfaces/TextAdapter.md +++ b/docs/reference/interfaces/TextAdapter.md @@ -13,7 +13,7 @@ An adapter is created by a provider function: `provider('model')` → `adapter` All type resolution happens at the provider call site, not in this interface. Generic parameters: -- TModel: The specific model name (e.g., 'gpt-4o') +- TModel: The specific model name (e.g., 'gpt-5.2') - TProviderOptions: Provider-specific options for this model (already resolved) - TInputModalities: Supported input modalities for this model (already resolved) - TMessageMetadata: Metadata types for content parts (already resolved) diff --git a/docs/reference/type-aliases/StreamChunk.md b/docs/reference/type-aliases/StreamChunk.md index 3c4c59dac..fb10bd7dd 100644 --- a/docs/reference/type-aliases/StreamChunk.md +++ b/docs/reference/type-aliases/StreamChunk.md @@ -13,3 +13,5 @@ Defined in: [packages/ai/src/types.ts:1380](https://github.com/TanStack/ai/blob/ Chunk returned by the SDK during streaming chat completions. Uses the AG-UI protocol event format. + +For the tool-aware variant that narrows `TOOL_CALL_START`/`TOOL_CALL_END` events by tool name and `CUSTOM` events by tagged literal name, see [`TypedStreamChunk`](./TypedStreamChunk). diff --git a/docs/reference/type-aliases/TaggedCustomEvent.md b/docs/reference/type-aliases/TaggedCustomEvent.md new file mode 100644 index 000000000..04140ca18 --- /dev/null +++ b/docs/reference/type-aliases/TaggedCustomEvent.md @@ -0,0 +1,32 @@ +--- +id: TaggedCustomEvent +title: TaggedCustomEvent +--- + +# Type Alias: TaggedCustomEvent\ + +```ts +type TaggedCustomEvent = + | StructuredOutputStartEvent + | StructuredOutputCompleteEvent + | ApprovalRequestedEvent + | ToolInputAvailableEvent; +``` + +Defined in: [packages/typescript/ai/src/types.ts](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/types.ts) + +Discriminated union of the orchestrator-tagged `CUSTOM` events. Each variant has a literal `name`, so a single narrow on `chunk.name` yields a typed `value` with no helper or cast: + +```ts +if (chunk.type === 'CUSTOM' && chunk.name === 'approval-requested') { + chunk.value.toolCallId // typed as string +} +``` + +The `StructuredOutputCompleteEvent` value is parameterized by `T`, which the chat orchestrator narrows to the schema's inferred type after Standard Schema validation. Adapters always emit it with `T = unknown`. + +`TaggedCustomEvent` is included in [`TypedStreamChunk`](./TypedStreamChunk)'s typed-tools branch so consumers iterating `chat()` streams get tagged narrowing alongside the per-tool `TOOL_CALL_START`/`TOOL_CALL_END` typing. + +## Caveat: user-emitted custom events + +Tools can emit arbitrary user-defined custom events via the `emitCustomEvent(name, value)` context API. Those flow through the stream at runtime but are intentionally absent from this union — including a bare `CustomEvent` (whose `value: any` would poison the union) would collapse `chunk.value` back to `any` after the narrow. If you rely on `emitCustomEvent`, branch on `CUSTOM` outside the literal-`name` narrows or cast the chunk to [`StreamChunk`](./StreamChunk) to recover the wider shape. diff --git a/docs/reference/type-aliases/TypedStreamChunk.md b/docs/reference/type-aliases/TypedStreamChunk.md new file mode 100644 index 000000000..bbd43709d --- /dev/null +++ b/docs/reference/type-aliases/TypedStreamChunk.md @@ -0,0 +1,67 @@ +--- +id: TypedStreamChunk +title: TypedStreamChunk +--- + +# Type Alias: TypedStreamChunk\ + +```ts +type TypedStreamChunk< + TTools extends ReadonlyArray> = ReadonlyArray>, +> = + HasTypedTools extends true + ? + | Exclude< + StreamChunk, + | { type: 'TOOL_CALL_START' } + | { type: 'TOOL_CALL_END' } + | { type: 'CUSTOM' } + > + | DistributedToolCallStart + | DistributedToolCallEnd + | TaggedCustomEvent + : StreamChunk; +``` + +Defined in: [packages/typescript/ai/src/types.ts](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/types.ts) + +A variant of [`StreamChunk`](./StreamChunk) parameterized by the tools array. When specific tool types are provided (e.g. from `chat({ tools: [myTool] })`): + +- `TOOL_CALL_START` and `TOOL_CALL_END` events form a **discriminated union** over tool names. +- Checking `toolName === 'x'` narrows `input` to that specific tool's input type. +- `TOOL_CALL_END` events have `input` typed per-tool via Standard Schema inference. +- `CUSTOM` events with literal tagged names (`structured-output.start`, `structured-output.complete`, `approval-requested`, `tool-input-available`) narrow `value` to the corresponding payload via the [`TaggedCustomEvent`](./TaggedCustomEvent) union. + +When tools are untyped or absent, `TypedStreamChunk` falls back to plain `StreamChunk` so existing consumers that pass streams as `AsyncIterable` keep working. + +This is the type returned by `chat()` when streaming is enabled (the default). You don't typically need to reference it directly unless annotating function parameters or return types. + +```ts +import { chat, toolDefinition, type TypedStreamChunk } from "@tanstack/ai"; +import { openaiText } from "@tanstack/ai-openai"; +import { z } from "zod"; + +const weatherTool = toolDefinition({ + name: "get_weather", + description: "Get weather for a location", + inputSchema: z.object({ location: z.string() }), +}); + +const searchTool = toolDefinition({ + name: "search", + description: "Search the web", + inputSchema: z.object({ query: z.string() }), +}); + +// Inferred from `chat()` — typed tool call events plus tagged CUSTOM events +const stream = chat({ + adapter: openaiText("gpt-5.2"), + messages, + tools: [weatherTool, searchTool], +}); + +// Explicit annotation +type Chunk = TypedStreamChunk<[typeof weatherTool, typeof searchTool]>; +``` + +See [Streaming - Type-Safe Tool Call Events](../../chat/streaming) for a practical walkthrough. diff --git a/docs/tools/lazy-tool-discovery.md b/docs/tools/lazy-tool-discovery.md index 70625dfbc..75375344b 100644 --- a/docs/tools/lazy-tool-discovery.md +++ b/docs/tools/lazy-tool-discovery.md @@ -82,7 +82,7 @@ import { chat, toServerSentEventsResponse } from "@tanstack/ai"; import { openaiText } from "@tanstack/ai-openai"; const stream = chat({ - adapter: openaiText("gpt-4o"), + adapter: openaiText("gpt-5.2"), messages, tools: [ getProducts, // Normal tool — sent to LLM immediately @@ -208,7 +208,7 @@ export async function POST(request: Request) { const { messages } = await request.json(); const stream = chat({ - adapter: openaiText("gpt-4o"), + adapter: openaiText("gpt-5.2"), messages, tools: [getProducts, compareProducts, calculateFinancing], agentLoopStrategy: maxIterations(20), diff --git a/docs/tools/server-tools.md b/docs/tools/server-tools.md index 69bf1552d..f8f5c759f 100644 --- a/docs/tools/server-tools.md +++ b/docs/tools/server-tools.md @@ -307,6 +307,8 @@ const getUserData = getUserDataDef.server(async (args) => { > **Note:** JSON Schema tools skip runtime validation. Zod schemas are recommended for full type safety and validation. +> **Tip:** When you pass typed tools (server, client, or definition) to `chat()`, the returned stream is fully typed — `toolName` narrows to your tool name literals and `input` narrows per-tool when you check the name. See [Type-Safe Tool Call Events](../chat/streaming#type-safe-tool-call-events). + ## Best Practices 1. **Keep tools focused** - Each tool should do one thing well diff --git a/docs/tools/tools.md b/docs/tools/tools.md index 3d4ffdd56..2b0c7c9c1 100644 --- a/docs/tools/tools.md +++ b/docs/tools/tools.md @@ -91,6 +91,8 @@ const inputSchema: JSONSchema = { > **Note:** When using JSON Schema, TypeScript will infer `any` for input/output types since JSON Schema cannot provide compile-time type information. Zod schemas are recommended for full type safety. +> **Tip:** Type safety from Zod schemas extends beyond tool execution — when you iterate over the stream returned by `chat()`, tool call events have typed `toolName` and `input` fields too. See [Type-Safe Tool Call Events](../chat/streaming#type-safe-tool-call-events). + ## Tool Definition Tools are defined using `toolDefinition()` from `@tanstack/ai`: diff --git a/examples/ts-react-chat/src/routes/api.tanchat.ts b/examples/ts-react-chat/src/routes/api.tanchat.ts index 8349399c8..c98a8d866 100644 --- a/examples/ts-react-chat/src/routes/api.tanchat.ts +++ b/examples/ts-react-chat/src/routes/api.tanchat.ts @@ -230,7 +230,7 @@ export const Route = createFileRoute('/api/tanchat')({ const stream = chat({ ...options, - tools: Object.values(mergedTools), + tools: mergedTools, middleware: [loggingMiddleware], systemPrompts: [SYSTEM_PROMPT], agentLoopStrategy: maxIterations(20), @@ -239,6 +239,7 @@ export const Route = createFileRoute('/api/tanchat')({ runId: params.runId, abortController, }) + return toServerSentEventsResponse(stream, { abortController }) } catch (error: any) { console.error('[API Route] Error in chat request:', { diff --git a/packages/ai-client/src/connection-adapters.ts b/packages/ai-client/src/connection-adapters.ts index 44ad11bff..ef0c10241 100644 --- a/packages/ai-client/src/connection-adapters.ts +++ b/packages/ai-client/src/connection-adapters.ts @@ -410,7 +410,7 @@ export interface FetchConnectionOptions { * const connection = fetchServerSentEvents('/api/chat', async () => ({ * body: { * provider: 'openai', - * model: 'gpt-4o', + * model: 'gpt-5.2', * } * })); * ``` @@ -516,7 +516,7 @@ export function fetchServerSentEvents( * const connection = fetchHttpStream('/api/chat', async () => ({ * body: { * provider: 'openai', - * model: 'gpt-4o', + * model: 'gpt-5.2', * } * })); * ``` diff --git a/packages/ai-code-mode-skills/src/code-mode-with-skills.ts b/packages/ai-code-mode-skills/src/code-mode-with-skills.ts index 2e5e3ebe4..7e9f43a21 100644 --- a/packages/ai-code-mode-skills/src/code-mode-with-skills.ts +++ b/packages/ai-code-mode-skills/src/code-mode-with-skills.ts @@ -44,7 +44,7 @@ export type { CodeModeWithSkillsOptions, CodeModeWithSkillsResult } * }); * * const stream = chat({ - * adapter: openaiText('gpt-4o'), // Main model + * adapter: openaiText('gpt-5.2'), // Main model * toolRegistry: toolsRegistry, // Dynamic tool registry * messages, * systemPrompts: [BASE_PROMPT, systemPrompt], diff --git a/packages/ai-openai/src/adapters/summarize.ts b/packages/ai-openai/src/adapters/summarize.ts index e95597d3d..55c647bdd 100644 --- a/packages/ai-openai/src/adapters/summarize.ts +++ b/packages/ai-openai/src/adapters/summarize.ts @@ -14,7 +14,7 @@ export interface OpenAISummarizeConfig extends OpenAIClientConfig {} * Creates an OpenAI summarize adapter with explicit API key. * Type resolution happens here at the call site. * - * @param model - The model name (e.g., 'gpt-4o-mini', 'gpt-4o') + * @param model - The model name (e.g., 'gpt-4o-mini', 'gpt-5.2') * @param apiKey - Your OpenAI API key * @param config - Optional additional configuration * @returns Configured OpenAI summarize adapter instance with resolved types @@ -47,7 +47,7 @@ export function createOpenaiSummarize( * - `process.env` (Node.js) * - `window.env` (Browser with injected env) * - * @param model - The model name (e.g., 'gpt-4o-mini', 'gpt-4o') + * @param model - The model name (e.g., 'gpt-4o-mini', 'gpt-5.2') * @param config - Optional configuration (excluding apiKey which is auto-detected) * @returns Configured OpenAI summarize adapter instance with resolved types * @throws Error if OPENAI_API_KEY is not found in environment diff --git a/packages/ai-openai/src/adapters/text.ts b/packages/ai-openai/src/adapters/text.ts index efa520385..d6af2e1eb 100644 --- a/packages/ai-openai/src/adapters/text.ts +++ b/packages/ai-openai/src/adapters/text.ts @@ -144,15 +144,15 @@ export class OpenAITextAdapter< * Creates an OpenAI chat adapter with explicit API key. * Type resolution happens here at the call site. * - * @param model - The model name (e.g., 'gpt-4o', 'gpt-4-turbo') + * @param model - The model name (e.g., 'gpt-5.2', 'gpt-4-turbo') * @param apiKey - Your OpenAI API key * @param config - Optional additional configuration * @returns Configured OpenAI chat adapter instance with resolved types * * @example * ```typescript - * const adapter = createOpenaiChat('gpt-4o', "sk-..."); - * // adapter has type-safe modelOptions for gpt-4o + * const adapter = createOpenaiChat('gpt-5.2', "sk-..."); + * // adapter has type-safe modelOptions for gpt-5.2 * ``` */ export function createOpenaiChat< @@ -173,7 +173,7 @@ export function createOpenaiChat< * - `process.env` (Node.js) * - `window.env` (Browser with injected env) * - * @param model - The model name (e.g., 'gpt-4o', 'gpt-4-turbo') + * @param model - The model name (e.g., 'gpt-5.2', 'gpt-4-turbo') * @param config - Optional configuration (excluding apiKey which is auto-detected) * @returns Configured OpenAI text adapter instance with resolved types * @throws Error if OPENAI_API_KEY is not found in environment @@ -181,7 +181,7 @@ export function createOpenaiChat< * @example * ```typescript * // Automatically uses OPENAI_API_KEY from environment - * const adapter = openaiText('gpt-4o'); + * const adapter = openaiText('gpt-5.2'); * * const stream = chat({ * adapter, diff --git a/packages/ai-openai/src/text/text-provider-options.ts b/packages/ai-openai/src/text/text-provider-options.ts index e3e8be740..d0ee5f3b5 100644 --- a/packages/ai-openai/src/text/text-provider-options.ts +++ b/packages/ai-openai/src/text/text-provider-options.ts @@ -263,7 +263,7 @@ https://platform.openai.com/docs/api-reference/responses/create#responses_create max_output_tokens?: number /** - * The model name (e.g. "gpt-4o", "gpt-5", "gpt-4.1-mini", etc). + * The model name (e.g. "gpt-5.2", "gpt-5", "gpt-4.1-mini", etc). * https://platform.openai.com/docs/api-reference/responses/create#responses_create-model */ model: string diff --git a/packages/ai-openrouter/tests/openrouter-adapter.test.ts b/packages/ai-openrouter/tests/openrouter-adapter.test.ts index e010db062..5d710379b 100644 --- a/packages/ai-openrouter/tests/openrouter-adapter.test.ts +++ b/packages/ai-openrouter/tests/openrouter-adapter.test.ts @@ -2055,7 +2055,7 @@ describe('OpenRouter convertMessage fail-loud guards', () => { events.push(evt) } const runError = events.find( - (e): e is Extract => + (e): e is Extract => e.type === EventType.RUN_ERROR, ) expect(runError).toBeDefined() @@ -2081,7 +2081,7 @@ describe('OpenRouter convertMessage fail-loud guards', () => { events.push(evt) } const runError = events.find( - (e): e is Extract => + (e): e is Extract => e.type === EventType.RUN_ERROR, ) expect(runError).toBeDefined() diff --git a/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts b/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts index 17d86ec5e..af87862af 100644 --- a/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts +++ b/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts @@ -208,7 +208,7 @@ describe('OpenRouter responses adapter — request shape', () => { events.push(evt) } const runError = events.find( - (e): e is Extract => + (e): e is Extract => e.type === EventType.RUN_ERROR, ) expect(runError).toBeDefined() @@ -228,7 +228,7 @@ describe('OpenRouter responses adapter — request shape', () => { events.push(evt) } const runError = events.find( - (e): e is Extract => + (e): e is Extract => e.type === EventType.RUN_ERROR, ) expect(runError).toBeDefined() @@ -515,7 +515,7 @@ describe('OpenRouter responses adapter — request shape', () => { events.push(evt) } const runError = events.find( - (e): e is Extract => + (e): e is Extract => e.type === EventType.RUN_ERROR, ) expect(runError).toBeDefined() diff --git a/packages/ai-svelte/src/create-chat.svelte.ts b/packages/ai-svelte/src/create-chat.svelte.ts index df16fca5b..00d892459 100644 --- a/packages/ai-svelte/src/create-chat.svelte.ts +++ b/packages/ai-svelte/src/create-chat.svelte.ts @@ -77,11 +77,18 @@ export function createChat< type Final = InferSchemaType> // Create ChatClient instance. - // Note: Svelte's createChat runs once per instance and `options` is captured - // by reference. Callbacks are therefore frozen to whatever the caller passed - // at creation — to swap them dynamically, mutate the options object - // in-place or call `client.updateOptions(...)` imperatively. - // Optional fields use conditional spread because the target + // + // Svelte's `createChat` runs once per instance, so `options` is captured by + // reference at creation time. Wrapping each user-supplied callback through + // `options.onX?.(...)` lets callers mutate the options object in place (or + // call `client.updateOptions(...)` imperatively) and have the next invocation + // pick up the new function — without this indirection, those five callbacks + // would be frozen to whatever was passed at `createChat(...)` time, which + // diverges from the React/Preact/Vue/Solid sibling wrappers. This is the + // same uniform treatment applied to `onFinish`/`onError`; the other three + // (`onResponse`, `onChunk`, `onCustomEvent`) used to be direct references. + // + // Non-callback optional fields use conditional spread because the target // `ChatClientOptions` declares them as `field?: T` (absent vs. present) // rather than `field?: T | undefined`. Under `exactOptionalPropertyTypes`, // passing an explicit `undefined` for an absent-only optional is a type @@ -100,7 +107,7 @@ export function createChat< ...(options.forwardedProps !== undefined && { forwardedProps: options.forwardedProps, }), - ...(options.onResponse !== undefined && { onResponse: options.onResponse }), + onResponse: (response) => options.onResponse?.(response), onChunk: (chunk: StreamChunk) => { options.onChunk?.(chunk) }, @@ -111,9 +118,9 @@ export function createChat< options.onError?.(err) }, tools: options.tools, - ...(options.onCustomEvent !== undefined && { - onCustomEvent: options.onCustomEvent, - }), + onCustomEvent: (eventType, data, context) => { + options.onCustomEvent?.(eventType, data, context) + }, ...(options.streamProcessor !== undefined && { streamProcessor: options.streamProcessor, }), diff --git a/packages/ai/src/activities/chat/adapter.ts b/packages/ai/src/activities/chat/adapter.ts index 648e7e6bf..7f60d6fe2 100644 --- a/packages/ai/src/activities/chat/adapter.ts +++ b/packages/ai/src/activities/chat/adapter.ts @@ -49,7 +49,7 @@ export interface StructuredOutputResult { * All type resolution happens at the provider call site, not in this interface. * * Generic parameters: - * - TModel: The specific model name (e.g., 'gpt-4o') + * - TModel: The specific model name (e.g., 'gpt-5.2') * - TProviderOptions: Provider-specific options for this model (already resolved) * - TInputModalities: Supported input modalities for this model (already resolved) * - TMessageMetadata: Metadata types for content parts (already resolved) diff --git a/packages/ai/src/activities/chat/agent-loop-strategies.ts b/packages/ai/src/activities/chat/agent-loop-strategies.ts index 25b7b1334..79a07fd82 100644 --- a/packages/ai/src/activities/chat/agent-loop-strategies.ts +++ b/packages/ai/src/activities/chat/agent-loop-strategies.ts @@ -10,7 +10,7 @@ import type { AgentLoopStrategy } from '../../types' * ```typescript * const stream = chat({ * adapter: openaiText(), - * model: "gpt-4o", + * model: "gpt-5.2", * messages: [...], * tools: [weatherTool], * agentLoopStrategy: maxIterations(3), // Max 3 iterations @@ -31,7 +31,7 @@ export function maxIterations(max: number): AgentLoopStrategy { * ```typescript * const stream = chat({ * adapter: openaiText(), - * model: "gpt-4o", + * model: "gpt-5.2", * messages: [...], * tools: [weatherTool], * agentLoopStrategy: untilFinishReason(["stop", "length"]), @@ -66,7 +66,7 @@ export function untilFinishReason( * ```typescript * const stream = chat({ * adapter: openaiText(), - * model: "gpt-4o", + * model: "gpt-5.2", * messages: [...], * tools: [weatherTool], * agentLoopStrategy: combineStrategies([ diff --git a/packages/ai/src/activities/chat/index.ts b/packages/ai/src/activities/chat/index.ts index 26e3153fc..1ba0a7c83 100644 --- a/packages/ai/src/activities/chat/index.ts +++ b/packages/ai/src/activities/chat/index.ts @@ -37,6 +37,7 @@ import type { InferSchemaType, JSONSchema, ModelMessage, + ProviderTool, RunFinishedEvent, SchemaInput, StreamChunk, @@ -49,6 +50,7 @@ import type { ToolCallArgsEvent, ToolCallEndEvent, ToolCallStartEvent, + TypedStreamChunk, UIMessage, } from '../../types' import type { @@ -60,7 +62,6 @@ import type { import type { SystemPrompt } from '../../system-prompts' import type { InternalLogger } from '../../logger/internal-logger' import type { DebugOption } from '../../logger/types' -import type { ProviderTool } from '../../tools/provider-tool' // =========================== // Activity Kind @@ -80,13 +81,21 @@ export const kind = 'text' as const * @template TAdapter - The text adapter type (created by a provider function) * @template TSchema - Optional Standard Schema for structured output * @template TStream - Whether to stream the output (default: true) + * @template TTools - The tools array type for type-safe tool call events in the stream */ export interface TextActivityOptions< TAdapter extends AnyTextAdapter, TSchema extends SchemaInput | undefined, TStream extends boolean, + TTools extends ReadonlyArray< + | (Tool & { readonly '~toolKind'?: never }) + | ProviderTool + > = ReadonlyArray< + | (Tool & { readonly '~toolKind'?: never }) + | ProviderTool + >, > { - /** The text adapter to use (created by a provider function like openaiText('gpt-4o')) */ + /** The text adapter to use (created by a provider function like openaiText('gpt-5.2')) */ adapter: TAdapter /** * Conversation messages. Accepts: @@ -128,12 +137,7 @@ export interface TextActivityOptions< * `supports.tools` list. Passing an unsupported tool produces a * compile-time error on the array element. */ - tools?: - | Array< - | (Tool & { readonly '~toolKind'?: never }) - | ProviderTool - > - | undefined + tools?: TTools /** Controls the randomness of the output. Higher values make output more random. Range: [0.0, 2.0] */ temperature?: TextOptions['temperature'] /** Nucleus sampling parameter. The model considers tokens with topP probability mass. */ @@ -167,7 +171,7 @@ export interface TextActivityOptions< * @example * ```ts * const result = await chat({ - * adapter: openaiText('gpt-4o'), + * adapter: openaiText('gpt-5.2'), * messages: [{ role: 'user', content: 'Generate a person' }], * outputSchema: z.object({ name: z.string(), age: z.number() }) * }) @@ -177,7 +181,7 @@ export interface TextActivityOptions< outputSchema?: TSchema /** * Whether to stream the text result. - * When true (default), returns an AsyncIterable for streaming output. + * When true (default), returns an AsyncIterable> for streaming output. * When false, returns a Promise with the collected text content. * * Note: If outputSchema is provided, this option is ignored and the result @@ -188,7 +192,7 @@ export interface TextActivityOptions< * @example Non-streaming text * ```ts * const text = await chat({ - * adapter: openaiText('gpt-4o'), + * adapter: openaiText('gpt-5.2'), * messages: [{ role: 'user', content: 'Hello!' }], * stream: false * }) @@ -203,7 +207,7 @@ export interface TextActivityOptions< * @example * ```ts * const stream = chat({ - * adapter: openaiText('gpt-4o'), + * adapter: openaiText('gpt-5.2'), * messages: [...], * middleware: [loggingMiddleware, redactionMiddleware], * }) @@ -245,9 +249,16 @@ export function createChatOptions< TAdapter extends AnyTextAdapter, TSchema extends SchemaInput | undefined = undefined, TStream extends boolean = true, + TTools extends ReadonlyArray< + | (Tool & { readonly '~toolKind'?: never }) + | ProviderTool + > = ReadonlyArray< + | (Tool & { readonly '~toolKind'?: never }) + | ProviderTool + >, >( - options: TextActivityOptions, -): TextActivityOptions { + options: TextActivityOptions, +): TextActivityOptions { return options } @@ -264,7 +275,10 @@ export function createChatOptions< * - If outputSchema is provided without explicit stream:true: * Promise>. * - If stream is explicitly false (no schema): Promise. - * - Otherwise (default): AsyncIterable. + * - Otherwise (default): AsyncIterable>. + * + * When tools with typed schemas are provided, the stream chunks include + * type-safe `toolName` and `input` fields on tool call events. * * `[TStream] extends [true]` is used (not `TStream extends true`) so that the * default `boolean` value of `TStream` does *not* match the streaming branch. @@ -274,13 +288,16 @@ export function createChatOptions< export type TextActivityResult< TSchema extends SchemaInput | undefined, TStream extends boolean = boolean, + TTools extends ReadonlyArray> = ReadonlyArray< + Tool + >, > = TSchema extends SchemaInput ? [TStream] extends [true] ? StructuredOutputStream> : Promise> : [TStream] extends [false] ? Promise - : AsyncIterable + : AsyncIterable> // =========================== // ChatEngine Implementation @@ -1481,7 +1498,7 @@ class TextEngine< needsApproval: true, }, }, - } as StreamChunk) + }) } return chunks @@ -1504,7 +1521,7 @@ class TextEngine< toolName: clientTool.toolName, input: clientTool.input, }, - } as StreamChunk) + }) } return chunks @@ -1530,7 +1547,7 @@ class TextEngine< toolCallId: result.toolCallId, toolCallName: result.toolName, toolName: result.toolName, - } as StreamChunk) + }) const args = argsMap.get(result.toolCallId) ?? '{}' chunks.push({ @@ -1540,7 +1557,7 @@ class TextEngine< toolCallId: result.toolCallId, delta: args, args, - } as StreamChunk) + }) } chunks.push({ @@ -1551,7 +1568,7 @@ class TextEngine< toolCallName: result.toolName, toolName: result.toolName, result: content, - } as StreamChunk) + }) // AG-UI spec TOOL_CALL_RESULT event chunks.push({ @@ -1562,7 +1579,7 @@ class TextEngine< toolCallId: result.toolCallId, content, role: 'tool', - } as StreamChunk) + }) // If a placeholder tool message exists for this toolCallId (created by // uiMessageToModelMessages for an approval-responded part with no @@ -1652,7 +1669,7 @@ class TextEngine< model: this.params.model, timestamp: Date.now(), finishReason: 'tool_calls', - } as RunFinishedEvent + } } private shouldContinue(): boolean { @@ -2309,7 +2326,7 @@ class TextEngine< model: this.params.model, name: eventName, value, - } as CustomEvent + } } private createId(prefix: string): string { @@ -2336,7 +2353,7 @@ class TextEngine< * import { openaiText } from '@tanstack/ai-openai' * * for await (const chunk of chat({ - * adapter: openaiText('gpt-4o'), + * adapter: openaiText('gpt-5.2'), * messages: [{ role: 'user', content: 'What is the weather?' }], * tools: [weatherTool] * })) { @@ -2349,7 +2366,7 @@ class TextEngine< * @example One-shot text (streaming without tools) * ```ts * for await (const chunk of chat({ - * adapter: openaiText('gpt-4o'), + * adapter: openaiText('gpt-5.2'), * messages: [{ role: 'user', content: 'Hello!' }] * })) { * console.log(chunk) @@ -2359,7 +2376,7 @@ class TextEngine< * @example Non-streaming text (stream: false) * ```ts * const text = await chat({ - * adapter: openaiText('gpt-4o'), + * adapter: openaiText('gpt-5.2'), * messages: [{ role: 'user', content: 'Hello!' }], * stream: false * }) @@ -2371,7 +2388,7 @@ class TextEngine< * import { z } from 'zod' * * const result = await chat({ - * adapter: openaiText('gpt-4o'), + * adapter: openaiText('gpt-5.2'), * messages: [{ role: 'user', content: 'Research and summarize the topic' }], * tools: [researchTool, analyzeTool], * outputSchema: z.object({ @@ -2386,9 +2403,16 @@ export function chat< TAdapter extends AnyTextAdapter, TSchema extends SchemaInput | undefined = undefined, TStream extends boolean = boolean, + TTools extends ReadonlyArray< + | (Tool & { readonly '~toolKind'?: never }) + | ProviderTool + > = ReadonlyArray< + | (Tool & { readonly '~toolKind'?: never }) + | ProviderTool + >, >( - options: TextActivityOptions, -): TextActivityResult { + options: TextActivityOptions, +): TextActivityResult { const { outputSchema, stream } = options // outputSchema + stream:true is the only branch that streams structured @@ -2399,7 +2423,7 @@ export function chat< ...options, outputSchema, stream, - }) as TextActivityResult + }) as TextActivityResult } // If outputSchema is provided, run agentic structured output (Promise) @@ -2407,7 +2431,7 @@ export function chat< return runAgenticStructuredOutput({ ...options, outputSchema, - }) as TextActivityResult + }) as TextActivityResult } // If stream is explicitly false, run non-streaming text @@ -2416,7 +2440,7 @@ export function chat< ...options, outputSchema: undefined, stream, - }) as TextActivityResult + }) as TextActivityResult } // Otherwise, run streaming text (default) @@ -2424,7 +2448,7 @@ export function chat< ...options, outputSchema: undefined, stream, - }) as TextActivityResult + }) as TextActivityResult } /** diff --git a/packages/ai/src/activities/chat/middleware/types.ts b/packages/ai/src/activities/chat/middleware/types.ts index e2a724b97..b94efcd04 100644 --- a/packages/ai/src/activities/chat/middleware/types.ts +++ b/packages/ai/src/activities/chat/middleware/types.ts @@ -74,7 +74,7 @@ export interface ChatMiddlewareContext { /** Provider name (e.g., 'openai', 'anthropic') */ provider: string - /** Model identifier (e.g., 'gpt-4o') */ + /** Model identifier (e.g., 'gpt-5.2') */ model: string /** Source of the chat invocation — always 'server' for server-side chat */ source: 'client' | 'server' diff --git a/packages/ai/src/activities/chat/tools/tool-calls.ts b/packages/ai/src/activities/chat/tools/tool-calls.ts index 16c1955e7..44e57d474 100644 --- a/packages/ai/src/activities/chat/tools/tool-calls.ts +++ b/packages/ai/src/activities/chat/tools/tool-calls.ts @@ -169,6 +169,13 @@ export class ToolCallManager { const tool = this.tools.find((t) => t.name === toolCall.function.name) let toolResultContent: string + // Holds the parsed/validated execution output before JSON-stringify. + // Surfaced on the emitted `TOOL_CALL_END` event as `output` so + // consumers can read it typed (via `TypedStreamChunk` distribution + // over the tools array) without re-parsing `result`. + // Stays `undefined` when the tool has no `.execute` (pure client + // tools) or when execution throws. + let toolOutput: unknown if (tool?.execute) { try { // Parse arguments (normalize null/non-object to {} for empty tool_use blocks) @@ -221,6 +228,7 @@ export class ToolCallManager { } } + toolOutput = result toolResultContent = typeof result === 'string' ? result : JSON.stringify(result) } catch (error: unknown) { @@ -242,8 +250,10 @@ export class ToolCallManager { toolName: toolCall.function.name, model: finishEvent.model, timestamp: Date.now(), + // Typed parsed output (undefined for failed exec / client-only tools). + ...(toolOutput !== undefined ? { output: toolOutput } : {}), result: toolResultContent, - } as ToolCallEndEvent + } // Add tool result message toolResults.push({ diff --git a/packages/ai/src/activities/generateVideo/index.ts b/packages/ai/src/activities/generateVideo/index.ts index cee2339f7..37e0f0893 100644 --- a/packages/ai/src/activities/generateVideo/index.ts +++ b/packages/ai/src/activities/generateVideo/index.ts @@ -314,7 +314,7 @@ async function* runStreamingVideoGeneration< runId, threadId, timestamp: Date.now(), - } as StreamChunk + } logger.request( `activity=generateVideo provider=${providerName} stream=true`, @@ -340,7 +340,7 @@ async function* runStreamingVideoGeneration< name: 'video:job:created', value: { jobId: jobResult.jobId }, timestamp: Date.now(), - } as StreamChunk + } // Poll for completion const startTime = Date.now() @@ -359,7 +359,7 @@ async function* runStreamingVideoGeneration< error: statusResult.error, }, timestamp: Date.now(), - } as StreamChunk + } if (statusResult.status === 'completed') { const urlResult = await adapter.getVideoUrl(jobResult.jobId) @@ -382,7 +382,7 @@ async function* runStreamingVideoGeneration< expiresAt: urlResult.expiresAt, }, timestamp: Date.now(), - } as StreamChunk + } yield { type: 'RUN_FINISHED', @@ -390,7 +390,7 @@ async function* runStreamingVideoGeneration< threadId, finishReason: 'stop', timestamp: Date.now(), - } as StreamChunk + } return } @@ -415,7 +415,7 @@ async function* runStreamingVideoGeneration< code: payload.code, error: payload, timestamp: Date.now(), - } as StreamChunk + } } } diff --git a/packages/ai/src/activities/summarize/adapter.ts b/packages/ai/src/activities/summarize/adapter.ts index 2f7cc34f6..47812e91b 100644 --- a/packages/ai/src/activities/summarize/adapter.ts +++ b/packages/ai/src/activities/summarize/adapter.ts @@ -22,7 +22,7 @@ export interface SummarizeAdapterConfig { * All type resolution happens at the provider call site, not in this interface. * * Generic parameters: - * - TModel: The specific model name (e.g., 'gpt-4o') + * - TModel: The specific model name (e.g., 'gpt-5.2') * - TProviderOptions: Provider-specific options (already resolved) */ export interface SummarizeAdapter< diff --git a/packages/ai/src/extend-adapter.ts b/packages/ai/src/extend-adapter.ts index c689f7ed4..e468457d2 100644 --- a/packages/ai/src/extend-adapter.ts +++ b/packages/ai/src/extend-adapter.ts @@ -148,7 +148,7 @@ type InferAdapterReturn = TFactory extends ( * const myOpenai = extendAdapter(openaiText, customModels) * * // Use with original models - full type inference preserved - * const gpt4 = myOpenai('gpt-4o') + * const gpt4 = myOpenai('gpt-5.2') * * // Use with custom models * const custom = myOpenai('my-fine-tuned-gpt4') diff --git a/packages/ai/src/stream-to-response.ts b/packages/ai/src/stream-to-response.ts index 9850f4d60..c53372c1b 100644 --- a/packages/ai/src/stream-to-response.ts +++ b/packages/ai/src/stream-to-response.ts @@ -14,7 +14,7 @@ import type { StreamChunk } from './types' * ```typescript * const stream = chat({ * adapter: openaiText(), - * model: 'gpt-4o', + * model: 'gpt-5.2', * messages: [{ role: 'user', content: 'Hello!' }] * }); * const text = await streamToText(stream); @@ -113,7 +113,7 @@ export function toServerSentEventsStream( * * @example * ```typescript - * const stream = chat({ adapter: openaiText(), model: "gpt-4o", messages: [...] }); + * const stream = chat({ adapter: openaiText(), model: "gpt-5.2", messages: [...] }); * return toServerSentEventsResponse(stream, { abortController }); * ``` */ @@ -160,7 +160,7 @@ export function toServerSentEventsResponse( * * @example * ```typescript - * const stream = chat({ adapter: openaiText(), model: "gpt-4o", messages: [...] }); + * const stream = chat({ adapter: openaiText(), model: "gpt-5.2", messages: [...] }); * const readableStream = toHttpStream(stream); * // Use with Response for HTTP streaming (not SSE) * return new Response(readableStream, { @@ -233,7 +233,7 @@ export function toHttpStream( * * @example * ```typescript - * const stream = chat({ adapter: openaiText(), model: "gpt-4o", messages: [...] }); + * const stream = chat({ adapter: openaiText(), model: "gpt-5.2", messages: [...] }); * return toHttpResponse(stream, { abortController }); * ``` */ diff --git a/packages/ai/src/strip-to-spec-middleware.ts b/packages/ai/src/strip-to-spec-middleware.ts index c4648702f..98ad80fa1 100644 --- a/packages/ai/src/strip-to-spec-middleware.ts +++ b/packages/ai/src/strip-to-spec-middleware.ts @@ -11,10 +11,18 @@ import type { StreamChunk } from './types' * spec validation or verifyEvents. */ export function stripToSpec(chunk: StreamChunk): StreamChunk { - // Only strip the deprecated nested error object from RUN_ERROR + // Only strip the deprecated nested error object from RUN_ERROR. + // StreamChunk is a closed discriminated union with no index signature, + // so we need to bypass the overlap check to destructure into a record + // and drop the legacy field. if (chunk.type === 'RUN_ERROR' && 'error' in chunk) { - const { error: _deprecated, ...rest } = chunk as Record - return rest as StreamChunk + // eslint-disable-next-line no-restricted-syntax -- structural narrowing into a record (see comment above) + const { error: _deprecated, ...rest } = chunk as unknown as Record< + string, + unknown + > + // eslint-disable-next-line no-restricted-syntax -- inverse cast; `rest` is structurally a subset of RunErrorEvent + return rest as unknown as StreamChunk } return chunk } diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 6c596a2dc..44d3f6160 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -4,8 +4,8 @@ import type { } from '@standard-schema/spec' import type { InternalLogger } from './logger/internal-logger' import type { SystemPrompt } from './system-prompts' +import type { ProviderTool } from './tools/provider-tool' import type { - BaseEvent as AGUIBaseEvent, CustomEvent as AGUICustomEvent, MessagesSnapshotEvent as AGUIMessagesSnapshotEvent, ReasoningEncryptedValueEvent as AGUIReasoningEncryptedValueEvent, @@ -31,6 +31,12 @@ import type { EventType, } from '@ag-ui/core' +// Re-export ProviderTool so the type is reachable from `@tanstack/ai`'s root +// entry via `export * from './types'` without forcing the subpath import. +// The canonical declaration lives in `./tools/provider-tool` alongside its +// runtime helper `brandProviderTool`. +export type { ProviderTool } from './tools/provider-tool' + /** * Tool call states - track the lifecycle of a tool call */ @@ -898,17 +904,62 @@ export type AGUIEventType = `${EventType}` export type StreamChunkType = AGUIEventType /** - * Base structure for AG-UI events. - * Extends @ag-ui/core BaseEvent with TanStack AI additions. + * Base structure for TanStack AI events. + * + * We deliberately do NOT `extends AGUIBaseEvent` here. @ag-ui/core's + * schemas use Zod's `.passthrough()` mode, which leaks an index + * signature into every `z.infer` event type. That index + * signature pollutes discriminated-union narrowing — `chunk.foo` would + * succeed on every variant regardless of which event it is — and + * collapses IntelliSense back to "every property from every variant". + * + * Instead, each event below uses `Pick` + * to pull in the AGUI-spec'd fields without inheriting the index + * signature, then declares `type` as a plain string literal + * (e.g. `'RUN_STARTED'`). This is the form TypeScript's IntelliSense + * uses when offering suggestions after `chunk.type === "`, so the + * common discriminator narrow autocompletes. Comparing against the + * `EventType` enum still works because each enum member's runtime + * value IS that same string — `EventType.RUN_STARTED` is assignable + * to `'RUN_STARTED'`: + * + * if (chunk.type === 'RUN_STARTED') { ... } // string (autocompletes) + * if (chunk.type === EventType.RUN_STARTED) { ... } // enum (also narrows) + * + * The per-event drift guards below fail to compile if @ag-ui/core ever + * changes the shape of a field we mirror — they catch field-type drift + * but NOT new fields, so when upgrading @ag-ui/core, re-check that the + * `Pick<>` field lists still match the AGUI schema. * * @ag-ui/core provides: `type`, `timestamp?`, `rawEvent?` * TanStack AI adds: `model?` */ -export interface BaseAGUIEvent extends AGUIBaseEvent { +export interface BaseAGUIEvent { + /** Discriminator carried by every event variant. */ + type: AGUIEventType + /** Optional event timestamp (ms since epoch). */ + timestamp?: number + /** Optional opaque provider payload. */ + rawEvent?: unknown /** Model identifier for multi-model support */ model?: string } +/** + * Compile-time drift guard. Errors with a descriptive type if the + * TanStack event's fields are not assignable to the corresponding + * AGUI event's `Pick`'d shape. Used to catch breaking changes when + * @ag-ui/core releases a new version. + * @internal + */ +type AssertSatisfiesAGUI = TTanStack extends TAGUIShape + ? true + : [ + 'DRIFT: TanStack event no longer satisfies AGUI shape', + TTanStack, + TAGUIShape, + ] + // ============================================================================ // AG-UI Event Interfaces // ============================================================================ @@ -920,10 +971,23 @@ export interface BaseAGUIEvent extends AGUIBaseEvent { * @ag-ui/core provides: `threadId`, `runId`, `parentRunId?`, `input?` * TanStack AI adds: `model?` */ -export interface RunStartedEvent extends AGUIRunStartedEvent { +export interface RunStartedEvent extends Pick< + AGUIRunStartedEvent, + 'threadId' | 'runId' | 'parentRunId' | 'input' | 'timestamp' | 'rawEvent' +> { + type: 'RUN_STARTED' /** Model identifier for multi-model support */ model?: string } +type _RunStartedDriftCheck = AssertSatisfiesAGUI< + Omit, + Pick< + AGUIRunStartedEvent, + 'threadId' | 'runId' | 'parentRunId' | 'input' | 'timestamp' | 'rawEvent' + > +> +const _runStartedDriftCheck: _RunStartedDriftCheck = true +void _runStartedDriftCheck /** * Emitted when a run completes successfully. @@ -931,7 +995,11 @@ export interface RunStartedEvent extends AGUIRunStartedEvent { * @ag-ui/core provides: `threadId`, `runId`, `result?` * TanStack AI adds: `model?`, `finishReason?`, `usage?` */ -export interface RunFinishedEvent extends AGUIRunFinishedEvent { +export interface RunFinishedEvent extends Pick< + AGUIRunFinishedEvent, + 'threadId' | 'runId' | 'result' | 'timestamp' | 'rawEvent' +> { + type: 'RUN_FINISHED' /** Model identifier for multi-model support */ model?: string /** Why the generation stopped */ @@ -943,6 +1011,15 @@ export interface RunFinishedEvent extends AGUIRunFinishedEvent { totalTokens: number } } +type _RunFinishedDriftCheck = AssertSatisfiesAGUI< + Omit, + Pick< + AGUIRunFinishedEvent, + 'threadId' | 'runId' | 'result' | 'timestamp' | 'rawEvent' + > +> +const _runFinishedDriftCheck: _RunFinishedDriftCheck = true +void _runFinishedDriftCheck /** * Emitted when an error occurs during a run. @@ -950,9 +1027,20 @@ export interface RunFinishedEvent extends AGUIRunFinishedEvent { * @ag-ui/core provides: `message`, `code?` * TanStack AI adds: `model?`, `error?` (deprecated nested form) */ -export interface RunErrorEvent extends AGUIRunErrorEvent { +export interface RunErrorEvent extends Pick< + AGUIRunErrorEvent, + 'message' | 'code' | 'timestamp' | 'rawEvent' +> { + type: 'RUN_ERROR' /** Model identifier for multi-model support */ model?: string + /** + * Routing metadata the TanStack engine attaches when emitting error + * events. Stripped by `strip-to-spec-middleware` before going on the + * wire so the AG-UI consumer never sees them. + */ + threadId?: string + runId?: string /** * @deprecated Use top-level `message` and `code` fields instead. * Kept for backward compatibility. @@ -964,6 +1052,12 @@ export interface RunErrorEvent extends AGUIRunErrorEvent { } | undefined } +type _RunErrorDriftCheck = AssertSatisfiesAGUI< + Omit, + Pick +> +const _runErrorDriftCheck: _RunErrorDriftCheck = true +void _runErrorDriftCheck /** * Emitted when a text message starts. @@ -971,10 +1065,23 @@ export interface RunErrorEvent extends AGUIRunErrorEvent { * @ag-ui/core provides: `messageId`, `role?`, `name?` * TanStack AI adds: `model?` */ -export interface TextMessageStartEvent extends AGUITextMessageStartEvent { +export interface TextMessageStartEvent extends Pick< + AGUITextMessageStartEvent, + 'messageId' | 'role' | 'name' | 'timestamp' | 'rawEvent' +> { + type: 'TEXT_MESSAGE_START' /** Model identifier for multi-model support */ model?: string } +type _TextMessageStartDriftCheck = AssertSatisfiesAGUI< + Omit, + Pick< + AGUITextMessageStartEvent, + 'messageId' | 'role' | 'name' | 'timestamp' | 'rawEvent' + > +> +const _textMessageStartDriftCheck: _TextMessageStartDriftCheck = true +void _textMessageStartDriftCheck /** * Emitted when text content is generated (streaming tokens). @@ -982,12 +1089,25 @@ export interface TextMessageStartEvent extends AGUITextMessageStartEvent { * @ag-ui/core provides: `messageId`, `delta` * TanStack AI adds: `model?`, `content?` (accumulated) */ -export interface TextMessageContentEvent extends AGUITextMessageContentEvent { +export interface TextMessageContentEvent extends Pick< + AGUITextMessageContentEvent, + 'messageId' | 'delta' | 'timestamp' | 'rawEvent' +> { + type: 'TEXT_MESSAGE_CONTENT' /** Model identifier for multi-model support */ model?: string /** Full accumulated content so far (TanStack AI internal, for debugging) */ content?: string } +type _TextMessageContentDriftCheck = AssertSatisfiesAGUI< + Omit, + Pick< + AGUITextMessageContentEvent, + 'messageId' | 'delta' | 'timestamp' | 'rawEvent' + > +> +const _textMessageContentDriftCheck: _TextMessageContentDriftCheck = true +void _textMessageContentDriftCheck /** * Emitted when a text message completes. @@ -995,25 +1115,61 @@ export interface TextMessageContentEvent extends AGUITextMessageContentEvent { * @ag-ui/core provides: `messageId` * TanStack AI adds: `model?` */ -export interface TextMessageEndEvent extends AGUITextMessageEndEvent { +export interface TextMessageEndEvent extends Pick< + AGUITextMessageEndEvent, + 'messageId' | 'timestamp' | 'rawEvent' +> { + type: 'TEXT_MESSAGE_END' /** Model identifier for multi-model support */ model?: string } +type _TextMessageEndDriftCheck = AssertSatisfiesAGUI< + Omit, + Pick +> +const _textMessageEndDriftCheck: _TextMessageEndDriftCheck = true +void _textMessageEndDriftCheck /** * Emitted when a tool call starts. * * @ag-ui/core provides: `toolCallId`, `toolCallName`, `parentMessageId?` * TanStack AI adds: `model?`, `toolName` (deprecated alias), `index?`, `metadata?` - */ -export interface ToolCallStartEvent extends AGUIToolCallStartEvent { + * + * @typeParam TToolName - Constrained tool name type. Defaults to `string` (untyped). + * When the stream is returned from `chat()` with typed tools, this narrows to + * the union of tool name literals on both `toolCallName` and the deprecated + * `toolName` alias via the `DistributedToolCallStart` intersection — the base + * interface intentionally keeps `toolCallName` at `string` so the + * `AGUIToolCallStartEvent` parent (which uses a passthrough index signature) + * remains a compatible supertype without triggering `Omit`-induced + * discriminant collapse. + * + * Note: `TToolName` is preserved on the `toolName` (TanStack-only) field and + * appears as a literal in the discriminated `DistributedToolCallStart` + * variants of `TypedStreamChunk`. Consumers narrowing through + * `TypedStreamChunk` get the literal. Consumers reading a bare + * `ToolCallStartEvent<'x'>['toolCallName']` get `string` — use the + * distributed variant (via `TypedStreamChunk`) for discriminated narrowing. + */ +export interface ToolCallStartEvent< + TToolName extends string = string, +> extends Pick< + AGUIToolCallStartEvent, + 'toolCallId' | 'toolCallName' | 'parentMessageId' | 'timestamp' | 'rawEvent' +> { + type: 'TOOL_CALL_START' /** Model identifier for multi-model support */ model?: string /** * @deprecated Use `toolCallName` instead (from @ag-ui/core spec). * Kept for backward compatibility. + * + * This field carries the `TToolName` literal in typed streams. For + * `toolCallName` narrowing, use `TypedStreamChunk` — its + * `DistributedToolCallStart` variants intersect an override in. */ - toolName: string + toolName: TToolName /** Index for parallel tool calls */ index?: number /** Provider-specific metadata to carry into the ToolCall. @@ -1022,6 +1178,18 @@ export interface ToolCallStartEvent extends AGUIToolCallStartEvent { * `TToolCallMetadata` shape when emitting. */ metadata?: Record } +type _ToolCallStartDriftCheck = AssertSatisfiesAGUI< + Omit< + ToolCallStartEvent, + 'type' | 'model' | 'toolName' | 'index' | 'metadata' + >, + Pick< + AGUIToolCallStartEvent, + 'toolCallId' | 'toolCallName' | 'parentMessageId' | 'timestamp' | 'rawEvent' + > +> +const _toolCallStartDriftCheck: _ToolCallStartDriftCheck = true +void _toolCallStartDriftCheck /** * Emitted when tool call arguments are streaming. @@ -1029,34 +1197,93 @@ export interface ToolCallStartEvent extends AGUIToolCallStartEvent { * @ag-ui/core provides: `toolCallId`, `delta` * TanStack AI adds: `model?`, `args?` (accumulated) */ -export interface ToolCallArgsEvent extends AGUIToolCallArgsEvent { +export interface ToolCallArgsEvent extends Pick< + AGUIToolCallArgsEvent, + 'toolCallId' | 'delta' | 'timestamp' | 'rawEvent' +> { + type: 'TOOL_CALL_ARGS' /** Model identifier for multi-model support */ model?: string /** Full accumulated arguments so far (TanStack AI internal) */ args?: string } +type _ToolCallArgsDriftCheck = AssertSatisfiesAGUI< + Omit, + Pick +> +const _toolCallArgsDriftCheck: _ToolCallArgsDriftCheck = true +void _toolCallArgsDriftCheck /** * Emitted when a tool call completes. * * @ag-ui/core provides: `toolCallId` - * TanStack AI adds: `model?`, `toolCallName?`, `toolName?` (deprecated), `input?`, `result?` - */ -export interface ToolCallEndEvent extends AGUIToolCallEndEvent { + * TanStack AI adds: `model?`, `toolCallName?`, `toolName?` (deprecated), `input?`, `output?`, `result?` + * + * @typeParam TToolName - Constrained tool name type. Defaults to `string` (untyped). + * @typeParam TInput - Constrained input arguments type. Defaults to `unknown` (untyped). + * @typeParam TOutput - Constrained output type, inferred from the tool's + * `outputSchema`. Defaults to `unknown` (untyped). + * When the stream is returned from `chat()` with typed tools, these narrow to + * the union of tool name literals, input types, and output types respectively. + */ +export interface ToolCallEndEvent< + TToolName extends string = string, + TInput = unknown, + TOutput = unknown, +> extends Pick { + type: 'TOOL_CALL_END' /** Model identifier for multi-model support */ model?: string - /** Name of the tool that completed */ - toolCallName?: string + /** + * Name of the tool that completed (from @ag-ui/core spec). + * + * `AGUIToolCallEndEvent` does not declare `toolCallName`, so re-declaring + * it here as optional is safe — it extends the base shape rather than + * narrowing an existing field. `DistributedToolCallEnd` intersects an + * override to make it required and narrowed to the tool's literal name + * in `TypedStreamChunk`. + */ + toolCallName?: TToolName /** * @deprecated Use `toolCallName` instead. - * Kept for backward compatibility. + * Kept for backward compatibility. Required so that consumers who rely on + * the TanStack surface (pre-ag-ui-merge) can continue to read `toolName` + * without an `undefined` check — every adapter populates this field. */ - toolName?: string + toolName: TToolName /** Final parsed input arguments (TanStack AI internal) */ - input?: unknown - /** Tool execution result (TanStack AI internal) */ + input?: TInput + /** + * Tool execution output, validated against the tool's `outputSchema` when + * one is declared. Set by the engine before JSON-stringifying into + * `result`. Undefined for tools without a `.server(...)` implementation + * (pure client tools — their results arrive via the chat client's tool + * approval path instead). + */ + output?: TOutput + /** + * Wire-level JSON-stringified result. The AG-UI spec carries the result + * as text; consumers that need the typed object should read `output` + * instead (it carries the parsed value before serialization). + */ result?: string } +type _ToolCallEndDriftCheck = AssertSatisfiesAGUI< + Omit< + ToolCallEndEvent, + | 'type' + | 'model' + | 'toolCallName' + | 'toolName' + | 'input' + | 'output' + | 'result' + >, + Pick +> +const _toolCallEndDriftCheck: _ToolCallEndDriftCheck = true +void _toolCallEndDriftCheck /** * Emitted when a tool call result is available. @@ -1064,10 +1291,23 @@ export interface ToolCallEndEvent extends AGUIToolCallEndEvent { * @ag-ui/core provides: `messageId`, `toolCallId`, `content`, `role?` * TanStack AI adds: `model?` */ -export interface ToolCallResultEvent extends AGUIToolCallResultEvent { +export interface ToolCallResultEvent extends Pick< + AGUIToolCallResultEvent, + 'messageId' | 'toolCallId' | 'content' | 'role' | 'timestamp' | 'rawEvent' +> { + type: 'TOOL_CALL_RESULT' /** Model identifier for multi-model support */ model?: string } +type _ToolCallResultDriftCheck = AssertSatisfiesAGUI< + Omit, + Pick< + AGUIToolCallResultEvent, + 'messageId' | 'toolCallId' | 'content' | 'role' | 'timestamp' | 'rawEvent' + > +> +const _toolCallResultDriftCheck: _ToolCallResultDriftCheck = true +void _toolCallResultDriftCheck /** * Emitted when a thinking/reasoning step starts. @@ -1075,7 +1315,11 @@ export interface ToolCallResultEvent extends AGUIToolCallResultEvent { * @ag-ui/core provides: `stepName` * TanStack AI adds: `model?`, `stepId?` (deprecated alias), `stepType?` */ -export interface StepStartedEvent extends AGUIStepStartedEvent { +export interface StepStartedEvent extends Pick< + AGUIStepStartedEvent, + 'stepName' | 'timestamp' | 'rawEvent' +> { + type: 'STEP_STARTED' /** Model identifier for multi-model support */ model?: string /** @@ -1086,6 +1330,12 @@ export interface StepStartedEvent extends AGUIStepStartedEvent { /** Type of step (e.g., 'thinking', 'planning') */ stepType?: string } +type _StepStartedDriftCheck = AssertSatisfiesAGUI< + Omit, + Pick +> +const _stepStartedDriftCheck: _StepStartedDriftCheck = true +void _stepStartedDriftCheck /** * Emitted when a thinking/reasoning step finishes. @@ -1093,7 +1343,11 @@ export interface StepStartedEvent extends AGUIStepStartedEvent { * @ag-ui/core provides: `stepName` * TanStack AI adds: `model?`, `stepId?` (deprecated alias), `delta?`, `content?` */ -export interface StepFinishedEvent extends AGUIStepFinishedEvent { +export interface StepFinishedEvent extends Pick< + AGUIStepFinishedEvent, + 'stepName' | 'timestamp' | 'rawEvent' +> { + type: 'STEP_FINISHED' /** Model identifier for multi-model support */ model?: string /** @@ -1108,6 +1362,15 @@ export interface StepFinishedEvent extends AGUIStepFinishedEvent { /** Provider signature for the thinking block */ signature?: string } +type _StepFinishedDriftCheck = AssertSatisfiesAGUI< + Omit< + StepFinishedEvent, + 'type' | 'model' | 'stepId' | 'delta' | 'content' | 'signature' + >, + Pick +> +const _stepFinishedDriftCheck: _StepFinishedDriftCheck = true +void _stepFinishedDriftCheck /** * Emitted to provide a snapshot of all messages in a conversation. @@ -1121,10 +1384,20 @@ export interface StepFinishedEvent extends AGUIStepFinishedEvent { * Note: The `messages` field uses the @ag-ui/core Message type. * Use converters to transform to/from TanStack UIMessage format. */ -export interface MessagesSnapshotEvent extends AGUIMessagesSnapshotEvent { +export interface MessagesSnapshotEvent extends Pick< + AGUIMessagesSnapshotEvent, + 'messages' | 'timestamp' | 'rawEvent' +> { + type: 'MESSAGES_SNAPSHOT' /** Model identifier for multi-model support */ model?: string } +type _MessagesSnapshotDriftCheck = AssertSatisfiesAGUI< + Omit, + Pick +> +const _messagesSnapshotDriftCheck: _MessagesSnapshotDriftCheck = true +void _messagesSnapshotDriftCheck /** * Emitted to provide a full state snapshot. @@ -1132,7 +1405,11 @@ export interface MessagesSnapshotEvent extends AGUIMessagesSnapshotEvent { * @ag-ui/core provides: `snapshot` (any) * TanStack AI adds: `model?`, `state?` (deprecated alias for snapshot) */ -export interface StateSnapshotEvent extends AGUIStateSnapshotEvent { +export interface StateSnapshotEvent extends Pick< + AGUIStateSnapshotEvent, + 'snapshot' | 'timestamp' | 'rawEvent' +> { + type: 'STATE_SNAPSHOT' /** Model identifier for multi-model support */ model?: string /** @@ -1141,6 +1418,12 @@ export interface StateSnapshotEvent extends AGUIStateSnapshotEvent { */ state?: Record } +type _StateSnapshotDriftCheck = AssertSatisfiesAGUI< + Omit, + Pick +> +const _stateSnapshotDriftCheck: _StateSnapshotDriftCheck = true +void _stateSnapshotDriftCheck /** * Emitted to provide an incremental state update. @@ -1148,10 +1431,20 @@ export interface StateSnapshotEvent extends AGUIStateSnapshotEvent { * @ag-ui/core provides: `delta` (any[] - JSON Patch RFC 6902) * TanStack AI adds: `model?` */ -export interface StateDeltaEvent extends AGUIStateDeltaEvent { +export interface StateDeltaEvent extends Pick< + AGUIStateDeltaEvent, + 'delta' | 'timestamp' | 'rawEvent' +> { + type: 'STATE_DELTA' /** Model identifier for multi-model support */ model?: string } +type _StateDeltaDriftCheck = AssertSatisfiesAGUI< + Omit, + Pick +> +const _stateDeltaDriftCheck: _StateDeltaDriftCheck = true +void _stateDeltaDriftCheck /** * Custom event for extensibility. @@ -1159,10 +1452,28 @@ export interface StateDeltaEvent extends AGUIStateDeltaEvent { * @ag-ui/core provides: `name`, `value` * TanStack AI adds: `model?` */ -export interface CustomEvent extends AGUICustomEvent { +export interface CustomEvent extends Pick< + AGUICustomEvent, + 'name' | 'value' | 'timestamp' | 'rawEvent' +> { + type: 'CUSTOM' /** Model identifier for multi-model support */ model?: string + /** + * Routing metadata the TanStack engine attaches when emitting CUSTOM + * events that need to be correlated with a specific thread/run. + * Stripped by `strip-to-spec-middleware` before going on the wire so + * the AG-UI consumer never sees them. + */ + threadId?: string + runId?: string } +type _CustomDriftCheck = AssertSatisfiesAGUI< + Omit, + Pick +> +const _customDriftCheck: _CustomDriftCheck = true +void _customDriftCheck /** * Final event of a streaming structured-output run. Carries the validated @@ -1265,11 +1576,7 @@ export interface ToolInputAvailableEvent extends CustomEvent { * outside the literal-`name` narrows or cast explicitly. */ export type StructuredOutputStream = AsyncIterable< - | Exclude - | StructuredOutputStartEvent - | StructuredOutputCompleteEvent - | ApprovalRequestedEvent - | ToolInputAvailableEvent + Exclude | TaggedCustomEvent > // ============================================================================ @@ -1282,10 +1589,20 @@ export type StructuredOutputStream = AsyncIterable< * @ag-ui/core provides: `messageId` * TanStack AI adds: `model?` */ -export interface ReasoningStartEvent extends AGUIReasoningStartEvent { +export interface ReasoningStartEvent extends Pick< + AGUIReasoningStartEvent, + 'messageId' | 'timestamp' | 'rawEvent' +> { + type: 'REASONING_START' /** Model identifier for multi-model support */ model?: string } +type _ReasoningStartDriftCheck = AssertSatisfiesAGUI< + Omit, + Pick +> +const _reasoningStartDriftCheck: _ReasoningStartDriftCheck = true +void _reasoningStartDriftCheck /** * Emitted when a reasoning message starts. @@ -1293,10 +1610,23 @@ export interface ReasoningStartEvent extends AGUIReasoningStartEvent { * @ag-ui/core provides: `messageId`, `role` ("reasoning") * TanStack AI adds: `model?` */ -export interface ReasoningMessageStartEvent extends AGUIReasoningMessageStartEvent { +export interface ReasoningMessageStartEvent extends Pick< + AGUIReasoningMessageStartEvent, + 'messageId' | 'role' | 'timestamp' | 'rawEvent' +> { + type: 'REASONING_MESSAGE_START' /** Model identifier for multi-model support */ model?: string } +type _ReasoningMessageStartDriftCheck = AssertSatisfiesAGUI< + Omit, + Pick< + AGUIReasoningMessageStartEvent, + 'messageId' | 'role' | 'timestamp' | 'rawEvent' + > +> +const _reasoningMessageStartDriftCheck: _ReasoningMessageStartDriftCheck = true +void _reasoningMessageStartDriftCheck /** * Emitted when reasoning message content is generated. @@ -1304,10 +1634,23 @@ export interface ReasoningMessageStartEvent extends AGUIReasoningMessageStartEve * @ag-ui/core provides: `messageId`, `delta` * TanStack AI adds: `model?` */ -export interface ReasoningMessageContentEvent extends AGUIReasoningMessageContentEvent { +export interface ReasoningMessageContentEvent extends Pick< + AGUIReasoningMessageContentEvent, + 'messageId' | 'delta' | 'timestamp' | 'rawEvent' +> { + type: 'REASONING_MESSAGE_CONTENT' /** Model identifier for multi-model support */ model?: string } +type _ReasoningMessageContentDriftCheck = AssertSatisfiesAGUI< + Omit, + Pick< + AGUIReasoningMessageContentEvent, + 'messageId' | 'delta' | 'timestamp' | 'rawEvent' + > +> +const _reasoningMessageContentDriftCheck: _ReasoningMessageContentDriftCheck = true +void _reasoningMessageContentDriftCheck /** * Emitted when a reasoning message ends. @@ -1315,10 +1658,20 @@ export interface ReasoningMessageContentEvent extends AGUIReasoningMessageConten * @ag-ui/core provides: `messageId` * TanStack AI adds: `model?` */ -export interface ReasoningMessageEndEvent extends AGUIReasoningMessageEndEvent { +export interface ReasoningMessageEndEvent extends Pick< + AGUIReasoningMessageEndEvent, + 'messageId' | 'timestamp' | 'rawEvent' +> { + type: 'REASONING_MESSAGE_END' /** Model identifier for multi-model support */ model?: string } +type _ReasoningMessageEndDriftCheck = AssertSatisfiesAGUI< + Omit, + Pick +> +const _reasoningMessageEndDriftCheck: _ReasoningMessageEndDriftCheck = true +void _reasoningMessageEndDriftCheck /** * Emitted when reasoning ends for a message. @@ -1326,10 +1679,20 @@ export interface ReasoningMessageEndEvent extends AGUIReasoningMessageEndEvent { * @ag-ui/core provides: `messageId` * TanStack AI adds: `model?` */ -export interface ReasoningEndEvent extends AGUIReasoningEndEvent { +export interface ReasoningEndEvent extends Pick< + AGUIReasoningEndEvent, + 'messageId' | 'timestamp' | 'rawEvent' +> { + type: 'REASONING_END' /** Model identifier for multi-model support */ model?: string } +type _ReasoningEndDriftCheck = AssertSatisfiesAGUI< + Omit, + Pick +> +const _reasoningEndDriftCheck: _ReasoningEndDriftCheck = true +void _reasoningEndDriftCheck /** * Emitted for encrypted reasoning values. @@ -1337,10 +1700,23 @@ export interface ReasoningEndEvent extends AGUIReasoningEndEvent { * @ag-ui/core provides: `subtype`, `entityId`, `encryptedValue` * TanStack AI adds: `model?` */ -export interface ReasoningEncryptedValueEvent extends AGUIReasoningEncryptedValueEvent { +export interface ReasoningEncryptedValueEvent extends Pick< + AGUIReasoningEncryptedValueEvent, + 'subtype' | 'entityId' | 'encryptedValue' | 'timestamp' | 'rawEvent' +> { + type: 'REASONING_ENCRYPTED_VALUE' /** Model identifier for multi-model support */ model?: string } +type _ReasoningEncryptedValueDriftCheck = AssertSatisfiesAGUI< + Omit, + Pick< + AGUIReasoningEncryptedValueEvent, + 'subtype' | 'entityId' | 'encryptedValue' | 'timestamp' | 'rawEvent' + > +> +const _reasoningEncryptedValueDriftCheck: _ReasoningEncryptedValueDriftCheck = true +void _reasoningEncryptedValueDriftCheck // ============================================================================ // AG-UI Event Union @@ -1379,6 +1755,207 @@ export type AGUIEvent = */ export type StreamChunk = AGUIEvent +// ============================================================================ +// Typed Stream Chunks (tool-aware) +// ============================================================================ + +/** + * Detect the `any` type. Returns `true` for `any`, `false` for everything else. + * @internal + */ +type IsAny = 0 extends 1 & T ? true : false + +/** + * Partition out provider-specific tools from a tools array. `ProviderTool` + * carries opaque provider metadata (e.g. `webSearchTool` from + * `@tanstack/ai-anthropic`) and intentionally has a generic `string` name — + * if we included it in the discriminated union, it would widen `toolName` + * back to `string` and defeat the entire typing exercise. + * + * @internal + */ +type NonProviderTools>> = + Exclude> + +/** + * Check whether the tools array carries typed tool definitions. + * Returns `false` for empty arrays or arrays whose only entries are + * `ProviderTool`s (which have generic `string` names). + * + * The partitioning step matters: a user who passes + * `[webSearchTool, myTypedTool]` should still get typed narrowing for + * `myTypedTool`. Evaluating `string extends TTools[number]['name']` without + * filtering provider tools first would always return `false` (because + * `ProviderTool`'s `name` is `string`) and silently fall through to the + * untyped branch. + * + * @internal + */ +type HasTypedTools>> = [ + NonProviderTools, +] extends [never] + ? false + : string extends NonProviderTools['name'] + ? false + : true + +/** + * Safely infer input type for a single tool, guarding against `any` leaks. + * Returns `unknown` when the tool has no inputSchema or when InferSchemaType + * produces `any` (e.g. for plain JSON Schema tools). + * @internal + */ +type SafeToolInput = T extends { + inputSchema?: infer TInput +} + ? IsAny>> extends true + ? unknown + : InferSchemaType> + : unknown + +/** + * Safely infer output type for a single tool. Mirrors `SafeToolInput`, + * picking `outputSchema` instead. Returns `unknown` when the tool has no + * `outputSchema` declared or when `InferSchemaType` produces `any`. + * @internal + */ +type SafeToolOutput = T extends { + outputSchema?: infer TOutput +} + ? IsAny>> extends true + ? unknown + : InferSchemaType> + : unknown + +/** + * Distribute over each non-provider tool to create a per-tool + * `ToolCallStartEvent`. + * + * This produces a discriminated union — one variant per tool name literal. + * We distribute over `NonProviderTools` (not `TTools[number]`) so + * that provider tools with generic `string` names do not leak into the + * union and widen `toolCallName` / `toolName` back to `string`. + * + * The trailing `& { toolCallName: TName; toolName: TName }` intersection + * narrows the base `AGUIToolCallStartEvent['toolCallName']` (declared as + * `string`) to the literal name — TypeScript intersects `string & TName` + * down to `TName` for literal `TName`. + * + * The `name` parameter constraint on the inner `extends` picks up any + * tool-like shape — including `ServerTool`, `ClientTool`, and the bare + * `Tool` definition — because all three expose `name: TName`. + * @internal + */ +type DistributedToolCallStart< + TTools extends ReadonlyArray>, +> = + NonProviderTools extends infer T + ? T extends { name: infer TName extends string } + ? ToolCallStartEvent & { toolCallName: TName; toolName: TName } + : never + : never + +/** + * Distribute over each non-provider tool to create a per-tool + * `ToolCallEndEvent`. + * + * Each variant pairs the tool's name literal with its specific input type, + * enabling discriminated narrowing: checking `toolName === 'x'` narrows + * `input`. + * + * `toolName`/`toolCallName` are intersected as required in the distributed + * variants so that `Extract<..., { toolName: 'x' }>` works for consumers + * relying on the discriminated-union pattern, even though the base + * interface keeps them optional for compatibility with the broader AG-UI + * surface. + * + * Distribution happens over `NonProviderTools` for the same + * reason as in `DistributedToolCallStart`. + * @internal + */ +type DistributedToolCallEnd>> = + NonProviderTools extends infer T + ? T extends { name: infer TName extends string } + ? ToolCallEndEvent, SafeToolOutput> & { + toolCallName: TName + toolName: TName + } + : never + : never + +/** + * Discriminated union of the orchestrator-tagged `CUSTOM` events. Each variant + * has a literal `name`, so a single narrow on `chunk.name` yields a typed + * `value` with no helper or cast: + * + * ```ts + * if (chunk.type === 'CUSTOM' && chunk.name === 'approval-requested') { + * chunk.value.toolCallId // typed as string + * } + * ``` + * + * The `StructuredOutputCompleteEvent` value is parameterized by `T`, which + * the chat orchestrator narrows to the schema's inferred type after Standard + * Schema validation. Adapters always emit it with `T = unknown`. + * + * Caveat: tools can emit arbitrary user-defined custom events via the + * `emitCustomEvent(name, value)` context API. Those flow through the stream + * at runtime but are intentionally absent from this union — including a bare + * `CustomEvent` (whose `value: any` would poison the union) would collapse + * `chunk.value` back to `any` after the narrow. If you rely on + * `emitCustomEvent`, branch on `CUSTOM` outside the literal-`name` narrows + * or cast the chunk to `StreamChunk` to recover the wider shape. + */ +export type TaggedCustomEvent = + | StructuredOutputStartEvent + | StructuredOutputCompleteEvent + | ApprovalRequestedEvent + | ToolInputAvailableEvent + +/** + * Stream chunk type parameterized by the tools array for type-safe tool call events. + * + * When specific tool types are provided (e.g. from `chat({ tools: [myTool] })`): + * - `TOOL_CALL_START` and `TOOL_CALL_END` events form a **discriminated union** + * over tool names — checking `toolName === 'x'` narrows `input` to that tool's type. + * - `TOOL_CALL_END` events have `input` typed per-tool via Standard Schema inference. + * + * `CUSTOM` events are narrowed to the discriminated `TaggedCustomEvent` + * union — `structured-output.start/complete`, `approval-requested`, and + * `tool-input-available` all carry typed `value` payloads. Free-form + * user-emitted custom events (via `emitCustomEvent`) still flow at runtime + * but are excluded from the type to avoid `any` poisoning the union; cast to + * `StreamChunk` if you need to read those. + * + * When tools are untyped or absent, the tool-call events stay as plain + * `ToolCallStartEvent` / `ToolCallEndEvent` (no per-tool name narrowing), + * but `CUSTOM` events still narrow to `TaggedCustomEvent` — the tagged + * shapes the engine emits (`structured-output.start/complete`, + * `approval-requested`, `tool-input-available`) don't depend on the + * tools array, so they're available in every variant of the union. + * + * Free-form user-emitted custom events (via `emitCustomEvent`) still + * flow at runtime but are excluded from the type to avoid `any` + * poisoning the union; cast to `StreamChunk` if you need to read those. + */ +export type TypedStreamChunk< + TTools extends ReadonlyArray> = ReadonlyArray< + Tool + >, +> = + HasTypedTools extends true + ? + | Exclude< + StreamChunk, + | { type: 'TOOL_CALL_START' } + | { type: 'TOOL_CALL_END' } + | { type: 'CUSTOM' } + > + | DistributedToolCallStart + | DistributedToolCallEnd + | TaggedCustomEvent + : Exclude | TaggedCustomEvent + // Simple streaming format for basic text completions // Converted to StreamChunk format by convertTextCompletionStream() export interface TextCompletionChunk { diff --git a/packages/ai/src/utilities/chat-params.ts b/packages/ai/src/utilities/chat-params.ts index 4131a6a01..b59c51f73 100644 --- a/packages/ai/src/utilities/chat-params.ts +++ b/packages/ai/src/utilities/chat-params.ts @@ -171,16 +171,18 @@ export async function chatParamsFromRequest( * `chatParamsFromRequest(...)` / `chatParamsFromRequestBody(...)`. * @returns A merged array suitable for `chat({ tools })`. */ -export function mergeAgentTools( - serverTools: ReadonlyArray, +export function mergeAgentTools< + const TServerTools extends ReadonlyArray>, +>( + serverTools: TServerTools, clientTools: ReadonlyArray<{ name: string description: string parameters: JSONSchema }>, -): Array { +): TServerTools { const seen = new Set(serverTools.map((t) => t.name)) - const merged: Array = [...serverTools] + const merged: Array> = [...serverTools] for (const ct of clientTools) { if (seen.has(ct.name)) { // Server wins on name collision. @@ -195,5 +197,13 @@ export function mergeAgentTools( // emits ClientToolRequest events. } as Tool) } - return merged + // The runtime array carries both server and client tools, but the + // return type is narrowed to just the typed server tuple so that + // `chat({ tools })` can discriminate `chunk.toolCallName` against the + // server tool names. Client tool calls still flow at runtime through + // the existing `ClientToolRequest` path — TypedStreamChunk just doesn't + // surface their names as typed literals (they appear as the bare + // `ToolCallStartEvent` shape after narrowing). + // eslint-disable-next-line no-restricted-syntax -- intentional narrowing (see above) + return merged as unknown as TServerTools } diff --git a/packages/ai/tests/chat-result-types.test.ts b/packages/ai/tests/chat-result-types.test.ts index 2e8ad1aeb..b653a390b 100644 --- a/packages/ai/tests/chat-result-types.test.ts +++ b/packages/ai/tests/chat-result-types.test.ts @@ -14,6 +14,7 @@ import type { InferSchemaType, StreamChunk, StructuredOutputStream, + TypedStreamChunk, } from '../src/types' type Person = { name: string } @@ -88,8 +89,15 @@ describe('chat() return type', () => { }) describe('without outputSchema', () => { - it('stream: true → AsyncIterable', () => { + it('stream: true → AsyncIterable (assignable to AsyncIterable)', () => { + // With untyped tools, `TypedStreamChunk` is StreamChunk with the + // bare CUSTOM variant replaced by the discriminated TaggedCustomEvent + // union — `chunk.type === 'CUSTOM' && chunk.name === '...'` narrows + // even when chat() is called without typed tools. expectTypeOf>().toEqualTypeOf< + AsyncIterable + >() + expectTypeOf>().toMatchTypeOf< AsyncIterable >() }) @@ -100,9 +108,9 @@ describe('chat() return type', () => { >() }) - it('default stream (boolean) → AsyncIterable', () => { + it('default stream (boolean) → AsyncIterable', () => { expectTypeOf>().toEqualTypeOf< - AsyncIterable + AsyncIterable >() }) }) diff --git a/packages/ai/tests/stream-processor.test.ts b/packages/ai/tests/stream-processor.test.ts index 2e1f613bd..de34b452e 100644 --- a/packages/ai/tests/stream-processor.test.ts +++ b/packages/ai/tests/stream-processor.test.ts @@ -23,10 +23,10 @@ import type { function chunk( type: T, fields?: Record, -): Extract { - return { type, timestamp: Date.now(), ...fields } as Extract< +): Extract { + return { type, timestamp: Date.now(), ...fields } as unknown as Extract< StreamChunk, - { type: T } + { type: `${T}` } > } diff --git a/packages/ai/tests/stream-to-response.test.ts b/packages/ai/tests/stream-to-response.test.ts index 420f62794..ad5844b30 100644 --- a/packages/ai/tests/stream-to-response.test.ts +++ b/packages/ai/tests/stream-to-response.test.ts @@ -11,7 +11,7 @@ async function* createMockStream( chunks: Array>, ): AsyncGenerator { for (const chunk of chunks) { - yield chunk as StreamChunk + yield chunk as unknown as StreamChunk } } diff --git a/packages/ai/tests/strip-to-spec-middleware.test.ts b/packages/ai/tests/strip-to-spec-middleware.test.ts index e27a7e7a7..15d23b7b6 100644 --- a/packages/ai/tests/strip-to-spec-middleware.test.ts +++ b/packages/ai/tests/strip-to-spec-middleware.test.ts @@ -21,7 +21,7 @@ describe('stripToSpec', () => { error: { message: 'Something went wrong' }, model: 'gpt-4o', }) - const result = stripToSpec(chunk) as Record + const result = stripToSpec(chunk) as unknown as Record expect(result).not.toHaveProperty('error') expect(result).toHaveProperty('message', 'Something went wrong') expect(result).toHaveProperty('code', 'INTERNAL_ERROR') @@ -49,7 +49,7 @@ describe('stripToSpec', () => { finishReason: 'stop', usage: { promptTokens: 100, completionTokens: 50, totalTokens: 150 }, }) - const result = stripToSpec(chunk) as Record + const result = stripToSpec(chunk) as unknown as Record expect(result).toHaveProperty('model', 'gpt-4o') expect(result).toHaveProperty('finishReason', 'stop') expect(result).toHaveProperty('usage') @@ -64,7 +64,7 @@ describe('stripToSpec', () => { result: '{"items":[]}', model: 'gpt-4o', }) - const result = stripToSpec(chunk) as Record + const result = stripToSpec(chunk) as unknown as Record expect(result).toHaveProperty('toolName', 'getTodos') expect(result).toHaveProperty('toolCallName', 'getTodos') expect(result).toHaveProperty('input') diff --git a/packages/ai/tests/tool-call-manager.test.ts b/packages/ai/tests/tool-call-manager.test.ts index 5927e1682..9b9d734f2 100644 --- a/packages/ai/tests/tool-call-manager.test.ts +++ b/packages/ai/tests/tool-call-manager.test.ts @@ -15,11 +15,14 @@ import type { /** Helper to create a ToolCallStartEvent from plain fields (avoids EventType enum issues). */ function toolCallStart( - fields: Omit, + fields: Omit, ): ToolCallStartEvent { return { type: 'TOOL_CALL_START' as any, timestamp: Date.now(), + // `toolName` is the deprecated alias of `toolCallName`; mirror it + // so tests don't have to write both. + toolName: fields.toolCallName, ...fields, } as ToolCallStartEvent } @@ -37,11 +40,14 @@ function toolCallArgs( /** Helper to create a ToolCallEndEvent from plain fields. */ function toolCallEnd( - fields: Omit, + fields: Omit & { + toolCallName: string + }, ): ToolCallEndEvent { return { type: 'TOOL_CALL_END' as any, timestamp: Date.now(), + toolName: fields.toolCallName, ...fields, } as ToolCallEndEvent } diff --git a/packages/ai/tests/tool-calls-null-input.test.ts b/packages/ai/tests/tool-calls-null-input.test.ts index 10f90dada..8a7640945 100644 --- a/packages/ai/tests/tool-calls-null-input.test.ts +++ b/packages/ai/tests/tool-calls-null-input.test.ts @@ -115,6 +115,8 @@ describe('null tool input normalization', () => { manager.completeToolCall({ type: EventType.TOOL_CALL_END, toolCallId: 'tc-1', + toolCallName: 'test_tool', + toolName: 'test_tool', timestamp: Date.now(), input: null as unknown, }) @@ -139,6 +141,8 @@ describe('null tool input normalization', () => { manager.completeToolCall({ type: EventType.TOOL_CALL_END, toolCallId: 'tc-1', + toolCallName: 'test_tool', + toolName: 'test_tool', timestamp: Date.now(), input: { location: 'NYC' }, }) diff --git a/packages/ai/tests/type-check.test.ts b/packages/ai/tests/type-check.test.ts index f82f46d9b..43270d54e 100644 --- a/packages/ai/tests/type-check.test.ts +++ b/packages/ai/tests/type-check.test.ts @@ -1,13 +1,27 @@ /** - * Type-level tests for TextActivityOptions + * Type-level tests for TextActivityOptions and TypedStreamChunk * These should fail to compile if the types are incorrect */ import { describe, it, expectTypeOf } from 'vitest' -import { createChatOptions } from '../src' +import { z } from 'zod' +import { chat, createChatOptions, toolDefinition } from '../src' +import type { + JSONSchema, + ProviderTool, + StreamChunk, + Tool, + ToolCallArgsEvent, + ToolCallStartEvent, + ToolCallEndEvent, + TypedStreamChunk, +} from '../src' import type { TextAdapter } from '../src/activities/chat/adapter' -// Mock adapter for testing - simulates OpenAI adapter +// =========================== +// Mock adapter (inline — needed for typeof in generic args) +// =========================== + type MockAdapter = TextAdapter< 'test-model', { validOption: string; anotherOption?: number }, @@ -29,6 +43,8 @@ const mockAdapter = { providerOptions: {} as { validOption: string; anotherOption?: number }, inputModalities: ['text', 'image'] as const, messageMetadataByModality: { + // These `as unknown` casts are necessary — TextAdapter requires all 5 + // modality keys but the mock doesn't have real metadata types for them. text: undefined as unknown, image: undefined as unknown, audio: undefined as unknown, @@ -43,9 +59,87 @@ const mockAdapter = { structuredOutput: async () => ({ data: {}, rawText: '{}' }), } satisfies MockAdapter +// =========================== +// Tool definitions for type tests +// =========================== + +const weatherTool = toolDefinition({ + name: 'get_weather', + description: 'Get weather', + inputSchema: z.object({ + location: z.string(), + unit: z.enum(['celsius', 'fahrenheit']).optional(), + }), + outputSchema: z.object({ + temperature: z.number(), + conditions: z.string(), + }), +}) + +const searchTool = toolDefinition({ + name: 'search', + description: 'Search the web', + inputSchema: z.object({ + query: z.string(), + }), +}) + +const weatherServerTool = weatherTool.server(async () => ({ + temperature: 72, + conditions: 'sunny', +})) + +const searchClientTool = searchTool.client(async () => 'results') + +const noInputTool = toolDefinition({ + name: 'get_time', + description: 'Get the current time', +}) + +const jsonSchemaTool: Tool = { + name: 'json_tool', + description: 'A tool with plain JSON Schema', + inputSchema: { + type: 'object', + properties: { key: { type: 'string' } }, + }, +} + +// Provider tools carry opaque metadata and intentionally have a generic +// `string` name — used here to assert `NonProviderTools` correctly partitions +// them out so they don't widen the typed tool-call discriminated union. +const fakeProviderTool: ProviderTool<'fake-provider', 'web_search'> = { + name: 'web_search', + description: 'Provider-native web search', + '~provider': 'fake-provider', + '~toolKind': 'web_search', +} + +// =========================== +// Type-level helpers to reduce Extract repetition +// =========================== + +/** Extract the TOOL_CALL_START event from a chunk union */ +type StartEventOf = Extract + +/** Extract the TOOL_CALL_END event from a chunk union */ +type EndEventOf = Extract + +/** Extract the chunk type from an AsyncIterable (e.g. chat() return) */ +type ChunkOf = T extends AsyncIterable ? C : never + +/** Build the full TypedStreamChunk and extract both event types at once */ +type ToolEventsOf>> = { + start: StartEventOf> + end: EndEventOf> +} + +// =========================== +// TextActivityOptions type checking (pre-existing) +// =========================== + describe('TextActivityOptions type checking', () => { it('should allow valid options', () => { - // This should type-check successfully const options = createChatOptions({ adapter: mockAdapter, messages: [{ role: 'user', content: 'Hello' }], @@ -78,3 +172,549 @@ describe('TextActivityOptions type checking', () => { }) }) }) + +// =========================== +// TypedStreamChunk: tool name and input typing +// =========================== + +describe('TypedStreamChunk tool call type safety', () => { + describe('tool name typing', () => { + it('should narrow toolName to literal union on both START and END events', () => { + type E = ToolEventsOf<[typeof weatherTool, typeof searchTool]> + + expectTypeOf().toEqualTypeOf< + 'get_weather' | 'search' + >() + expectTypeOf().toEqualTypeOf< + 'get_weather' | 'search' + >() + }) + + it('should narrow toolName to a single literal with one tool', () => { + type E = ToolEventsOf<[typeof weatherTool]> + + expectTypeOf().toEqualTypeOf<'get_weather'>() + expectTypeOf().toEqualTypeOf<'get_weather'>() + }) + }) + + describe('tool input typing', () => { + it('should type input as the union of tool input types', () => { + type E = ToolEventsOf<[typeof weatherTool, typeof searchTool]> + + type ExpectedInput = + | { location: string; unit?: 'celsius' | 'fahrenheit' } + | { query: string } + expectTypeOf< + Exclude + >().toEqualTypeOf() + }) + + it('should type input correctly with a single tool', () => { + type E = ToolEventsOf<[typeof searchTool]> + + expectTypeOf>().toEqualTypeOf<{ + query: string + }>() + }) + + it('should produce unknown input for tools without inputSchema', () => { + type E = ToolEventsOf<[typeof noInputTool]> + + // Use toBeUnknown() instead of toEqualTypeOf() — + // the latter can't distinguish `any` from `unknown` in vitest. + expectTypeOf>().toBeUnknown() + }) + + it('should produce unknown input for plain JSON Schema tools', () => { + type E = ToolEventsOf<[typeof jsonSchemaTool]> + + expectTypeOf>().toBeUnknown() + }) + + it('should preserve tool names when mixing Zod and no-schema tools', () => { + type E = ToolEventsOf<[typeof searchTool, typeof noInputTool]> + + expectTypeOf().toEqualTypeOf< + 'search' | 'get_time' + >() + }) + }) + + describe('discriminated union narrowing', () => { + it('should narrow input to specific tool type when checking toolName', () => { + type Chunk = TypedStreamChunk<[typeof weatherTool, typeof searchTool]> + type End = Extract + + // Narrowing by toolName should give the specific tool's input type + type WeatherEnd = Extract + expectTypeOf>().toEqualTypeOf<{ + location: string + unit?: 'celsius' | 'fahrenheit' + }>() + + type SearchEnd = Extract + expectTypeOf>().toEqualTypeOf<{ + query: string + }>() + }) + + it('should narrow START events by toolName', () => { + type Chunk = TypedStreamChunk<[typeof weatherTool, typeof searchTool]> + type Start = Extract + + type WeatherStart = Extract + expectTypeOf().toEqualTypeOf<'get_weather'>() + + type SearchStart = Extract + expectTypeOf().toEqualTypeOf<'search'>() + }) + + it('should narrow input with three or more tools', () => { + type Chunk = TypedStreamChunk< + [typeof weatherTool, typeof searchTool, typeof noInputTool] + > + type End = Extract + + type WeatherEnd = Extract + expectTypeOf>().toEqualTypeOf<{ + location: string + unit?: 'celsius' | 'fahrenheit' + }>() + + type SearchEnd = Extract + expectTypeOf>().toEqualTypeOf<{ + query: string + }>() + + type TimeEnd = Extract + expectTypeOf>().toBeUnknown() + }) + + it('should narrow input through chat() return type', () => { + const stream = chat({ + adapter: mockAdapter, + messages: [], + tools: [weatherTool, searchTool], + }) + type Chunk = ChunkOf + type End = Extract + + type WeatherEnd = Extract + expectTypeOf>().toEqualTypeOf<{ + location: string + unit?: 'celsius' | 'fahrenheit' + }>() + + type SearchEnd = Extract + expectTypeOf>().toEqualTypeOf<{ + query: string + }>() + }) + + it('should narrow input with server tool variants', () => { + type Chunk = TypedStreamChunk< + [typeof weatherServerTool, typeof searchClientTool] + > + type End = Extract + + type WeatherEnd = Extract + expectTypeOf>().toEqualTypeOf<{ + location: string + unit?: 'celsius' | 'fahrenheit' + }>() + + type SearchEnd = Extract + // .client() preserves the original inputSchema type from the base definition + expectTypeOf>().toEqualTypeOf<{ + query: string + }>() + }) + }) + + describe('server and client tool variants', () => { + it('should type ServerTool name and input from .server()', () => { + type E = ToolEventsOf<[typeof weatherServerTool]> + + expectTypeOf().toEqualTypeOf<'get_weather'>() + expectTypeOf>().toEqualTypeOf<{ + location: string + unit?: 'celsius' | 'fahrenheit' + }>() + }) + + it('should type ClientTool name from .client()', () => { + type E = ToolEventsOf<[typeof searchClientTool]> + + expectTypeOf().toEqualTypeOf<'search'>() + }) + + it('should deduplicate names across definition, server, and client variants', () => { + type E = ToolEventsOf< + [typeof weatherTool, typeof weatherServerTool, typeof searchClientTool] + > + + expectTypeOf().toEqualTypeOf< + 'get_weather' | 'search' + >() + }) + + it('should narrow input through chat() with server/client tools', () => { + const stream = chat({ + adapter: mockAdapter, + messages: [], + tools: [weatherServerTool, searchClientTool], + }) + type Chunk = ChunkOf + type End = Extract + + type WeatherEnd = Extract + expectTypeOf>().toEqualTypeOf<{ + location: string + unit?: 'celsius' | 'fahrenheit' + }>() + + type SearchEnd = Extract + expectTypeOf>().toEqualTypeOf<{ + query: string + }>() + }) + }) + + describe('mixed schema types', () => { + it('should narrow per-tool when mixing Zod and JSON Schema tools', () => { + type Chunk = TypedStreamChunk<[typeof searchTool, typeof jsonSchemaTool]> + type End = Extract + + type SearchEnd = Extract + expectTypeOf>().toEqualTypeOf<{ + query: string + }>() + + type JsonEnd = Extract + expectTypeOf>().toBeUnknown() + }) + }) + + describe('non-tool events are preserved', () => { + it('should include all non-tool-call AG-UI events in the union', () => { + type Chunk = TypedStreamChunk<[typeof weatherTool]> + + // Every AG-UI event type should still be extractable + expectTypeOf>().not.toBeNever() + expectTypeOf>().not.toBeNever() + expectTypeOf>().not.toBeNever() + expectTypeOf< + Extract + >().not.toBeNever() + expectTypeOf< + Extract + >().not.toBeNever() + expectTypeOf< + Extract + >().not.toBeNever() + expectTypeOf>().not.toBeNever() + expectTypeOf>().not.toBeNever() + expectTypeOf< + Extract + >().not.toBeNever() + expectTypeOf>().not.toBeNever() + expectTypeOf>().not.toBeNever() + expectTypeOf>().not.toBeNever() + }) + + it('should keep ToolCallArgsEvent unparameterized (string delta, no toolName)', () => { + type Chunk = TypedStreamChunk<[typeof weatherTool]> + type ArgsEvent = Extract + + expectTypeOf().not.toBeNever() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toMatchTypeOf() + }) + }) +}) + +// =========================== +// chat() return type integration +// =========================== + +describe('chat() tool type inference', () => { + it('should infer typed tool names through chat() return type', () => { + type Chunk = ChunkOf< + ReturnType< + typeof chat< + typeof mockAdapter, + undefined, + true, + [typeof weatherTool, typeof searchTool] + > + > + > + + expectTypeOf['toolName']>().toEqualTypeOf< + 'get_weather' | 'search' + >() + expectTypeOf['toolName']>().toEqualTypeOf< + 'get_weather' | 'search' + >() + }) + + it('should infer TTools from options.tools without explicit type args', () => { + // This is the actual user-facing API — if inference breaks, users silently + // get `string` for toolName even when passing typed tools. + const stream = chat({ + adapter: mockAdapter, + messages: [], + tools: [weatherTool, searchTool], + }) + type Chunk = ChunkOf + + expectTypeOf['toolName']>().toEqualTypeOf< + 'get_weather' | 'search' + >() + expectTypeOf['toolName']>().toEqualTypeOf< + 'get_weather' | 'search' + >() + }) + + it('should return Promise when stream: false, regardless of tools', () => { + type Result = ReturnType< + typeof chat + > + + expectTypeOf().toEqualTypeOf>() + }) + + it('should return Promise when outputSchema is provided without explicit stream', () => { + // Per issue #526, schema-bearing calls default to Promise. + // Only explicit `stream: true` opts into StructuredOutputStream. + const schema = z.object({ summary: z.string() }) + type Result = ReturnType< + typeof chat< + typeof mockAdapter, + typeof schema, + boolean, + [typeof weatherTool] + > + > + + expectTypeOf().toEqualTypeOf>() + }) +}) + +// =========================== +// createChatOptions() preserves TTools +// =========================== + +describe('createChatOptions() tool type preservation', () => { + it('should preserve specific tool types through options helper', () => { + const opts = createChatOptions({ + adapter: mockAdapter, + tools: [weatherTool, searchTool], + }) + + type ToolsType = Exclude + + // Use union check — tuple ordering is not guaranteed across TS versions + expectTypeOf().toEqualTypeOf< + 'get_weather' | 'search' + >() + }) +}) + +// =========================== +// Fallback / default behavior +// =========================== + +describe('TypedStreamChunk fallback behavior', () => { + it('should fallback to string/unknown with no tools (default generic)', () => { + type Chunk = ChunkOf>> + + expectTypeOf['toolName']>().toEqualTypeOf() + expectTypeOf['toolName']>().toEqualTypeOf() + expectTypeOf['input'], undefined>>().toBeUnknown() + }) + + it('should fallback to string/unknown with empty tools array', () => { + type E = ToolEventsOf<[]> + + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf>().toBeUnknown() + }) + + it('should fallback to string/unknown when used without type args', () => { + type E = { + start: StartEventOf + end: EndEventOf + } + + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf>().toBeUnknown() + }) + + it('should handle readonly tools array (as const)', () => { + const tools = [weatherTool, searchTool] as const + type E = ToolEventsOf + + expectTypeOf().toEqualTypeOf< + 'get_weather' | 'search' + >() + }) + + it('should fall back to untyped events when the array contains ONLY ProviderTools', () => { + // ProviderTool has `name: string` (generic), so HasTypedTools must report + // false for arrays containing only ProviderTools — otherwise the + // discriminated union would widen `toolName` back to `string` and defeat + // the entire typing exercise. Regression guard for NonProviderTools. + type E = ToolEventsOf<[typeof fakeProviderTool]> + + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf>().toBeUnknown() + }) + + it('should keep narrowing on user tools when mixed with ProviderTools', () => { + // The motivating mixed case: a user passes `[webSearchTool, myTypedTool]` + // — they should still get typed narrowing for `myTypedTool`. Partition + // strips the ProviderTool, leaving the typed tool's literal name. + type E = ToolEventsOf<[typeof fakeProviderTool, typeof weatherTool]> + + expectTypeOf().toEqualTypeOf<'get_weather'>() + expectTypeOf().toEqualTypeOf<'get_weather'>() + }) +}) + +// =========================== +// Backward compatibility +// =========================== + +describe('backward compatibility', () => { + it('should preserve unparameterized ToolCallStartEvent/ToolCallEndEvent defaults', () => { + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf>().toBeUnknown() + }) + + it('should treat explicit defaults as identical to unparameterized', () => { + expectTypeOf< + ToolCallStartEvent + >().toEqualTypeOf() + expectTypeOf< + ToolCallEndEvent + >().toEqualTypeOf() + }) + + it('should make typed events assignable to untyped events', () => { + expectTypeOf< + ToolCallStartEvent<'get_weather'> + >().toMatchTypeOf() + expectTypeOf< + ToolCallEndEvent<'get_weather', { location: string }> + >().toMatchTypeOf() + }) + + it('should make TypedStreamChunk assignable to StreamChunk', () => { + type Typed = TypedStreamChunk<[typeof weatherTool]> + expectTypeOf().toMatchTypeOf() + }) + + it('should keep StreamChunk itself unchanged', () => { + type Start = Extract + expectTypeOf().toEqualTypeOf() + }) +}) + +// =========================== +// TypedStreamChunk: tagged custom events +// =========================== + +describe('TypedStreamChunk tagged custom event narrowing', () => { + it('should narrow approval-requested CUSTOM event payload', () => { + type Chunk = TypedStreamChunk<[typeof weatherTool]> + type Approval = Extract< + Chunk, + { type: 'CUSTOM'; name: 'approval-requested' } + > + + expectTypeOf().toEqualTypeOf<{ + toolCallId: string + toolName: string + input: unknown + approval: { id: string; needsApproval: true } + }>() + }) + + it('should narrow tool-input-available CUSTOM event payload', () => { + type Chunk = TypedStreamChunk<[typeof weatherTool]> + type ToolInput = Extract< + Chunk, + { type: 'CUSTOM'; name: 'tool-input-available' } + > + + expectTypeOf().toEqualTypeOf<{ + toolCallId: string + toolName: string + input: unknown + }>() + }) + + it('should narrow structured-output.start CUSTOM event payload', () => { + type Chunk = TypedStreamChunk<[typeof weatherTool]> + type Start = Extract< + Chunk, + { type: 'CUSTOM'; name: 'structured-output.start' } + > + + expectTypeOf().toEqualTypeOf<{ messageId: string }>() + }) + + it('should narrow structured-output.complete CUSTOM event payload', () => { + type Chunk = TypedStreamChunk<[typeof weatherTool]> + type Complete = Extract< + Chunk, + { type: 'CUSTOM'; name: 'structured-output.complete' } + > + + // Adapter-emitted form: T defaults to unknown, narrowed by orchestrator later + expectTypeOf().toEqualTypeOf<{ + object: unknown + raw: string + reasoning?: string + }>() + }) + + it('should narrow CustomEvent to the TaggedCustomEvent union even without typed tools', () => { + // The tagged shapes the engine emits (`structured-output.start`, + // `structured-output.complete`, `approval-requested`, + // `tool-input-available`) don't depend on the tools array, so they + // narrow in the fallback branch too — `chunk.type === 'CUSTOM' && + // chunk.name === '...'` works whether the caller passed typed tools + // or not. + type Chunk = TypedStreamChunk + type Custom = Extract + // `name` is the discriminated union of tagged literals. + expectTypeOf().toEqualTypeOf< + | 'structured-output.start' + | 'structured-output.complete' + | 'approval-requested' + | 'tool-input-available' + >() + }) + + it('should not poison `value` to any across the CUSTOM union', () => { + // Regression test: when bare CustomEvent (`value: any`) gets unioned with + // tagged variants, the discriminated narrow loses type information. + // Picking any tagged variant must keep its `value` shape intact rather + // than collapsing to `any`. + type Chunk = TypedStreamChunk<[typeof weatherTool]> + type Approval = Extract< + Chunk, + { type: 'CUSTOM'; name: 'approval-requested' } + > + + // toBeAny() inverts the assertion — this guards the regression. + expectTypeOf().not.toBeAny() + }) +})