diff --git a/.changeset/two-bikes-kneel.md b/.changeset/two-bikes-kneel.md new file mode 100644 index 00000000..49440cf4 --- /dev/null +++ b/.changeset/two-bikes-kneel.md @@ -0,0 +1,9 @@ +--- +'@tanstack/ai-anthropic': minor +'@tanstack/ai-gemini': minor +'@tanstack/ai-ollama': minor +'@tanstack/ai-openai': minor +'@tanstack/ai': minor +--- + +Split up adapters for better tree shaking into separate functionalities diff --git a/docs/api/ai.md b/docs/api/ai.md index 626fa047..02344ccb 100644 --- a/docs/api/ai.md +++ b/docs/api/ai.md @@ -12,9 +12,176 @@ The core AI library for TanStack AI. npm install @tanstack/ai ``` -## `chat(options)` +## Migration: `chat` → `text` + `agentLoop` -Creates a streaming chat response. +The `chat` function is being replaced by two focused functions: + +| Old API | New API | Use Case | +|---------|---------|----------| +| `chat({ tools: [...] })` | `agentLoop({ ... })` | Agentic workflows with tool execution | +| `chat({ })` (no tools) | `text({ ... })` | Simple text generation | + +Both new functions are currently exported as `experimental_text` and `experimental_agentLoop`. + +### Why the change? + +- **Clearer intent**: `text` is for simple generation, `agentLoop` is for agentic workflows +- **Simpler APIs**: Each function is focused on its use case +- **Better defaults**: No need to configure agent loop strategies for simple text generation + +--- + +## `text(options)` (Experimental) + +Simple text generation without tool support. Use this for straightforward chat completions and structured output. + +```typescript +import { experimental_text as text } from "@tanstack/ai"; +import { openaiText } from "@tanstack/ai-openai"; + +// Streaming (default) +for await (const chunk of text({ + adapter: openaiText("gpt-4o"), + messages: [{ role: "user", content: "Tell me a joke" }], +})) { + if (chunk.type === "content") { + process.stdout.write(chunk.delta); + } +} + +// Non-streaming +const response = await text({ + adapter: openaiText("gpt-4o"), + messages: [{ role: "user", content: "What is 2+2?" }], + stream: false, +}); + +// Structured output +import { z } from "zod"; +const person = await text({ + adapter: openaiText("gpt-4o"), + messages: [{ role: "user", content: "Generate a person" }], + outputSchema: z.object({ + name: z.string(), + age: z.number(), + }), +}); +``` + +### Options + +| Option | Type | Description | +|--------|------|-------------| +| `adapter` | `AnyTextAdapter` | **Required.** The text adapter (e.g., `openaiText('gpt-4o')`) | +| `messages` | `ModelMessage[]` | Conversation messages | +| `systemPrompts` | `string[]` | System prompts to prepend | +| `temperature` | `number` | Randomness (0.0-2.0) | +| `topP` | `number` | Nucleus sampling parameter | +| `maxTokens` | `number` | Maximum tokens to generate | +| `metadata` | `Record` | Request metadata | +| `modelOptions` | Provider-specific | Model-specific options | +| `abortController` | `AbortController` | For cancellation | +| `conversationId` | `string` | Tracking identifier | +| `outputSchema` | `StandardSchema` | For structured output | +| `stream` | `boolean` | Stream output (default: `true`) | + +### Returns + +- Default: `AsyncIterable` (streaming) +- With `stream: false`: `Promise` +- With `outputSchema`: `Promise>` + +--- + +## `agentLoop(options)` (Experimental) + +Agentic text generation with automatic tool execution. Use this when you need the model to call tools and loop until completion. + +```typescript +import { experimental_agentLoop as agentLoop, maxIterations } from "@tanstack/ai"; +import { openaiText } from "@tanstack/ai-openai"; + +// Streaming with tools +for await (const chunk of agentLoop({ + adapter: openaiText("gpt-4o"), + messages: [{ role: "user", content: "What's the weather in Paris?" }], + tools: [weatherTool], +})) { + if (chunk.type === "content") { + process.stdout.write(chunk.delta); + } else if (chunk.type === "tool_result") { + console.log("Tool result:", chunk.content); + } +} + +// Structured output with tools +import { z } from "zod"; +const result = await agentLoop({ + adapter: openaiText("gpt-4o"), + messages: [{ role: "user", content: "Research the weather and summarize" }], + tools: [weatherTool, searchTool], + outputSchema: z.object({ + summary: z.string(), + temperature: z.number(), + }), +}); + +// With agent loop strategy +for await (const chunk of agentLoop({ + adapter: openaiText("gpt-4o"), + messages: [{ role: "user", content: "Complete this task" }], + tools: [myTools], + agentLoopStrategy: maxIterations(10), +})) { + // ... +} +``` + +### Options + +| Option | Type | Description | +|--------|------|-------------| +| `adapter` | `AnyTextAdapter` | **Required.** The text adapter (e.g., `openaiText('gpt-4o')`) | +| `messages` | `ModelMessage[]` | **Required.** Conversation messages | +| `tools` | `Tool[]` | Tools for function calling | +| `systemPrompts` | `string[]` | System prompts to prepend | +| `temperature` | `number` | Randomness (0.0-2.0) | +| `topP` | `number` | Nucleus sampling parameter | +| `maxTokens` | `number` | Maximum tokens to generate | +| `metadata` | `Record` | Request metadata | +| `modelOptions` | Provider-specific | Model-specific options | +| `abortController` | `AbortController` | For cancellation | +| `agentLoopStrategy` | `AgentLoopStrategy` | Controls loop behavior (default: `maxIterations(5)`) | +| `conversationId` | `string` | Tracking identifier | +| `outputSchema` | `z.ZodType` | For structured output after tool execution | + +### Returns + +- Default: `AsyncIterable` (streaming) +- With `outputSchema`: `Promise>` + +### Legacy API + +The `agentLoop` function also supports a callback-based API for advanced use cases: + +```typescript +const textFn = (opts) => chat({ adapter: openaiText("gpt-4o"), ...opts }); + +for await (const chunk of agentLoop(textFn, { + messages: [...], + tools: [...], +})) { + // ... +} +``` + +--- + +## `chat(options)` (Deprecated) + +> **Note**: `chat` will be replaced by `text` (for simple generation) and `agentLoop` (for agentic workflows). See the migration guide above. + +Creates a streaming chat response with optional tool support. ```typescript import { chat } from "@tanstack/ai"; @@ -31,45 +198,19 @@ const stream = chat({ ### Parameters -- `adapter` - An AI adapter instance with model (e.g., `openaiText('gpt-4o')`, `anthropicText('claude-sonnet-4-5')`) +- `adapter` - An AI adapter instance with model (e.g., `openaiText('gpt-4o')`) - `messages` - Array of chat messages - `tools?` - Array of tools for function calling - `systemPrompts?` - System prompts to prepend to messages - `agentLoopStrategy?` - Strategy for agent loops (default: `maxIterations(5)`) - `abortController?` - AbortController for cancellation -- `modelOptions?` - Model-specific options (renamed from `providerOptions`) +- `modelOptions?` - Model-specific options ### Returns An async iterable of `StreamChunk`. -## `summarize(options)` - -Creates a text summarization. - -```typescript -import { summarize } from "@tanstack/ai"; -import { openaiSummarize } from "@tanstack/ai-openai"; - -const result = await summarize({ - adapter: openaiSummarize("gpt-4o"), - text: "Long text to summarize...", - maxLength: 100, - style: "concise", -}); -``` - -### Parameters - -- `adapter` - An AI adapter instance with model -- `text` - Text to summarize -- `maxLength?` - Maximum length of summary -- `style?` - Summary style ("concise" | "detailed") -- `modelOptions?` - Model-specific options - -### Returns - -A `SummarizationResult` with the summary text. +--- ## `toolDefinition(config)` @@ -88,33 +229,24 @@ const myToolDef = toolDefinition({ outputSchema: z.object({ result: z.string(), }), - needsApproval: false, // Optional + needsApproval: false, }); -// Or create client implementation -const myClientTool = myToolDef.client(async ({ param }) => { - // Client-side implementation +// Server implementation +const myServerTool = myToolDef.server(async ({ param }) => { return { result: "..." }; }); -// Use directly in chat() (server-side, no execute) -chat({ - adapter: openaiText("gpt-4o"), - tools: [myToolDef], - messages: [{ role: "user", content: "..." }], -}); - -// Or create server implementation -const myServerTool = myToolDef.server(async ({ param }) => { - // Server-side implementation +// Client implementation +const myClientTool = myToolDef.client(async ({ param }) => { return { result: "..." }; }); -// Use directly in chat() (server-side, no execute) -chat({ +// Use in agentLoop +agentLoop({ adapter: openaiText("gpt-4o"), tools: [myServerTool], - messages: [{ role: "user", content: "..." }], + messages: [...], }); ``` @@ -129,81 +261,97 @@ chat({ ### Returns -A `ToolDefinition` object with `.server()` and `.client()` methods for creating concrete implementations. +A `ToolDefinition` object with `.server()` and `.client()` methods. -## `toServerSentEventsStream(stream, abortController?)` +--- -Converts a stream to a ReadableStream in Server-Sent Events format. +## Agent Loop Strategies + +Control how the agent loop behaves with these strategy functions. + +### `maxIterations(count)` + +Limits the number of tool execution iterations. ```typescript -import { chat, toServerSentEventsStream } from "@tanstack/ai"; -import { openaiText } from "@tanstack/ai-openai"; +import { maxIterations } from "@tanstack/ai"; -const stream = chat({ +agentLoop({ adapter: openaiText("gpt-4o"), messages: [...], + tools: [...], + agentLoopStrategy: maxIterations(10), }); -const readableStream = toServerSentEventsStream(stream); ``` -### Parameters +### `untilFinishReason(reason)` -- `stream` - Async iterable of `StreamChunk` -- `abortController?` - Optional AbortController to abort when stream is cancelled +Continues until a specific finish reason is reached. -### Returns +```typescript +import { untilFinishReason } from "@tanstack/ai"; -A `ReadableStream` in Server-Sent Events format. Each chunk is: -- Prefixed with `"data: "` -- Followed by `"\n\n"` -- Stream ends with `"data: [DONE]\n\n"` +agentLoop({ + // ... + agentLoopStrategy: untilFinishReason("stop"), +}); +``` -## `toStreamResponse(stream, init?)` +### `combineStrategies(...strategies)` -Converts a stream to an HTTP Response with proper SSE headers. +Combines multiple strategies (all must return true to continue). ```typescript -import { chat, toStreamResponse } from "@tanstack/ai"; -import { openaiText } from "@tanstack/ai-openai"; - -const stream = chat({ - adapter: openaiText("gpt-4o"), - messages: [...], +import { combineStrategies, maxIterations, untilFinishReason } from "@tanstack/ai"; + +agentLoop({ + // ... + agentLoopStrategy: combineStrategies( + maxIterations(20), + untilFinishReason("stop") + ), }); -return toStreamResponse(stream); ``` -### Parameters - -- `stream` - Async iterable of `StreamChunk` -- `init?` - Optional ResponseInit options (including `abortController`) - -### Returns +--- -A `Response` object suitable for HTTP endpoints with SSE headers (`Content-Type: text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive`). +## Stream Utilities -## `maxIterations(count)` +### `toServerSentEventsStream(stream, abortController?)` -Creates an agent loop strategy that limits iterations. +Converts a stream to a ReadableStream in Server-Sent Events format. ```typescript -import { chat, maxIterations } from "@tanstack/ai"; -import { openaiText } from "@tanstack/ai-openai"; +import { toServerSentEventsStream, agentLoop } from "@tanstack/ai"; -const stream = chat({ - adapter: openaiText("gpt-4o"), - messages: [...], - agentLoopStrategy: maxIterations(20), +const stream = agentLoop({ adapter, messages, tools }); +const readableStream = toServerSentEventsStream(stream); + +return new Response(readableStream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, }); ``` -### Parameters +### `streamToText(stream)` -- `count` - Maximum number of tool execution iterations +Collects a stream into a single string. -### Returns +```typescript +import { streamToText, experimental_text as text } from "@tanstack/ai"; + +const result = await streamToText( + text({ + adapter: openaiText("gpt-4o"), + messages: [{ role: "user", content: "Hello" }], + }) +); +``` -An `AgentLoopStrategy` object. +--- ## Types @@ -227,21 +375,12 @@ type StreamChunk = | ToolResultStreamChunk | DoneStreamChunk | ErrorStreamChunk; - -interface ThinkingStreamChunk { - type: "thinking"; - id: string; - model: string; - timestamp: number; - delta?: string; // Incremental thinking token - content: string; // Accumulated thinking content -} ``` -Stream chunks represent different types of data in the stream: +Stream chunks represent different types of data: - **Content chunks** - Text content being generated -- **Thinking chunks** - Model's reasoning process (when supported by the model) +- **Thinking chunks** - Model's reasoning process (when supported) - **Tool call chunks** - When the model calls a tool - **Tool result chunks** - Results from tool execution - **Done chunks** - Stream completion @@ -262,84 +401,112 @@ interface Tool { } ``` -## Usage Examples +--- + +## Complete Examples + +### Simple Text Generation ```typescript -import { chat, summarize, generateImage } from "@tanstack/ai"; -import { - openaiText, - openaiSummarize, - openaiImage, -} from "@tanstack/ai-openai"; - -// --- Streaming chat -const stream = chat({ +import { experimental_text as text } from "@tanstack/ai"; +import { openaiText } from "@tanstack/ai-openai"; + +// Streaming +for await (const chunk of text({ adapter: openaiText("gpt-4o"), - messages: [{ role: "user", content: "Hello!" }], -}); + messages: [{ role: "user", content: "Write a haiku" }], +})) { + if (chunk.type === "content") { + process.stdout.write(chunk.delta); + } +} -// --- One-shot chat response (stream: false) -const response = await chat({ +// Non-streaming +const response = await text({ adapter: openaiText("gpt-4o"), messages: [{ role: "user", content: "What's the capital of France?" }], - stream: false, // Returns a Promise instead of AsyncIterable + stream: false, }); +``` + +### Agentic Workflow with Tools -// --- Structured response with outputSchema +```typescript +import { experimental_agentLoop as agentLoop, toolDefinition } from "@tanstack/ai"; +import { openaiText } from "@tanstack/ai-openai"; import { z } from "zod"; -const parsed = await chat({ - adapter: openaiText("gpt-4o"), - messages: [{ role: "user", content: "Summarize this text in JSON with keys 'summary' and 'keywords': ... " }], - outputSchema: z.object({ - summary: z.string(), - keywords: z.array(z.string()), - }), -}); -// --- Structured response with tools -import { toolDefinition } from "@tanstack/ai"; const weatherTool = toolDefinition({ name: "getWeather", description: "Get the current weather for a city", inputSchema: z.object({ - city: z.string().describe("City name"), + city: z.string(), }), }).server(async ({ city }) => { - // Implementation that fetches weather info return JSON.stringify({ temperature: 72, condition: "Sunny" }); }); -const toolResult = await chat({ +for await (const chunk of agentLoop({ adapter: openaiText("gpt-4o"), - messages: [ - { role: "user", content: "What's the weather in Paris?" } - ], + messages: [{ role: "user", content: "What's the weather in Paris?" }], tools: [weatherTool], +})) { + if (chunk.type === "content") { + process.stdout.write(chunk.delta); + } +} +``` + +### Structured Output + +```typescript +import { experimental_text as text } from "@tanstack/ai"; +import { openaiText } from "@tanstack/ai-openai"; +import { z } from "zod"; + +const person = await text({ + adapter: openaiText("gpt-4o"), + messages: [{ role: "user", content: "Generate a fictional character" }], outputSchema: z.object({ - answer: z.string(), - weather: z.object({ - temperature: z.number(), - condition: z.string(), - }), + name: z.string(), + age: z.number(), + backstory: z.string(), }), }); -// --- Summarization -const summary = await summarize({ - adapter: openaiSummarize("gpt-4o"), - text: "Long text to summarize...", - maxLength: 100, +console.log(person.name, person.age); +``` + +### Structured Output with Tool Execution + +```typescript +import { experimental_agentLoop as agentLoop, toolDefinition } from "@tanstack/ai"; +import { openaiText } from "@tanstack/ai-openai"; +import { z } from "zod"; + +const searchTool = toolDefinition({ + name: "search", + description: "Search for information", + inputSchema: z.object({ query: z.string() }), +}).server(async ({ query }) => { + return `Results for: ${query}`; }); -// --- Image generation -const image = await generateImage({ - adapter: openaiImage("dall-e-3"), - prompt: "A futuristic city skyline at sunset", - numberOfImages: 1, - size: "1024x1024", +const result = await agentLoop({ + adapter: openaiText("gpt-4o"), + messages: [{ role: "user", content: "Research AI trends and summarize" }], + tools: [searchTool], + outputSchema: z.object({ + summary: z.string(), + keyPoints: z.array(z.string()), + }), }); + +console.log(result.summary); ``` +--- + ## Next Steps - [Getting Started](../getting-started/quick-start) - Learn the basics diff --git a/docs/guides/migration.md b/docs/guides/migration.md index 6e1e4ccd..9023c16a 100644 --- a/docs/guides/migration.md +++ b/docs/guides/migration.md @@ -435,3 +435,4 @@ If you encounter issues during migration: 2. Review the [API Reference](../api/ai) for complete function signatures 3. Look at the [examples](../getting-started/quick-start) for working code samples + diff --git a/docs/reference/classes/BaseAdapter.md b/docs/reference/classes/BaseAdapter.md new file mode 100644 index 00000000..504c176a --- /dev/null +++ b/docs/reference/classes/BaseAdapter.md @@ -0,0 +1,310 @@ +--- +id: BaseAdapter +title: BaseAdapter +--- + +# Abstract Class: BaseAdapter\ + +Defined in: [base-adapter.ts:26](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/base-adapter.ts#L26) + +Base adapter class with support for endpoint-specific models and provider options. + +Generic parameters: +- TChatModels: Models that support chat/text completion +- TEmbeddingModels: Models that support embeddings +- TChatProviderOptions: Provider-specific options for chat endpoint +- TEmbeddingProviderOptions: Provider-specific options for embedding endpoint +- TModelProviderOptionsByName: Provider-specific options for model by name +- TModelInputModalitiesByName: Map from model name to its supported input modalities +- TMessageMetadataByModality: Map from modality type to adapter-specific metadata types + +## Type Parameters + +### TChatModels + +`TChatModels` *extends* `ReadonlyArray`\<`string`\> = `ReadonlyArray`\<`string`\> + +### TEmbeddingModels + +`TEmbeddingModels` *extends* `ReadonlyArray`\<`string`\> = `ReadonlyArray`\<`string`\> + +### TChatProviderOptions + +`TChatProviderOptions` *extends* `Record`\<`string`, `any`\> = `Record`\<`string`, `any`\> + +### TEmbeddingProviderOptions + +`TEmbeddingProviderOptions` *extends* `Record`\<`string`, `any`\> = `Record`\<`string`, `any`\> + +### TModelProviderOptionsByName + +`TModelProviderOptionsByName` *extends* `Record`\<`string`, `any`\> = `Record`\<`string`, `any`\> + +### TModelInputModalitiesByName + +`TModelInputModalitiesByName` *extends* `Record`\<`string`, `ReadonlyArray`\<[`Modality`](../type-aliases/Modality.md)\>\> = `Record`\<`string`, `ReadonlyArray`\<[`Modality`](../type-aliases/Modality.md)\>\> + +### TMessageMetadataByModality + +`TMessageMetadataByModality` *extends* `object` = [`DefaultMessageMetadataByModality`](../interfaces/DefaultMessageMetadataByModality.md) + +## Implements + +- [`AIAdapter`](../interfaces/AIAdapter.md)\<`TChatModels`, `TEmbeddingModels`, `TChatProviderOptions`, `TEmbeddingProviderOptions`, `TModelProviderOptionsByName`, `TModelInputModalitiesByName`, `TMessageMetadataByModality`\> + +## Constructors + +### Constructor + +```ts +new BaseAdapter(config): BaseAdapter; +``` + +Defined in: [base-adapter.ts:70](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/base-adapter.ts#L70) + +#### Parameters + +##### config + +`AIAdapterConfig` = `{}` + +#### Returns + +`BaseAdapter`\<`TChatModels`, `TEmbeddingModels`, `TChatProviderOptions`, `TEmbeddingProviderOptions`, `TModelProviderOptionsByName`, `TModelInputModalitiesByName`, `TMessageMetadataByModality`\> + +## Properties + +### \_chatProviderOptions? + +```ts +optional _chatProviderOptions: TChatProviderOptions; +``` + +Defined in: [base-adapter.ts:61](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/base-adapter.ts#L61) + +#### Implementation of + +[`AIAdapter`](../interfaces/AIAdapter.md).[`_chatProviderOptions`](../interfaces/AIAdapter.md#_chatprovideroptions) + +*** + +### \_embeddingProviderOptions? + +```ts +optional _embeddingProviderOptions: TEmbeddingProviderOptions; +``` + +Defined in: [base-adapter.ts:62](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/base-adapter.ts#L62) + +#### Implementation of + +[`AIAdapter`](../interfaces/AIAdapter.md).[`_embeddingProviderOptions`](../interfaces/AIAdapter.md#_embeddingprovideroptions) + +*** + +### \_messageMetadataByModality? + +```ts +optional _messageMetadataByModality: TMessageMetadataByModality; +``` + +Defined in: [base-adapter.ts:68](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/base-adapter.ts#L68) + +Type-only map from modality type to adapter-specific metadata types. +Used to provide type-safe autocomplete for metadata on content parts. + +#### Implementation of + +[`AIAdapter`](../interfaces/AIAdapter.md).[`_messageMetadataByModality`](../interfaces/AIAdapter.md#_messagemetadatabymodality) + +*** + +### \_modelInputModalitiesByName? + +```ts +optional _modelInputModalitiesByName: TModelInputModalitiesByName; +``` + +Defined in: [base-adapter.ts:66](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/base-adapter.ts#L66) + +Type-only map from model name to its supported input modalities. +Used by the core AI types to narrow ContentPart types based on the selected model. +Must be provided by all adapters. + +#### Implementation of + +[`AIAdapter`](../interfaces/AIAdapter.md).[`_modelInputModalitiesByName`](../interfaces/AIAdapter.md#_modelinputmodalitiesbyname) + +*** + +### \_modelProviderOptionsByName + +```ts +_modelProviderOptionsByName: TModelProviderOptionsByName; +``` + +Defined in: [base-adapter.ts:64](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/base-adapter.ts#L64) + +Type-only map from model name to its specific provider options. +Used by the core AI types to narrow providerOptions based on the selected model. +Must be provided by all adapters. + +#### Implementation of + +[`AIAdapter`](../interfaces/AIAdapter.md).[`_modelProviderOptionsByName`](../interfaces/AIAdapter.md#_modelprovideroptionsbyname) + +*** + +### \_providerOptions? + +```ts +optional _providerOptions: TChatProviderOptions; +``` + +Defined in: [base-adapter.ts:60](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/base-adapter.ts#L60) + +#### Implementation of + +[`AIAdapter`](../interfaces/AIAdapter.md).[`_providerOptions`](../interfaces/AIAdapter.md#_provideroptions) + +*** + +### config + +```ts +protected config: AIAdapterConfig; +``` + +Defined in: [base-adapter.ts:57](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/base-adapter.ts#L57) + +*** + +### embeddingModels? + +```ts +optional embeddingModels: TEmbeddingModels; +``` + +Defined in: [base-adapter.ts:56](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/base-adapter.ts#L56) + +Models that support embeddings + +#### Implementation of + +[`AIAdapter`](../interfaces/AIAdapter.md).[`embeddingModels`](../interfaces/AIAdapter.md#embeddingmodels) + +*** + +### models + +```ts +abstract models: TChatModels; +``` + +Defined in: [base-adapter.ts:55](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/base-adapter.ts#L55) + +Models that support chat/text completion + +#### Implementation of + +[`AIAdapter`](../interfaces/AIAdapter.md).[`models`](../interfaces/AIAdapter.md#models) + +*** + +### name + +```ts +abstract name: string; +``` + +Defined in: [base-adapter.ts:54](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/base-adapter.ts#L54) + +#### Implementation of + +[`AIAdapter`](../interfaces/AIAdapter.md).[`name`](../interfaces/AIAdapter.md#name) + +## Methods + +### chatStream() + +```ts +abstract chatStream(options): AsyncIterable; +``` + +Defined in: [base-adapter.ts:74](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/base-adapter.ts#L74) + +#### Parameters + +##### options + +[`TextOptions`](../interfaces/TextOptions.md) + +#### Returns + +`AsyncIterable`\<[`StreamChunk`](../type-aliases/StreamChunk.md)\> + +#### Implementation of + +[`AIAdapter`](../interfaces/AIAdapter.md).[`chatStream`](../interfaces/AIAdapter.md#chatstream) + +*** + +### createEmbeddings() + +```ts +abstract createEmbeddings(options): Promise; +``` + +Defined in: [base-adapter.ts:79](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/base-adapter.ts#L79) + +#### Parameters + +##### options + +`EmbeddingOptions` + +#### Returns + +`Promise`\<`EmbeddingResult`\> + +#### Implementation of + +[`AIAdapter`](../interfaces/AIAdapter.md).[`createEmbeddings`](../interfaces/AIAdapter.md#createembeddings) + +*** + +### generateId() + +```ts +protected generateId(): string; +``` + +Defined in: [base-adapter.ts:81](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/base-adapter.ts#L81) + +#### Returns + +`string` + +*** + +### summarize() + +```ts +abstract summarize(options): Promise; +``` + +Defined in: [base-adapter.ts:76](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/base-adapter.ts#L76) + +#### Parameters + +##### options + +[`SummarizationOptions`](../interfaces/SummarizationOptions.md) + +#### Returns + +`Promise`\<[`SummarizationResult`](../interfaces/SummarizationResult.md)\> + +#### Implementation of + +[`AIAdapter`](../interfaces/AIAdapter.md).[`summarize`](../interfaces/AIAdapter.md#summarize) diff --git a/docs/reference/functions/messages.md b/docs/reference/functions/messages.md new file mode 100644 index 00000000..c135cd0c --- /dev/null +++ b/docs/reference/functions/messages.md @@ -0,0 +1,94 @@ +--- +id: messages +title: messages +--- + +# Function: messages() + +```ts +function messages(_options, msgs): TAdapter extends AIAdapter ? TModel extends keyof ModelInputModalities ? ModelInputModalities[TModel] extends readonly Modality[] ? ConstrainedModelMessage[] : ModelMessage< + | string + | ContentPart[] + | null>[] : ModelMessage< + | string + | ContentPart[] + | null>[] : ModelMessage< + | string + | ContentPart[] + | null>[]; +``` + +Defined in: [utilities/messages.ts:33](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/utilities/messages.ts#L33) + +Type-safe helper to create a messages array constrained by a model's supported modalities. + +This function provides compile-time checking that your messages only contain +content types supported by the specified model. It's particularly useful when +combining typed messages with untyped data (like from request.json()). + +## Type Parameters + +### TAdapter + +`TAdapter` *extends* [`AIAdapter`](../interfaces/AIAdapter.md)\<`any`, `any`, `any`, `any`, `any`, `any`, [`DefaultMessageMetadataByModality`](../interfaces/DefaultMessageMetadataByModality.md)\> + +### TModel + +`TModel` *extends* `any` + +## Parameters + +### \_options + +#### adapter + +`TAdapter` + +#### model + +`TModel` + +### msgs + +`TAdapter` *extends* [`AIAdapter`](../interfaces/AIAdapter.md)\<`any`, `any`, `any`, `any`, `any`, `ModelInputModalities`, [`DefaultMessageMetadataByModality`](../interfaces/DefaultMessageMetadataByModality.md)\> ? `TModel` *extends* keyof `ModelInputModalities` ? `ModelInputModalities`\[`TModel`\<`TModel`\>\] *extends* readonly [`Modality`](../type-aliases/Modality.md)[] ? [`ConstrainedModelMessage`](../type-aliases/ConstrainedModelMessage.md)\<`any`\[`any`\]\>[] : [`ModelMessage`](../interfaces/ModelMessage.md)\< + \| `string` + \| [`ContentPart`](../type-aliases/ContentPart.md)\<`unknown`, `unknown`, `unknown`, `unknown`, `unknown`\>[] + \| `null`\>[] : [`ModelMessage`](../interfaces/ModelMessage.md)\< + \| `string` + \| [`ContentPart`](../type-aliases/ContentPart.md)\<`unknown`, `unknown`, `unknown`, `unknown`, `unknown`\>[] + \| `null`\>[] : [`ModelMessage`](../interfaces/ModelMessage.md)\< + \| `string` + \| [`ContentPart`](../type-aliases/ContentPart.md)\<`unknown`, `unknown`, `unknown`, `unknown`, `unknown`\>[] + \| `null`\>[] + +## Returns + +`TAdapter` *extends* [`AIAdapter`](../interfaces/AIAdapter.md)\<`any`, `any`, `any`, `any`, `any`, `ModelInputModalities`, [`DefaultMessageMetadataByModality`](../interfaces/DefaultMessageMetadataByModality.md)\> ? `TModel` *extends* keyof `ModelInputModalities` ? `ModelInputModalities`\[`TModel`\<`TModel`\>\] *extends* readonly [`Modality`](../type-aliases/Modality.md)[] ? [`ConstrainedModelMessage`](../type-aliases/ConstrainedModelMessage.md)\<`any`\[`any`\]\>[] : [`ModelMessage`](../interfaces/ModelMessage.md)\< + \| `string` + \| [`ContentPart`](../type-aliases/ContentPart.md)\<`unknown`, `unknown`, `unknown`, `unknown`, `unknown`\>[] + \| `null`\>[] : [`ModelMessage`](../interfaces/ModelMessage.md)\< + \| `string` + \| [`ContentPart`](../type-aliases/ContentPart.md)\<`unknown`, `unknown`, `unknown`, `unknown`, `unknown`\>[] + \| `null`\>[] : [`ModelMessage`](../interfaces/ModelMessage.md)\< + \| `string` + \| [`ContentPart`](../type-aliases/ContentPart.md)\<`unknown`, `unknown`, `unknown`, `unknown`, `unknown`\>[] + \| `null`\>[] + +## Example + +```typescript +import { messages, chat } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' + +const adapter = openaiText() + +// This will error at compile time because gpt-4o only supports text+image +const msgs = messages({ adapter, model: 'gpt-4o' }, [ + { + role: 'user', + content: [ + { type: 'video', source: { type: 'url', value: '...' } } // Error! + ] + } +]) +``` diff --git a/docs/reference/functions/chat.md b/docs/reference/functions/text.md similarity index 100% rename from docs/reference/functions/chat.md rename to docs/reference/functions/text.md diff --git a/docs/reference/functions/textOptions.md b/docs/reference/functions/textOptions.md new file mode 100644 index 00000000..a9c7d386 --- /dev/null +++ b/docs/reference/functions/textOptions.md @@ -0,0 +1,32 @@ +--- +id: textOptions +title: textOptions +--- + +# Function: textOptions() + +```ts +function textOptions(options): Omit, "model" | "providerOptions" | "messages" | "abortController"> & object; +``` + +Defined in: [utilities/chat-options.ts:3](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/utilities/chat-options.ts#L3) + +## Type Parameters + +### TAdapter + +`TAdapter` *extends* [`AIAdapter`](../interfaces/AIAdapter.md)\<`any`, `any`, `any`, `any`, `any`, `Record`\<`string`, readonly [`Modality`](../type-aliases/Modality.md)[]\>, [`DefaultMessageMetadataByModality`](../interfaces/DefaultMessageMetadataByModality.md)\> + +### TModel + +`TModel` *extends* `any` + +## Parameters + +### options + +`Omit`\<[`TextStreamOptionsUnion`](../type-aliases/TextStreamOptionsUnion.md)\<`TAdapter`\>, `"model"` \| `"providerOptions"` \| `"messages"` \| `"abortController"`\> & `object` + +## Returns + +`Omit`\<[`TextStreamOptionsUnion`](../type-aliases/TextStreamOptionsUnion.md)\<`TAdapter`\>, `"model"` \| `"providerOptions"` \| `"messages"` \| `"abortController"`\> & `object` diff --git a/docs/reference/index.md b/docs/reference/index.md index 106f3eaa..04e02bcc 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -119,7 +119,6 @@ title: "@tanstack/ai" ## Functions -- [chat](functions/chat.md) - [combineStrategies](functions/combineStrategies.md) - [convertMessagesToModelMessages](functions/convertMessagesToModelMessages.md) - [convertSchemaToJsonSchema](functions/convertSchemaToJsonSchema.md) diff --git a/docs/reference/interfaces/AIAdapter.md b/docs/reference/interfaces/AIAdapter.md new file mode 100644 index 00000000..7c77091f --- /dev/null +++ b/docs/reference/interfaces/AIAdapter.md @@ -0,0 +1,214 @@ +--- +id: AIAdapter +title: AIAdapter +--- + +# Interface: AIAdapter\ + +Defined in: [types.ts:756](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/types.ts#L756) + +AI adapter interface with support for endpoint-specific models and provider options. + +Generic parameters: +- TChatModels: Models that support chat/text completion +- TEmbeddingModels: Models that support embeddings +- TChatProviderOptions: Provider-specific options for chat endpoint +- TEmbeddingProviderOptions: Provider-specific options for embedding endpoint +- TModelProviderOptionsByName: Map from model name to its specific provider options +- TModelInputModalitiesByName: Map from model name to its supported input modalities +- TMessageMetadataByModality: Map from modality type to adapter-specific metadata types + +## Type Parameters + +### TChatModels + +`TChatModels` *extends* `ReadonlyArray`\<`string`\> = `ReadonlyArray`\<`string`\> + +### TEmbeddingModels + +`TEmbeddingModels` *extends* `ReadonlyArray`\<`string`\> = `ReadonlyArray`\<`string`\> + +### TChatProviderOptions + +`TChatProviderOptions` *extends* `Record`\<`string`, `any`\> = `Record`\<`string`, `any`\> + +### TEmbeddingProviderOptions + +`TEmbeddingProviderOptions` *extends* `Record`\<`string`, `any`\> = `Record`\<`string`, `any`\> + +### TModelProviderOptionsByName + +`TModelProviderOptionsByName` *extends* `Record`\<`string`, `any`\> = `Record`\<`string`, `any`\> + +### TModelInputModalitiesByName + +`TModelInputModalitiesByName` *extends* `Record`\<`string`, `ReadonlyArray`\<[`Modality`](../type-aliases/Modality.md)\>\> = `Record`\<`string`, `ReadonlyArray`\<[`Modality`](../type-aliases/Modality.md)\>\> + +### TMessageMetadataByModality + +`TMessageMetadataByModality` *extends* `object` = [`DefaultMessageMetadataByModality`](DefaultMessageMetadataByModality.md) + +## Properties + +### \_chatProviderOptions? + +```ts +optional _chatProviderOptions: TChatProviderOptions; +``` + +Defined in: [types.ts:783](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/types.ts#L783) + +*** + +### \_embeddingProviderOptions? + +```ts +optional _embeddingProviderOptions: TEmbeddingProviderOptions; +``` + +Defined in: [types.ts:784](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/types.ts#L784) + +*** + +### \_messageMetadataByModality? + +```ts +optional _messageMetadataByModality: TMessageMetadataByModality; +``` + +Defined in: [types.ts:801](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/types.ts#L801) + +Type-only map from modality type to adapter-specific metadata types. +Used to provide type-safe autocomplete for metadata on content parts. + +*** + +### \_modelInputModalitiesByName? + +```ts +optional _modelInputModalitiesByName: TModelInputModalitiesByName; +``` + +Defined in: [types.ts:796](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/types.ts#L796) + +Type-only map from model name to its supported input modalities. +Used by the core AI types to narrow ContentPart types based on the selected model. +Must be provided by all adapters. + +*** + +### \_modelProviderOptionsByName + +```ts +_modelProviderOptionsByName: TModelProviderOptionsByName; +``` + +Defined in: [types.ts:790](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/types.ts#L790) + +Type-only map from model name to its specific provider options. +Used by the core AI types to narrow providerOptions based on the selected model. +Must be provided by all adapters. + +*** + +### \_providerOptions? + +```ts +optional _providerOptions: TChatProviderOptions; +``` + +Defined in: [types.ts:782](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/types.ts#L782) + +*** + +### chatStream() + +```ts +chatStream: (options) => AsyncIterable; +``` + +Defined in: [types.ts:804](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/types.ts#L804) + +#### Parameters + +##### options + +[`TextOptions`](TextOptions.md)\<`string`, `TChatProviderOptions`\> + +#### Returns + +`AsyncIterable`\<[`StreamChunk`](../type-aliases/StreamChunk.md)\> + +*** + +### createEmbeddings() + +```ts +createEmbeddings: (options) => Promise; +``` + +Defined in: [types.ts:812](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/types.ts#L812) + +#### Parameters + +##### options + +`EmbeddingOptions` + +#### Returns + +`Promise`\<`EmbeddingResult`\> + +*** + +### embeddingModels? + +```ts +optional embeddingModels: TEmbeddingModels; +``` + +Defined in: [types.ts:779](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/types.ts#L779) + +Models that support embeddings + +*** + +### models + +```ts +models: TChatModels; +``` + +Defined in: [types.ts:776](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/types.ts#L776) + +Models that support chat/text completion + +*** + +### name + +```ts +name: string; +``` + +Defined in: [types.ts:774](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/types.ts#L774) + +*** + +### summarize() + +```ts +summarize: (options) => Promise; +``` + +Defined in: [types.ts:809](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/types.ts#L809) + +#### Parameters + +##### options + +[`SummarizationOptions`](SummarizationOptions.md) + +#### Returns + +`Promise`\<[`SummarizationResult`](SummarizationResult.md)\> diff --git a/docs/reference/type-aliases/TextStreamOptionsForModel.md b/docs/reference/type-aliases/TextStreamOptionsForModel.md new file mode 100644 index 00000000..f0c05360 --- /dev/null +++ b/docs/reference/type-aliases/TextStreamOptionsForModel.md @@ -0,0 +1,26 @@ +--- +id: TextStreamOptionsForModel +title: TextStreamOptionsForModel +--- + +# Type Alias: TextStreamOptionsForModel\ + +```ts +type TextStreamOptionsForModel = TAdapter extends AIAdapter ? Omit & object : never; +``` + +Defined in: [types.ts:883](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/types.ts#L883) + +Chat options constrained by a specific model's capabilities. +Unlike TextStreamOptionsUnion which creates a union over all models, +this type takes a specific model and constrains messages accordingly. + +## Type Parameters + +### TAdapter + +`TAdapter` *extends* [`AIAdapter`](../interfaces/AIAdapter.md)\<`any`, `any`, `any`, `any`, `any`, `any`, `any`\> + +### TModel + +`TModel` *extends* `string` diff --git a/docs/reference/type-aliases/TextStreamOptionsUnion.md b/docs/reference/type-aliases/TextStreamOptionsUnion.md new file mode 100644 index 00000000..5db11467 --- /dev/null +++ b/docs/reference/type-aliases/TextStreamOptionsUnion.md @@ -0,0 +1,18 @@ +--- +id: TextStreamOptionsUnion +title: TextStreamOptionsUnion +--- + +# Type Alias: TextStreamOptionsUnion\ + +```ts +type TextStreamOptionsUnion = TAdapter extends AIAdapter ? Models[number] extends infer TModel ? TModel extends string ? Omit & object : never : never : never; +``` + +Defined in: [types.ts:823](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/types.ts#L823) + +## Type Parameters + +### TAdapter + +`TAdapter` *extends* [`AIAdapter`](../interfaces/AIAdapter.md)\<`any`, `any`, `any`, `any`, `any`, `any`, `any`\> diff --git a/packages/typescript/ai/src/activities/index.ts b/packages/typescript/ai/src/activities/index.ts index 521675a7..5ccea3cf 100644 --- a/packages/typescript/ai/src/activities/index.ts +++ b/packages/typescript/ai/src/activities/index.ts @@ -32,6 +32,17 @@ export { type TextActivityResult, } from './chat/index' +// =========================== +// Text Activity (Experimental) +// =========================== + +export { + text, + createTextOptions, + type TextOptions_ as TextOptions, + type TextResult, +} from './text/index' + export { BaseTextAdapter, type AnyTextAdapter, diff --git a/packages/typescript/ai/src/activities/text/index.ts b/packages/typescript/ai/src/activities/text/index.ts new file mode 100644 index 00000000..3b90e8fd --- /dev/null +++ b/packages/typescript/ai/src/activities/text/index.ts @@ -0,0 +1,477 @@ +/** + * Text Activity + * + * Simple one-shot text generation without agent loop support. + * This is a standalone implementation that directly calls the adapter. + * For agentic workflows with tools and multi-turn execution, use agentLoop() instead. + */ + +import { aiEventClient } from '../../event-client.js' +import { streamToText } from '../../stream-to-response.js' +import { + convertSchemaToJsonSchema, + isStandardSchema, + parseWithStandardSchema, +} from '../chat/tools/schema-converter' +import type { AnyTextAdapter } from '../chat/adapter' +import type { + ConstrainedModelMessage, + InferSchemaType, + SchemaInput, + StreamChunk, + TextOptions, +} from '../../types' + +// =========================== +// Text Options Type +// =========================== + +/** + * Options for the text function. + * A simplified version of chat options without tools or agent loop strategy. + * + * @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) + */ +export interface TextOptions_< + TAdapter extends AnyTextAdapter, + TSchema extends SchemaInput | undefined = undefined, + TStream extends boolean = true, +> { + /** The text adapter to use (created by a provider function like openaiText('gpt-4o')) */ + adapter: TAdapter + /** Conversation messages - content types are constrained by the adapter's input modalities */ + messages?: Array< + ConstrainedModelMessage<{ + inputModalities: TAdapter['~types']['inputModalities'] + messageMetadataByModality: TAdapter['~types']['messageMetadataByModality'] + }> + > + /** System prompts to prepend to the conversation */ + systemPrompts?: TextOptions['systemPrompts'] + /** 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. */ + topP?: TextOptions['topP'] + /** The maximum number of tokens to generate in the response. */ + maxTokens?: TextOptions['maxTokens'] + /** Additional metadata to attach to the request. */ + metadata?: TextOptions['metadata'] + /** Model-specific provider options (type comes from adapter) */ + modelOptions?: TAdapter['~types']['providerOptions'] + /** AbortController for cancellation */ + abortController?: TextOptions['abortController'] + /** Unique conversation identifier for tracking */ + conversationId?: TextOptions['conversationId'] + /** + * Optional Standard Schema for structured output. + * When provided, returns a Promise with the parsed output matching the schema. + * + * @example + * ```ts + * const result = await text({ + * adapter: openaiText('gpt-4o'), + * messages: [{ role: 'user', content: 'Generate a person' }], + * outputSchema: z.object({ name: z.string(), age: z.number() }) + * }) + * // result is { name: string, age: number } + * ``` + */ + outputSchema?: TSchema + /** + * Whether to stream the text result. + * 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 + * is always a Promise>. + * + * @default true + */ + stream?: TStream +} + +// =========================== +// Text Result Type +// =========================== + +/** + * Result type for the text function. + * - If outputSchema is provided: Promise> + * - If stream is false: Promise + * - Otherwise (stream is true, default): AsyncIterable + */ +export type TextResult< + TSchema extends SchemaInput | undefined, + TStream extends boolean = true, +> = TSchema extends SchemaInput + ? Promise> + : TStream extends false + ? Promise + : AsyncIterable + +// =========================== +// Create Options Helper +// =========================== + +/** + * Create typed options for the text() function without executing. + * This is useful for pre-defining configurations with full type inference. + * + * @example + * ```ts + * const textOptions = createTextOptions({ + * adapter: openaiText('gpt-4o'), + * temperature: 0.7, + * }) + * + * const stream = text({ ...textOptions, messages }) + * ``` + */ +export function createTextOptions< + TAdapter extends AnyTextAdapter, + TSchema extends SchemaInput | undefined = undefined, + TStream extends boolean = true, +>( + options: TextOptions_, +): TextOptions_ { + return options +} + +// =========================== +// Helper Functions +// =========================== + +function createId(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}` +} + +// =========================== +// Text Function +// =========================== + +/** + * Simple one-shot text generation without agent loop support. + * + * Use this for straightforward text generation, chat completions, and structured output. + * For agentic workflows that require tool execution and multi-turn loops, use `agentLoop()` instead. + * + * The return type depends on the options: + * - Default (streaming): `AsyncIterable` + * - With `stream: false`: `Promise` + * - With `outputSchema`: `Promise>` + * + * @example Streaming text generation + * ```ts + * import { text } from '@tanstack/ai' + * import { openaiText } from '@tanstack/ai-openai' + * + * for await (const chunk of text({ + * adapter: openaiText('gpt-4o'), + * messages: [{ role: 'user', content: 'Tell me a story' }], + * })) { + * if (chunk.type === 'content') { + * process.stdout.write(chunk.delta) + * } + * } + * ``` + * + * @example Non-streaming text + * ```ts + * const response = await text({ + * adapter: openaiText('gpt-4o'), + * messages: [{ role: 'user', content: 'What is 2+2?' }], + * stream: false, + * }) + * console.log(response) // "4" + * ``` + * + * @example Structured output + * ```ts + * import { z } from 'zod' + * + * const person = await text({ + * adapter: openaiText('gpt-4o'), + * messages: [{ role: 'user', content: 'Generate a fictional person' }], + * outputSchema: z.object({ + * name: z.string(), + * age: z.number(), + * occupation: z.string(), + * }), + * }) + * // person is { name: string, age: number, occupation: string } + * ``` + * + * @example With model options + * ```ts + * const creative = await text({ + * adapter: openaiText('gpt-4o'), + * messages: [{ role: 'user', content: 'Write a poem' }], + * temperature: 0.9, + * maxTokens: 500, + * stream: false, + * }) + * ``` + */ +export function text< + TAdapter extends AnyTextAdapter, + TSchema extends SchemaInput | undefined = undefined, + TStream extends boolean = true, +>( + options: TextOptions_, +): TextResult { + const { outputSchema, stream } = options + + // If outputSchema is provided, run structured output + if (outputSchema) { + return runStructuredOutput( + options as unknown as TextOptions_, + ) as TextResult + } + + // If stream is explicitly false, run non-streaming text + if (stream === false) { + return runNonStreamingText( + options as unknown as TextOptions_, + ) as TextResult + } + + // Otherwise, run streaming text (default) + return runStreamingText( + options as unknown as TextOptions_, + ) as TextResult +} + +/** + * Run streaming text - directly calls the adapter's chatStream method. + * This is a simple one-shot request with no agent loop. + */ +async function* runStreamingText( + options: TextOptions_, +): AsyncIterable { + const { + adapter, + messages = [], + systemPrompts, + temperature, + topP, + maxTokens, + metadata, + modelOptions, + abortController, + conversationId, + } = options + + const model = adapter.model + const requestId = createId('text') + const streamId = createId('stream') + const messageId = createId('msg') + const streamStartTime = Date.now() + let totalChunkCount = 0 + let accumulatedContent = '' + let lastFinishReason: string | null | undefined + let lastUsage: + | { promptTokens: number; completionTokens: number; totalTokens: number } + | undefined + + const effectiveRequest = abortController + ? { signal: abortController.signal } + : undefined + + // Emit start events + const startOptions: Record = {} + if (temperature !== undefined) startOptions.temperature = temperature + if (topP !== undefined) startOptions.topP = topP + if (maxTokens !== undefined) startOptions.maxTokens = maxTokens + if (metadata !== undefined) startOptions.metadata = metadata + + aiEventClient.emit('text:started', { + requestId, + streamId, + model, + provider: adapter.name, + messageCount: messages.length, + hasTools: false, + streaming: true, + timestamp: Date.now(), + clientId: conversationId, + toolNames: undefined, + options: Object.keys(startOptions).length > 0 ? startOptions : undefined, + modelOptions: modelOptions as Record | undefined, + }) + + aiEventClient.emit('stream:started', { + streamId, + model, + provider: adapter.name, + timestamp: Date.now(), + }) + + try { + for await (const chunk of adapter.chatStream({ + model, + messages, + tools: undefined, + temperature, + topP, + maxTokens, + metadata, + request: effectiveRequest, + modelOptions, + systemPrompts, + })) { + if (abortController?.signal.aborted) { + break + } + + totalChunkCount++ + yield chunk + + // Track content and emit events + switch (chunk.type) { + case 'content': + accumulatedContent = chunk.content + aiEventClient.emit('stream:chunk:content', { + streamId, + messageId, + content: chunk.content, + delta: chunk.delta, + timestamp: Date.now(), + }) + break + case 'done': + lastFinishReason = chunk.finishReason + lastUsage = chunk.usage + aiEventClient.emit('stream:chunk:done', { + streamId, + messageId, + finishReason: chunk.finishReason, + usage: chunk.usage, + timestamp: Date.now(), + }) + if (chunk.usage) { + aiEventClient.emit('usage:tokens', { + requestId, + streamId, + messageId, + model, + usage: chunk.usage, + timestamp: Date.now(), + }) + } + break + case 'error': + aiEventClient.emit('stream:chunk:error', { + streamId, + messageId, + error: chunk.error.message, + timestamp: Date.now(), + }) + break + case 'thinking': + aiEventClient.emit('stream:chunk:thinking', { + streamId, + messageId, + content: chunk.content, + delta: chunk.delta, + timestamp: Date.now(), + }) + break + } + } + } finally { + const now = Date.now() + + aiEventClient.emit('text:completed', { + requestId, + streamId, + model, + content: accumulatedContent, + messageId, + finishReason: lastFinishReason ?? undefined, + usage: lastUsage, + timestamp: now, + }) + + aiEventClient.emit('stream:ended', { + requestId, + streamId, + totalChunks: totalChunkCount, + duration: now - streamStartTime, + timestamp: now, + }) + } +} + +/** + * Run non-streaming text - collects all content and returns as a string. + */ +function runNonStreamingText( + options: TextOptions_, +): Promise { + const stream = runStreamingText( + options as unknown as TextOptions_, + ) + return streamToText(stream) +} + +/** + * Run structured output - calls the adapter's structuredOutput method. + */ +async function runStructuredOutput< + TAdapter extends AnyTextAdapter, + TSchema extends SchemaInput, +>( + options: TextOptions_, +): Promise> { + const { + adapter, + messages = [], + systemPrompts, + temperature, + topP, + maxTokens, + metadata, + modelOptions, + outputSchema, + } = options + + if (!outputSchema) { + throw new Error('outputSchema is required for structured output') + } + + const model = adapter.model + + // Convert the schema to JSON Schema before passing to the adapter + const jsonSchema = convertSchemaToJsonSchema(outputSchema) + if (!jsonSchema) { + throw new Error('Failed to convert output schema to JSON Schema') + } + + // Call the adapter's structured output method + const result = await adapter.structuredOutput({ + chatOptions: { + model, + messages, + systemPrompts, + temperature, + topP, + maxTokens, + metadata, + modelOptions, + }, + outputSchema: jsonSchema, + }) + + // Validate the result against the schema if it's a Standard Schema + if (isStandardSchema(outputSchema)) { + return parseWithStandardSchema>( + outputSchema, + result.data, + ) + } + + // For plain JSON Schema, return the data as-is + return result.data as InferSchemaType +} diff --git a/packages/typescript/ai/src/agent/index.ts b/packages/typescript/ai/src/agent/index.ts new file mode 100644 index 00000000..3ad84462 --- /dev/null +++ b/packages/typescript/ai/src/agent/index.ts @@ -0,0 +1,1100 @@ +/** + * Agent Loop (Experimental) + * + * Orchestrates agentic text generation by wrapping a text creator function + * and handling automatic tool execution and looping. + */ + +import { aiEventClient } from '../event-client.js' +import { + ToolCallManager, + executeToolCalls, +} from '../activities/chat/tools/tool-calls' +import { maxIterations as maxIterationsStrategy } from '../activities/chat/agent-loop-strategies' +import { chat } from '../activities/chat/index' +import type { + ApprovalRequest, + ClientToolRequest, + ToolResult, +} from '../activities/chat/tools/tool-calls' +import type { AnyTextAdapter } from '../activities/chat/adapter' +import type { z } from 'zod' +import type { + AgentLoopStrategy, + ConstrainedModelMessage, + DoneStreamChunk, + ModelMessage, + StreamChunk, + Tool, + ToolCall, +} from '../types' + +// =========================== +// Types +// =========================== + +/** + * Options passed to the text creator function. + * The creator function should spread these into its text() call. + */ +export interface TextCreatorOptions { + /** Conversation messages (updated each iteration with tool results) */ + messages: Array + /** Tools for function calling */ + tools?: ReadonlyArray + /** System prompts */ + systemPrompts?: Array + /** AbortController for cancellation */ + abortController?: AbortController + /** Zod schema for structured output (when provided, returns Promise instead of stream) */ + outputSchema?: z.ZodType +} + +/** + * A function that creates a text stream or structured output. + * This is typically a partial application of the text() function with adapter and model pre-configured. + * + * @example + * ```ts + * const textFn: TextCreator = (opts) => text({ + * adapter: openaiText(), + * model: 'gpt-4o', + * ...opts + * }) + * ``` + */ +export type TextCreator = ( + options: TextCreatorOptions & { outputSchema?: TSchema }, +) => TSchema extends z.ZodType + ? Promise> + : AsyncIterable + +/** + * Base options for the agent loop. + */ +export interface AgentLoopBaseOptions { + /** Conversation messages */ + messages: Array + /** System prompts to prepend to the conversation */ + systemPrompts?: Array + /** Tools for function calling (auto-executed when called) */ + tools?: ReadonlyArray + /** AbortController for cancellation */ + abortController?: AbortController + /** Strategy for controlling the agent loop */ + agentLoopStrategy?: AgentLoopStrategy + /** Unique conversation identifier for tracking */ + conversationId?: string +} + +/** + * Options for streaming agent loop (no structured output). + */ +export interface AgentLoopStreamOptions extends AgentLoopBaseOptions { + outputSchema?: undefined +} + +/** + * Options for structured output agent loop. + */ +export interface AgentLoopStructuredOptions< + TSchema extends z.ZodType, +> extends AgentLoopBaseOptions { + /** Zod schema for structured output - determines return type */ + outputSchema: TSchema +} + +/** + * Combined options type for the agent loop. + */ +export type AgentLoopOptions< + TSchema extends z.ZodType | undefined = undefined, +> = TSchema extends z.ZodType + ? AgentLoopStructuredOptions + : AgentLoopStreamOptions + +// =========================== +// Direct Options Types (adapter-based API) +// =========================== + +/** + * Direct chat options for agent loop (adapter-based API). + * Provides full chat() parity with adapter-aware typing. + * + * @template TAdapter - The text adapter type (created by a provider function) + * @template TSchema - Optional schema for structured output + */ +export interface AgentLoopDirectOptions< + TAdapter extends AnyTextAdapter, + TSchema extends z.ZodType | undefined = undefined, +> { + /** The text adapter to use (created by a provider function like openaiText('gpt-4o')) */ + adapter: TAdapter + /** Conversation messages - content types are constrained by the adapter's input modalities */ + messages: Array< + ConstrainedModelMessage<{ + inputModalities: TAdapter['~types']['inputModalities'] + messageMetadataByModality: TAdapter['~types']['messageMetadataByModality'] + }> + > + /** System prompts to prepend to the conversation */ + systemPrompts?: Array + /** Tools for function calling (auto-executed when called) */ + tools?: ReadonlyArray + /** Controls the randomness of the output. Range: [0.0, 2.0] */ + temperature?: number + /** Nucleus sampling parameter. */ + topP?: number + /** The maximum number of tokens to generate in the response. */ + maxTokens?: number + /** Additional metadata to attach to the request. */ + metadata?: Record + /** Model-specific provider options (type comes from adapter) */ + modelOptions?: TAdapter['~types']['providerOptions'] + /** AbortController for cancellation */ + abortController?: AbortController + /** Strategy for controlling the agent loop */ + agentLoopStrategy?: AgentLoopStrategy + /** Unique conversation identifier for tracking */ + conversationId?: string + /** Zod schema for structured output - determines return type */ + outputSchema?: TSchema +} + +/** + * Streaming options for direct agent loop (no outputSchema). + */ +export interface AgentLoopDirectStreamOptions< + TAdapter extends AnyTextAdapter, +> extends AgentLoopDirectOptions { + outputSchema?: undefined +} + +/** + * Structured output options for direct agent loop. + */ +export interface AgentLoopDirectStructuredOptions< + TAdapter extends AnyTextAdapter, + TSchema extends z.ZodType, +> extends AgentLoopDirectOptions { + outputSchema: TSchema +} + +// =========================== +// Agent Loop Engine +// =========================== + +interface AgentLoopEngineConfig { + textFn: TextCreator + options: AgentLoopBaseOptions +} + +type ToolPhaseResult = 'continue' | 'stop' | 'wait' +type CyclePhase = 'processText' | 'executeToolCalls' + +class AgentLoopEngine { + private readonly textFn: TextCreator + private readonly options: AgentLoopBaseOptions + private readonly tools: ReadonlyArray + private readonly loopStrategy: AgentLoopStrategy + private readonly toolCallManager: ToolCallManager + private readonly initialMessageCount: number + private readonly requestId: string + private readonly streamId: string + private readonly effectiveSignal?: AbortSignal + + private messages: Array + private iterationCount = 0 + private lastFinishReason: string | null = null + private streamStartTime = 0 + private totalChunkCount = 0 + private currentMessageId: string | null = null + private accumulatedContent = '' + private doneChunk: DoneStreamChunk | null = null + private shouldEmitStreamEnd = true + private earlyTermination = false + private toolPhase: ToolPhaseResult = 'continue' + private cyclePhase: CyclePhase = 'processText' + + constructor(config: AgentLoopEngineConfig) { + this.textFn = config.textFn + this.options = config.options + this.tools = config.options.tools || [] + this.loopStrategy = + config.options.agentLoopStrategy || maxIterationsStrategy(5) + this.toolCallManager = new ToolCallManager(this.tools) + this.initialMessageCount = config.options.messages.length + this.messages = [...config.options.messages] + this.requestId = this.createId('agent') + this.streamId = this.createId('stream') + this.effectiveSignal = config.options.abortController?.signal + } + + /** Get the accumulated content after the loop completes */ + getAccumulatedContent(): string { + return this.accumulatedContent + } + + /** Get the final messages array after the loop completes */ + getMessages(): Array { + return this.messages + } + + async *run(): AsyncGenerator { + this.beforeRun() + + try { + const pendingPhase = yield* this.checkForPendingToolCalls() + if (pendingPhase === 'wait') { + return + } + + do { + if (this.earlyTermination || this.isAborted()) { + return + } + + this.beginCycle() + + if (this.cyclePhase === 'processText') { + yield* this.streamTextResponse() + } else { + yield* this.processToolCalls() + } + + this.endCycle() + } while (this.shouldContinue()) + } finally { + this.afterRun() + } + } + + private beforeRun(): void { + this.streamStartTime = Date.now() + + aiEventClient.emit('text:started', { + requestId: this.requestId, + streamId: this.streamId, + model: 'agent-loop', + provider: 'agent', + messageCount: this.initialMessageCount, + hasTools: this.tools.length > 0, + streaming: true, + timestamp: Date.now(), + clientId: this.options.conversationId, + toolNames: this.tools.map((t) => t.name), + }) + + aiEventClient.emit('stream:started', { + streamId: this.streamId, + model: 'agent-loop', + provider: 'agent', + timestamp: Date.now(), + }) + } + + private afterRun(): void { + if (!this.shouldEmitStreamEnd) { + return + } + + const now = Date.now() + + aiEventClient.emit('text:completed', { + requestId: this.requestId, + streamId: this.streamId, + model: 'agent-loop', + content: this.accumulatedContent, + messageId: this.currentMessageId || undefined, + finishReason: this.lastFinishReason || undefined, + usage: this.doneChunk?.usage, + timestamp: now, + }) + + aiEventClient.emit('stream:ended', { + requestId: this.requestId, + streamId: this.streamId, + totalChunks: this.totalChunkCount, + duration: now - this.streamStartTime, + timestamp: now, + }) + } + + private beginCycle(): void { + if (this.cyclePhase === 'processText') { + this.beginIteration() + } + } + + private endCycle(): void { + if (this.cyclePhase === 'processText') { + this.cyclePhase = 'executeToolCalls' + return + } + + this.cyclePhase = 'processText' + this.iterationCount++ + } + + private beginIteration(): void { + this.currentMessageId = this.createId('msg') + this.accumulatedContent = '' + this.doneChunk = null + } + + private async *streamTextResponse(): AsyncGenerator { + // Call the user-provided text function with current state (no outputSchema for streaming) + const stream = this.textFn({ + messages: this.messages, + tools: this.tools, + systemPrompts: this.options.systemPrompts, + abortController: this.options.abortController, + }) + + for await (const chunk of stream) { + if (this.isAborted()) { + break + } + + this.totalChunkCount++ + + yield chunk + this.handleStreamChunk(chunk) + + if (this.earlyTermination) { + break + } + } + } + + private handleStreamChunk(chunk: StreamChunk): void { + switch (chunk.type) { + case 'content': + this.handleContentChunk(chunk) + break + case 'tool_call': + this.handleToolCallChunk(chunk) + break + case 'tool_result': + this.handleToolResultChunk(chunk) + break + case 'done': + this.handleDoneChunk(chunk) + break + case 'error': + this.handleErrorChunk(chunk) + break + case 'thinking': + this.handleThinkingChunk(chunk) + break + default: + break + } + } + + private handleContentChunk(chunk: Extract) { + this.accumulatedContent = chunk.content + aiEventClient.emit('stream:chunk:content', { + streamId: this.streamId, + messageId: this.currentMessageId || undefined, + content: chunk.content, + delta: chunk.delta, + timestamp: Date.now(), + }) + } + + private handleToolCallChunk( + chunk: Extract, + ): void { + this.toolCallManager.addToolCallChunk(chunk) + aiEventClient.emit('stream:chunk:tool-call', { + streamId: this.streamId, + messageId: this.currentMessageId || undefined, + toolCallId: chunk.toolCall.id, + toolName: chunk.toolCall.function.name, + index: chunk.index, + arguments: chunk.toolCall.function.arguments, + timestamp: Date.now(), + }) + } + + private handleToolResultChunk( + chunk: Extract, + ): void { + aiEventClient.emit('stream:chunk:tool-result', { + streamId: this.streamId, + messageId: this.currentMessageId || undefined, + toolCallId: chunk.toolCallId, + result: chunk.content, + timestamp: Date.now(), + }) + } + + private handleDoneChunk(chunk: DoneStreamChunk): void { + // Don't overwrite a tool_calls finishReason with a stop finishReason + if ( + this.doneChunk?.finishReason === 'tool_calls' && + chunk.finishReason === 'stop' + ) { + this.lastFinishReason = chunk.finishReason + aiEventClient.emit('stream:chunk:done', { + streamId: this.streamId, + messageId: this.currentMessageId || undefined, + finishReason: chunk.finishReason, + usage: chunk.usage, + timestamp: Date.now(), + }) + + if (chunk.usage) { + aiEventClient.emit('usage:tokens', { + requestId: this.requestId, + streamId: this.streamId, + messageId: this.currentMessageId || undefined, + model: 'agent-loop', + usage: chunk.usage, + timestamp: Date.now(), + }) + } + return + } + + this.doneChunk = chunk + this.lastFinishReason = chunk.finishReason + aiEventClient.emit('stream:chunk:done', { + streamId: this.streamId, + messageId: this.currentMessageId || undefined, + finishReason: chunk.finishReason, + usage: chunk.usage, + timestamp: Date.now(), + }) + + if (chunk.usage) { + aiEventClient.emit('usage:tokens', { + requestId: this.requestId, + streamId: this.streamId, + messageId: this.currentMessageId || undefined, + model: 'agent-loop', + usage: chunk.usage, + timestamp: Date.now(), + }) + } + } + + private handleErrorChunk( + chunk: Extract, + ): void { + aiEventClient.emit('stream:chunk:error', { + streamId: this.streamId, + messageId: this.currentMessageId || undefined, + error: chunk.error.message, + timestamp: Date.now(), + }) + this.earlyTermination = true + this.shouldEmitStreamEnd = false + } + + private handleThinkingChunk( + chunk: Extract, + ): void { + aiEventClient.emit('stream:chunk:thinking', { + streamId: this.streamId, + messageId: this.currentMessageId || undefined, + content: chunk.content, + delta: chunk.delta, + timestamp: Date.now(), + }) + } + + private async *checkForPendingToolCalls(): AsyncGenerator< + StreamChunk, + ToolPhaseResult, + void + > { + const pendingToolCalls = this.getPendingToolCallsFromMessages() + if (pendingToolCalls.length === 0) { + return 'continue' + } + + const doneChunk = this.createSyntheticDoneChunk() + + aiEventClient.emit('text:iteration', { + requestId: this.requestId, + streamId: this.streamId, + iterationNumber: this.iterationCount + 1, + messageCount: this.messages.length, + toolCallCount: pendingToolCalls.length, + timestamp: Date.now(), + }) + + const { approvals, clientToolResults } = this.collectClientState() + + const executionResult = await executeToolCalls( + pendingToolCalls, + this.tools, + approvals, + clientToolResults, + ) + + if ( + executionResult.needsApproval.length > 0 || + executionResult.needsClientExecution.length > 0 + ) { + for (const chunk of this.emitApprovalRequests( + executionResult.needsApproval, + doneChunk, + )) { + yield chunk + } + + for (const chunk of this.emitClientToolInputs( + executionResult.needsClientExecution, + doneChunk, + )) { + yield chunk + } + + this.shouldEmitStreamEnd = false + return 'wait' + } + + const toolResultChunks = this.emitToolResults( + executionResult.results, + doneChunk, + ) + + for (const chunk of toolResultChunks) { + yield chunk + } + + return 'continue' + } + + private async *processToolCalls(): AsyncGenerator { + if (!this.shouldExecuteToolPhase()) { + this.setToolPhase('stop') + return + } + + const toolCalls = this.toolCallManager.getToolCalls() + const doneChunk = this.doneChunk + + if (!doneChunk || toolCalls.length === 0) { + this.setToolPhase('stop') + return + } + + aiEventClient.emit('text:iteration', { + requestId: this.requestId, + streamId: this.streamId, + iterationNumber: this.iterationCount + 1, + messageCount: this.messages.length, + toolCallCount: toolCalls.length, + timestamp: Date.now(), + }) + + this.addAssistantToolCallMessage(toolCalls) + + const { approvals, clientToolResults } = this.collectClientState() + + const executionResult = await executeToolCalls( + toolCalls, + this.tools, + approvals, + clientToolResults, + ) + + if ( + executionResult.needsApproval.length > 0 || + executionResult.needsClientExecution.length > 0 + ) { + for (const chunk of this.emitApprovalRequests( + executionResult.needsApproval, + doneChunk, + )) { + yield chunk + } + + for (const chunk of this.emitClientToolInputs( + executionResult.needsClientExecution, + doneChunk, + )) { + yield chunk + } + + this.setToolPhase('wait') + return + } + + const toolResultChunks = this.emitToolResults( + executionResult.results, + doneChunk, + ) + + for (const chunk of toolResultChunks) { + yield chunk + } + + this.toolCallManager.clear() + + this.setToolPhase('continue') + } + + private shouldExecuteToolPhase(): boolean { + return ( + this.doneChunk?.finishReason === 'tool_calls' && + this.tools.length > 0 && + this.toolCallManager.hasToolCalls() + ) + } + + private addAssistantToolCallMessage(toolCalls: Array): void { + this.messages = [ + ...this.messages, + { + role: 'assistant', + content: this.accumulatedContent || null, + toolCalls, + }, + ] + } + + private collectClientState(): { + approvals: Map + clientToolResults: Map + } { + const approvals = new Map() + const clientToolResults = new Map() + + for (const message of this.messages) { + if (message.role === 'assistant' && (message as any).parts) { + const parts = (message as any).parts + for (const part of parts) { + if ( + part.type === 'tool-call' && + part.state === 'approval-responded' && + part.approval + ) { + approvals.set(part.approval.id, part.approval.approved) + } + + if ( + part.type === 'tool-call' && + part.output !== undefined && + !part.approval + ) { + clientToolResults.set(part.id, part.output) + } + } + } + } + + return { approvals, clientToolResults } + } + + private emitApprovalRequests( + approvals: Array, + doneChunk: DoneStreamChunk, + ): Array { + const chunks: Array = [] + + for (const approval of approvals) { + aiEventClient.emit('stream:approval-requested', { + streamId: this.streamId, + messageId: this.currentMessageId || undefined, + toolCallId: approval.toolCallId, + toolName: approval.toolName, + input: approval.input, + approvalId: approval.approvalId, + timestamp: Date.now(), + }) + + chunks.push({ + type: 'approval-requested', + id: doneChunk.id, + model: doneChunk.model, + timestamp: Date.now(), + toolCallId: approval.toolCallId, + toolName: approval.toolName, + input: approval.input, + approval: { + id: approval.approvalId, + needsApproval: true, + }, + }) + } + + return chunks + } + + private emitClientToolInputs( + clientRequests: Array, + doneChunk: DoneStreamChunk, + ): Array { + const chunks: Array = [] + + for (const clientTool of clientRequests) { + aiEventClient.emit('stream:tool-input-available', { + streamId: this.streamId, + messageId: this.currentMessageId || undefined, + toolCallId: clientTool.toolCallId, + toolName: clientTool.toolName, + input: clientTool.input, + timestamp: Date.now(), + }) + + chunks.push({ + type: 'tool-input-available', + id: doneChunk.id, + model: doneChunk.model, + timestamp: Date.now(), + toolCallId: clientTool.toolCallId, + toolName: clientTool.toolName, + input: clientTool.input, + }) + } + + return chunks + } + + private emitToolResults( + results: Array, + doneChunk: DoneStreamChunk, + ): Array { + const chunks: Array = [] + + for (const result of results) { + aiEventClient.emit('tool:call-completed', { + requestId: this.requestId, + streamId: this.streamId, + messageId: this.currentMessageId || undefined, + toolCallId: result.toolCallId, + toolName: result.toolName, + result: result.result, + duration: result.duration ?? 0, + timestamp: Date.now(), + }) + + const content = JSON.stringify(result.result) + const chunk: Extract = { + type: 'tool_result', + id: doneChunk.id, + model: doneChunk.model, + timestamp: Date.now(), + toolCallId: result.toolCallId, + content, + } + + chunks.push(chunk) + + this.messages = [ + ...this.messages, + { + role: 'tool', + content, + toolCallId: result.toolCallId, + }, + ] + } + + return chunks + } + + private getPendingToolCallsFromMessages(): Array { + const completedToolIds = new Set( + this.messages + .filter((message) => message.role === 'tool' && message.toolCallId) + .map((message) => message.toolCallId!), + ) + + const pending: Array = [] + + for (const message of this.messages) { + if (message.role === 'assistant' && message.toolCalls) { + for (const toolCall of message.toolCalls) { + if (!completedToolIds.has(toolCall.id)) { + pending.push(toolCall) + } + } + } + } + + return pending + } + + private createSyntheticDoneChunk(): DoneStreamChunk { + return { + type: 'done', + id: this.createId('pending'), + model: 'agent-loop', + timestamp: Date.now(), + finishReason: 'tool_calls', + } + } + + private shouldContinue(): boolean { + if (this.cyclePhase === 'executeToolCalls') { + return true + } + + return ( + this.loopStrategy({ + iterationCount: this.iterationCount, + messages: this.messages, + finishReason: this.lastFinishReason, + }) && this.toolPhase === 'continue' + ) + } + + private isAborted(): boolean { + return !!this.effectiveSignal?.aborted + } + + private setToolPhase(phase: ToolPhaseResult): void { + this.toolPhase = phase + if (phase === 'wait') { + this.shouldEmitStreamEnd = false + } + } + + private createId(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}` + } +} + +// =========================== +// Direct Options Helpers +// =========================== + +/** + * Detect if the first argument is direct options (has adapter property) + */ +function isDirectOptions( + arg: unknown, +): arg is AgentLoopDirectOptions { + return typeof arg === 'object' && arg !== null && 'adapter' in arg +} + +/** + * Create a TextCreator function from direct options. + * This wraps the chat() function with the adapter and model-specific options. + */ +function createTextFnFromDirectOptions( + options: AgentLoopDirectOptions, +): TextCreator { + const { adapter, temperature, topP, maxTokens, metadata, modelOptions } = + options + + return (( + creatorOptions: TextCreatorOptions & { outputSchema?: z.ZodType }, + ) => { + return chat({ + adapter, + messages: creatorOptions.messages, + tools: creatorOptions.tools as Array, + systemPrompts: creatorOptions.systemPrompts, + abortController: creatorOptions.abortController, + temperature, + topP, + maxTokens, + metadata, + modelOptions, + outputSchema: creatorOptions.outputSchema, + stream: creatorOptions.outputSchema === undefined, + }) + }) as TextCreator +} + +/** + * Extract loop-specific options from direct options. + */ +function extractLoopOptions( + options: AgentLoopDirectOptions, +): AgentLoopBaseOptions & { outputSchema?: z.ZodType } { + return { + messages: options.messages as Array, + systemPrompts: options.systemPrompts, + tools: options.tools, + abortController: options.abortController, + agentLoopStrategy: options.agentLoopStrategy, + conversationId: options.conversationId, + outputSchema: options.outputSchema, + } +} + +// =========================== +// Public API +// =========================== + +/** + * Agent loop - orchestrates agentic text generation with automatic tool execution. + * + * Takes a text creator function and loop options, then handles the agentic loop: + * - Calls the text function to get model responses + * - Automatically executes tool calls + * - Continues looping until the strategy says stop + * + * The return type depends on whether `outputSchema` is provided: + * - Without outputSchema: Returns `AsyncIterable` + * - With outputSchema: Returns `Promise>` + * + * @param options - Direct options with adapter, messages, tools, etc. (preferred) + * @param textFn - Alternative: A function that creates a text stream (legacy API) + * + * @example Streaming mode (recommended) + * ```ts + * import { experimental_agentLoop as agentLoop } from '@tanstack/ai' + * import { openaiText } from '@tanstack/ai-openai' + * + * for await (const chunk of agentLoop({ + * adapter: openaiText('gpt-4o'), + * messages: [{ role: 'user', content: 'What is the weather?' }], + * tools: [weatherTool], + * })) { + * if (chunk.type === 'content') { + * process.stdout.write(chunk.delta) + * } + * } + * ``` + * + * @example Structured output mode + * ```ts + * const result = await agentLoop({ + * adapter: openaiText('gpt-4o'), + * messages: [{ role: 'user', content: 'Research and summarize' }], + * tools: [searchTool], + * outputSchema: z.object({ summary: z.string() }), + * }) + * // result is { summary: string } + * ``` + * + * @example Collect text with streamToText helper + * ```ts + * const result = await streamToText(agentLoop({ + * adapter: openaiText('gpt-4o'), + * messages: [{ role: 'user', content: 'Research this topic' }], + * tools: [searchTool], + * })) + * ``` + * + * @example With model options (temperature, etc.) + * ```ts + * for await (const chunk of agentLoop({ + * adapter: openaiText('gpt-4o'), + * messages: [{ role: 'user', content: 'Be creative' }], + * tools: [searchTool], + * temperature: 0.9, + * maxTokens: 2000, + * })) { + * // ... + * } + * ``` + * + * @example Legacy textFn API (still supported) + * ```ts + * const textFn = (opts) => chat({ adapter: openaiText('gpt-4o'), ...opts }) + * + * for await (const chunk of agentLoop(textFn, { + * messages: [{ role: 'user', content: 'What is the weather?' }], + * tools: [weatherTool] + * })) { + * // ... + * } + * ``` + */ +// Direct options overloads (adapter-based API) +export function agentLoop< + TAdapter extends AnyTextAdapter, + TSchema extends z.ZodType, +>( + options: AgentLoopDirectStructuredOptions, +): Promise> +export function agentLoop( + options: AgentLoopDirectStreamOptions, +): AsyncIterable +// TextFn overloads (callback-based API) +export function agentLoop( + textFn: TextCreator, + options: AgentLoopStructuredOptions, +): Promise> +export function agentLoop( + textFn: TextCreator, + options: AgentLoopStreamOptions, +): AsyncIterable +export function agentLoop( + textFn: TextCreator, + options: AgentLoopOptions, +): TSchema extends z.ZodType + ? Promise> + : AsyncIterable +// Implementation +export function agentLoop< + TAdapter extends AnyTextAdapter, + TSchema extends z.ZodType | undefined = undefined, +>( + textFnOrOptions: TextCreator | AgentLoopDirectOptions, + maybeOptions?: AgentLoopOptions, +): Promise> | AsyncIterable { + // Detect which API is being used + if (isDirectOptions(textFnOrOptions)) { + // New direct options API + const directOptions = textFnOrOptions + const textFn = createTextFnFromDirectOptions(directOptions) + const loopOptions = extractLoopOptions(directOptions) + + if (directOptions.outputSchema !== undefined) { + return runStructuredAgentLoop( + textFn, + loopOptions as AgentLoopStructuredOptions, + ) as Promise> + } + + const engine = new AgentLoopEngine({ textFn, options: loopOptions }) + return engine.run() + } + + // Existing textFn API + const textFn = textFnOrOptions as TextCreator + const options = maybeOptions! + + // Check if structured output is requested + if ('outputSchema' in options && options.outputSchema !== undefined) { + return runStructuredAgentLoop(textFn, options) as Promise> + } + + // Otherwise return streaming + const engine = new AgentLoopEngine({ textFn, options }) + return engine.run() +} + +/** + * Run the agent loop and return structured output. + */ +async function runStructuredAgentLoop( + textFn: TextCreator, + options: AgentLoopStructuredOptions, +): Promise> { + const { outputSchema, ...loopOptions } = options + + const engine = new AgentLoopEngine({ textFn, options: loopOptions }) + + // Consume the stream to run the agentic loop + for await (const _chunk of engine.run()) { + // Just consume the stream to execute the agentic loop + } + + // Get the final messages + const finalMessages = engine.getMessages() + + // Call textFn with outputSchema to get structured output + const result = await textFn({ + messages: finalMessages, + systemPrompts: options.systemPrompts, + abortController: options.abortController, + outputSchema, + }) + + return result as z.infer +} + +// Re-export types +export type { AgentLoopStrategy } from '../types' diff --git a/packages/typescript/ai/src/index.ts b/packages/typescript/ai/src/index.ts index 9a4de386..f98be1ee 100644 --- a/packages/typescript/ai/src/index.ts +++ b/packages/typescript/ai/src/index.ts @@ -1,6 +1,7 @@ // Activity functions - individual exports for each activity export { chat, + text as experimental_text, summarize, generateImage, generateVideo, @@ -9,6 +10,27 @@ export { generateTranscription, } from './activities/index' +// Text activity types (experimental) +export { + createTextOptions as experimental_createTextOptions, + type TextOptions as ExperimentalTextOptions, + type TextResult as ExperimentalTextResult, +} from './activities/index' + +// Agent loop (experimental) +export { + agentLoop as experimental_agentLoop, + type AgentLoopOptions, + type AgentLoopBaseOptions, + type AgentLoopStreamOptions, + type AgentLoopStructuredOptions, + type AgentLoopDirectOptions, + type AgentLoopDirectStreamOptions, + type AgentLoopDirectStructuredOptions, + type TextCreator, + type TextCreatorOptions, +} from './agent' + // Create options functions - for pre-defining typed configurations export { createChatOptions } from './activities/chat/index' export { createSummarizeOptions } from './activities/summarize/index' diff --git a/packages/typescript/ai/tests/generate-types.test-d.ts b/packages/typescript/ai/tests/generate-types.test-d.ts new file mode 100644 index 00000000..029c1c15 --- /dev/null +++ b/packages/typescript/ai/tests/generate-types.test-d.ts @@ -0,0 +1,1833 @@ +/** + * Type tests for the chat function + * These tests verify that TypeScript correctly infers types and provides autocomplete + */ + +import { describe, expectTypeOf, it } from 'vitest' +import { + BaseEmbeddingAdapter, + BaseImageAdapter, + BaseSummarizeAdapter, + BaseTextAdapter, +} from '../src/activities' +import { + chat, + embedding, + summarize, + createChatOptions, + createEmbeddingOptions, + createSummarizeOptions, +} from '../src' +import type { + StructuredOutputOptions, + StructuredOutputResult, +} from '../src/activities' +import type { + EmbeddingOptions, + EmbeddingResult, + ImageGenerationOptions, + ImageGenerationResult, + StreamChunk, + SummarizationOptions, + SummarizationResult, + TextOptions, +} from '../src/types' + +// Define test models +const TEST_CHAT_MODELS = ['gpt-4o', 'gpt-4o-mini', 'gpt-3.5-turbo'] as const +const TEST_EMBED_MODELS = [ + 'text-embedding-3-large', + 'text-embedding-3-small', +] as const +const TEST_SUMMARIZE_MODELS = ['summarize-v1', 'summarize-v2'] as const + +// Define strict provider options for testing (without index signatures) +interface TestTextProviderOptions { + temperature?: number + maxTokens?: number +} + +interface TestEmbedProviderOptions { + encodingFormat?: 'float' | 'base64' +} + +interface TestSummarizeProviderOptions { + style?: 'bullet-points' | 'paragraph' +} + +// Mock adapters for type testing +class TestTextAdapter extends BaseTextAdapter< + typeof TEST_CHAT_MODELS, + TestTextProviderOptions +> { + readonly kind = 'text' as const + readonly name = 'test' as const + readonly models = TEST_CHAT_MODELS + + constructor() { + super({}) + } + + async *chatStream(_options: TextOptions): AsyncIterable { + // Mock implementation + } + + structuredOutput( + _options: StructuredOutputOptions, + ): Promise> { + return Promise.resolve({ + data: {}, + rawText: '{}', + }) + } +} + +const TEST_CHAT_MODELS_WITH_MAP = ['model-a', 'model-b'] as const + +interface TestBaseProviderOptions { + baseOnly?: boolean +} + +type TestModelProviderOptionsByName = { + 'model-a': TestBaseProviderOptions & { + foo?: number + } + 'model-b': TestBaseProviderOptions & { + bar?: string + } +} + +type TestModelInputModalitiesByName = { + 'model-a': readonly ['text'] + 'model-b': readonly ['text'] +} + +class TestTextAdapterWithModelOptions extends BaseTextAdapter< + typeof TEST_CHAT_MODELS_WITH_MAP, + TestBaseProviderOptions, + TestModelProviderOptionsByName, + TestModelInputModalitiesByName +> { + readonly kind = 'text' as const + readonly name = 'test-with-map' as const + readonly models = TEST_CHAT_MODELS_WITH_MAP + + _modelProviderOptionsByName!: TestModelProviderOptionsByName + _modelInputModalitiesByName!: TestModelInputModalitiesByName + + constructor() { + super({}) + } + + async *chatStream(_options: TextOptions): AsyncIterable { + // Mock implementation + } + + structuredOutput( + _options: StructuredOutputOptions, + ): Promise> { + return Promise.resolve({ + data: {}, + rawText: '{}', + }) + } +} + +class TestEmbedAdapter extends BaseEmbeddingAdapter< + typeof TEST_EMBED_MODELS, + TestEmbedProviderOptions +> { + readonly kind = 'embedding' as const + readonly name = 'test' as const + readonly models = TEST_EMBED_MODELS + + constructor() { + super({}) + } + + createEmbeddings(_options: EmbeddingOptions): Promise { + return Promise.resolve({ + id: 'test', + model: 'text-embedding-3-small', + embeddings: [[0.1, 0.2]], + usage: { promptTokens: 1, totalTokens: 1 }, + }) + } +} + +class TestSummarizeAdapter extends BaseSummarizeAdapter< + typeof TEST_SUMMARIZE_MODELS, + TestSummarizeProviderOptions +> { + readonly kind = 'summarize' as const + readonly name = 'test' as const + readonly models = TEST_SUMMARIZE_MODELS + + constructor() { + super({}) + } + + summarize(_options: SummarizationOptions): Promise { + return Promise.resolve({ + id: 'test', + model: 'summarize-v1', + summary: 'Test summary', + usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 }, + }) + } +} + +describe('activity function type inference', () => { + it('should infer text adapter return type as AsyncIterable', () => { + const textAdapter = new TestTextAdapter() + const result = chat({ + adapter: textAdapter, + model: 'gpt-4o', + messages: [{ role: 'user', content: 'Hello' }], + }) + + expectTypeOf(result).toMatchTypeOf>() + }) + + it('should infer embedding adapter return type as Promise', () => { + const embedAdapter = new TestEmbedAdapter() + const result = embedding({ + adapter: embedAdapter, + model: 'text-embedding-3-small', + input: 'Hello', + }) + + expectTypeOf(result).toMatchTypeOf>() + }) + + it('should infer summarize adapter return type as Promise', () => { + const summarizeAdapter = new TestSummarizeAdapter() + const result = summarize({ + adapter: summarizeAdapter, + model: 'summarize-v1', + text: 'Long text to summarize', + }) + + expectTypeOf(result).toMatchTypeOf>() + }) + + it('should enforce valid model for text adapter', () => { + const textAdapter = new TestTextAdapter() + + // This should work - valid model + chat({ + adapter: textAdapter, + model: 'gpt-4o', + messages: [{ role: 'user', content: 'Hello' }], + }) + + // invalid model should error + chat({ + adapter: textAdapter, + model: 'invalid-model', + messages: [{ role: 'user', content: 'Hello' }], + }) + }) + + it('should enforce valid model for embedding adapter', () => { + const embedAdapter = new TestEmbedAdapter() + + // This should work - valid model + embedding({ + adapter: embedAdapter, + model: 'text-embedding-3-small', + input: 'Hello', + }) + + // invalid model should error + embedding({ + adapter: embedAdapter, + model: 'invalid-embedding-model', + input: 'Hello', + }) + }) + + it('should enforce valid model for summarize adapter', () => { + const summarizeAdapter = new TestSummarizeAdapter() + + // This should work - valid model + summarize({ + adapter: summarizeAdapter, + model: 'summarize-v1', + text: 'Text to summarize', + }) + + // invalid model should error + summarize({ + adapter: summarizeAdapter, + model: 'invalid-summarize-model', + text: 'Text to summarize', + }) + }) + + it('should enforce strict providerOptions for text adapter', () => { + const textAdapter = new TestTextAdapter() + + // This should work - valid provider options + chat({ + adapter: textAdapter, + model: 'gpt-4o', + messages: [{ role: 'user', content: 'Hello' }], + modelOptions: { + temperature: 0.7, + maxTokens: 100, + }, + }) + + // invalid property should error + chat({ + adapter: textAdapter, + model: 'gpt-4o', + messages: [{ role: 'user', content: 'Hello' }], + modelOptions: { + temperature: 0.7, + invalidProperty: 'should-error', + }, + }) + }) + + it('should enforce strict providerOptions for embedding adapter', () => { + const embedAdapter = new TestEmbedAdapter() + + // This should work - valid provider options + embedding({ + adapter: embedAdapter, + model: 'text-embedding-3-small', + input: 'Hello', + modelOptions: { + encodingFormat: 'float', + }, + }) + + // temperature is not valid for embedding adapter + embedding({ + adapter: embedAdapter, + model: 'text-embedding-3-small', + input: 'Hello', + modelOptions: { + temperature: 0.7, + }, + }) + }) + + it('should enforce strict providerOptions for summarize adapter', () => { + const summarizeAdapter = new TestSummarizeAdapter() + + // This should work - valid provider options + summarize({ + adapter: summarizeAdapter, + model: 'summarize-v1', + text: 'Text to summarize', + modelOptions: { + style: 'bullet-points', + }, + }) + + // invalid property should error + summarize({ + adapter: summarizeAdapter, + model: 'summarize-v1', + text: 'Text to summarize', + modelOptions: { + invalidOption: 'should-error', + }, + }) + }) + + it('should not allow chat-specific options for embedding adapter', () => { + const embedAdapter = new TestEmbedAdapter() + + embedding({ + adapter: embedAdapter, + model: 'text-embedding-3-small', + input: 'Hello', + messages: [{ role: 'user', content: 'Hello' }], + }) + }) + + it('should not allow chat-specific options for summarize adapter', () => { + const summarizeAdapter = new TestSummarizeAdapter() + + summarize({ + adapter: summarizeAdapter, + model: 'summarize-v1', + text: 'Text to summarize', + messages: [{ role: 'user', content: 'Hello' }], + }) + }) + + it('should not allow embedding-specific options for text adapter', () => { + const textAdapter = new TestTextAdapter() + + chat({ + adapter: textAdapter, + model: 'gpt-4o', + messages: [{ role: 'user', content: 'Hello' }], + input: 'Hello', + }) + }) + + it('should not allow summarize-specific options for text adapter', () => { + const textAdapter = new TestTextAdapter() + + chat({ + adapter: textAdapter, + model: 'gpt-4o', + messages: [{ role: 'user', content: 'Hello' }], + text: 'Text to summarize', + }) + }) + + it('should narrow providerOptions based on model (per-model map)', () => { + const adapter = new TestTextAdapterWithModelOptions() + + // model-a should accept both baseOnly and foo + chat({ + adapter, + model: 'model-a', + messages: [{ role: 'user', content: 'Hello' }], + modelOptions: { + baseOnly: true, + foo: 123, + }, + }) + + // model-a should NOT accept bar (it's model-b specific) + chat({ + adapter, + model: 'model-a', + messages: [{ role: 'user', content: 'Hello' }], + modelOptions: { + bar: 'nope', + }, + }) + + // model-b should accept both baseOnly and bar + chat({ + adapter, + model: 'model-b', + messages: [{ role: 'user', content: 'Hello' }], + modelOptions: { + baseOnly: true, + bar: 'ok', + }, + }) + + // model-b should NOT accept foo (it's model-a specific) + chat({ + adapter, + model: 'model-b', + messages: [{ role: 'user', content: 'Hello' }], + modelOptions: { + foo: 123, + }, + }) + }) +}) + +describe('chat() with outputSchema', () => { + // Import zod for schema tests + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const { z } = require('zod') as typeof import('zod') + + it('should return Promise when outputSchema is provided', () => { + const textAdapter = new TestTextAdapter() + + const PersonSchema = z.object({ + name: z.string(), + age: z.number(), + }) + + const result = chat({ + adapter: textAdapter, + model: 'gpt-4o', + messages: [{ role: 'user', content: 'Generate a person' }], + outputSchema: PersonSchema, + }) + + // Return type should be Promise<{ name: string; age: number }> + expectTypeOf(result).toMatchTypeOf>() + }) + + it('should return AsyncIterable when outputSchema is not provided', () => { + const textAdapter = new TestTextAdapter() + + const result = chat({ + adapter: textAdapter, + model: 'gpt-4o', + messages: [{ role: 'user', content: 'Hello' }], + }) + + // Return type should be AsyncIterable + expectTypeOf(result).toMatchTypeOf>() + }) + + it('should infer complex nested schema types', () => { + const textAdapter = new TestTextAdapter() + + const AddressSchema = z.object({ + street: z.string(), + city: z.string(), + country: z.string(), + }) + + const PersonWithAddressSchema = z.object({ + name: z.string(), + addresses: z.array(AddressSchema), + }) + + const result = chat({ + adapter: textAdapter, + model: 'gpt-4o', + messages: [{ role: 'user', content: 'Generate a person with addresses' }], + outputSchema: PersonWithAddressSchema, + }) + + // Return type should be Promise with the correct nested structure + expectTypeOf(result).toMatchTypeOf< + Promise<{ + name: string + addresses: Array<{ street: string; city: string; country: string }> + }> + >() + }) + + it('should not allow outputSchema for embedding adapter', () => { + const embedAdapter = new TestEmbedAdapter() + + const PersonSchema = z.object({ + name: z.string(), + }) + + chat({ + adapter: embedAdapter, + model: 'text-embedding-3-small', + input: 'Hello', + outputSchema: PersonSchema, + }) + }) + + it('should not allow outputSchema for summarize adapter', () => { + const summarizeAdapter = new TestSummarizeAdapter() + + const PersonSchema = z.object({ + name: z.string(), + }) + + chat({ + adapter: summarizeAdapter, + model: 'summarize-v1', + text: 'Text to summarize', + outputSchema: PersonSchema, + }) + }) + + it('should infer schema with optional fields', () => { + const textAdapter = new TestTextAdapter() + + const PersonSchema = z.object({ + name: z.string(), + age: z.number().optional(), + email: z.string().nullable(), + }) + + const result = chat({ + adapter: textAdapter, + model: 'gpt-4o', + messages: [{ role: 'user', content: 'Generate a person' }], + outputSchema: PersonSchema, + }) + + expectTypeOf(result).toMatchTypeOf< + Promise<{ + name: string + age?: number + email: string | null + }> + >() + }) + + it('should work with union types in schema', () => { + const textAdapter = new TestTextAdapter() + + const ResponseSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('success'), data: z.string() }), + z.object({ type: z.literal('error'), message: z.string() }), + ]) + + const result = chat({ + adapter: textAdapter, + model: 'gpt-4o', + messages: [{ role: 'user', content: 'Generate a response' }], + outputSchema: ResponseSchema, + }) + + expectTypeOf(result).toMatchTypeOf< + Promise< + { type: 'success'; data: string } | { type: 'error'; message: string } + > + >() + }) +}) + +describe('chat() with summarize streaming', () => { + it('should return Promise when stream is not provided', () => { + const summarizeAdapter = new TestSummarizeAdapter() + const result = chat({ + adapter: summarizeAdapter, + model: 'summarize-v1', + text: 'Long text to summarize', + }) + + expectTypeOf(result).toMatchTypeOf>() + }) + + it('should return Promise when stream is false', () => { + const summarizeAdapter = new TestSummarizeAdapter() + const result = chat({ + adapter: summarizeAdapter, + model: 'summarize-v1', + text: 'Long text to summarize', + stream: false, + }) + + expectTypeOf(result).toMatchTypeOf>() + }) + + it('should return AsyncIterable when stream is true', () => { + const summarizeAdapter = new TestSummarizeAdapter() + const result = chat({ + adapter: summarizeAdapter, + model: 'summarize-v1', + text: 'Long text to summarize', + stream: true, + }) + + expectTypeOf(result).toMatchTypeOf>() + }) + + it('should allow stream option for text adapter', () => { + const textAdapter = new TestTextAdapter() + + // stream: true is valid (explicit streaming, the default) + chat({ + adapter: textAdapter, + model: 'gpt-4o', + messages: [{ role: 'user', content: 'Hello' }], + stream: true, + }) + + // stream: false is valid (non-streaming mode) + chat({ + adapter: textAdapter, + model: 'gpt-4o', + messages: [{ role: 'user', content: 'Hello' }], + stream: false, + }) + }) + + it('should not allow stream option for embedding adapter', () => { + const embedAdapter = new TestEmbedAdapter() + + chat({ + adapter: embedAdapter, + model: 'text-embedding-3-small', + input: 'Hello', + stream: true, + }) + }) +}) + +// =========================== +// Image Adapter Test Setup +// =========================== + +const TEST_IMAGE_MODELS = ['image-model-1', 'image-model-2'] as const + +interface TestImageProviderOptions { + quality?: 'standard' | 'hd' +} + +type TestImageModelProviderOptionsByName = { + 'image-model-1': TestImageProviderOptions & { style?: 'vivid' | 'natural' } + 'image-model-2': TestImageProviderOptions & { + background?: 'transparent' | 'opaque' + } +} + +type TestImageModelSizeByName = { + 'image-model-1': '256x256' | '512x512' | '1024x1024' + 'image-model-2': '1024x1024' | '1792x1024' | '1024x1792' +} + +class TestImageAdapter extends BaseImageAdapter< + typeof TEST_IMAGE_MODELS, + TestImageProviderOptions, + TestImageModelProviderOptionsByName, + TestImageModelSizeByName +> { + readonly kind = 'image' as const + readonly name = 'test-image' as const + readonly models = TEST_IMAGE_MODELS + + _modelProviderOptionsByName!: TestImageModelProviderOptionsByName + _modelSizeByName!: TestImageModelSizeByName + + constructor() { + super({}) + } + + generateImages( + _options: ImageGenerationOptions, + ): Promise { + return Promise.resolve({ + id: 'test', + model: 'image-model-1', + images: [{ url: 'https://example.com/image.png' }], + }) + } +} + +// =========================== +// Text Adapter with Different Input Modalities Per Model +// =========================== + +const TEST_MULTIMODAL_MODELS = [ + 'text-only-model', + 'text-image-model', + 'multimodal-model', +] as const + +interface TestMultimodalProviderOptions { + temperature?: number +} + +// Define different input modalities per model +type TestMultimodalInputModalitiesByName = { + 'text-only-model': readonly ['text'] + 'text-image-model': readonly ['text', 'image'] + 'multimodal-model': readonly ['text', 'image', 'audio', 'document'] +} + +// Custom metadata types for testing +interface TestImageMetadata { + altText?: string +} + +interface TestMessageMetadataByModality { + text: unknown + image: TestImageMetadata + audio: unknown + video: unknown + document: unknown +} + +class TestMultimodalAdapter extends BaseTextAdapter< + typeof TEST_MULTIMODAL_MODELS, + TestMultimodalProviderOptions, + Record, + TestMultimodalInputModalitiesByName, + TestMessageMetadataByModality +> { + readonly kind = 'text' as const + readonly name = 'test-multimodal' as const + readonly models = TEST_MULTIMODAL_MODELS + + declare _modelInputModalitiesByName: TestMultimodalInputModalitiesByName + declare _messageMetadataByModality: TestMessageMetadataByModality + + constructor() { + super({}) + } + + async *chatStream(_options: TextOptions): AsyncIterable { + // Mock implementation + } + + structuredOutput( + _options: StructuredOutputOptions, + ): Promise> { + return Promise.resolve({ + data: {}, + rawText: '{}', + }) + } +} + +// =========================== +// Text Adapter Type Tests +// =========================== + +describe('chat() text adapter type safety', () => { + it('should return type that conforms to outputSchema type', () => { + const textAdapter = new TestTextAdapter() + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const { z } = require('zod') as typeof import('zod') + + const PersonSchema = z.object({ + name: z.string(), + age: z.number(), + }) + + const result = chat({ + adapter: textAdapter, + model: 'gpt-4o', + messages: [{ role: 'user', content: 'Generate a person' }], + outputSchema: PersonSchema, + }) + + // Return type should match the schema + expectTypeOf(result).toExtend>() + // Should NOT match a different type + expectTypeOf(result).not.toMatchTypeOf>() + }) + + it('should error on invalid provider options', () => { + const textAdapter = new TestTextAdapter() + + chat({ + adapter: textAdapter, + model: 'gpt-4o', + messages: [{ role: 'user', content: 'Hello' }], + modelOptions: { + unknownOption: 'invalid', + }, + }) + }) + + it('should error on non-existing props', () => { + const textAdapter = new TestTextAdapter() + + chat({ + adapter: textAdapter, + model: 'gpt-4o', + messages: [{ role: 'user', content: 'Hello' }], + nonExistingProp: 'should-error', + }) + }) + + it('should reject embedding-specific properties on text adapter', () => { + const textAdapter = new TestTextAdapter() + + chat({ + adapter: textAdapter, + model: 'gpt-4o', + messages: [{ role: 'user', content: 'Hello' }], + input: 'not allowed on text adapter', + }) + + chat({ + adapter: textAdapter, + model: 'gpt-4o', + messages: [{ role: 'user', content: 'Hello' }], + dimensions: 1024, + }) + }) + + it('should reject summarize-specific properties on text adapter', () => { + const textAdapter = new TestTextAdapter() + + chat({ + adapter: textAdapter, + model: 'gpt-4o', + messages: [{ role: 'user', content: 'Hello' }], + text: 'not allowed on text adapter', + }) + + chat({ + adapter: textAdapter, + model: 'gpt-4o', + messages: [{ role: 'user', content: 'Hello' }], + maxLength: 500, + }) + }) + + it('should reject image-specific properties on text adapter', () => { + const textAdapter = new TestTextAdapter() + + chat({ + adapter: textAdapter, + model: 'gpt-4o', + messages: [{ role: 'user', content: 'Hello' }], + prompt: 'not allowed on text adapter', + }) + + chat({ + adapter: textAdapter, + model: 'gpt-4o', + messages: [{ role: 'user', content: 'Hello' }], + size: '1024x1024', + }) + }) + + it('should reject providerOptions from other adapters on text adapter', () => { + const textAdapter = new TestTextAdapter() + + chat({ + adapter: textAdapter, + model: 'gpt-4o', + messages: [{ role: 'user', content: 'Hello' }], + modelOptions: { + encodingFormat: 'float', + }, + }) + + chat({ + adapter: textAdapter, + model: 'gpt-4o', + messages: [{ role: 'user', content: 'Hello' }], + modelOptions: { + quality: 'hd', + }, + }) + }) + + it('should change providerOptions type based on model selected', () => { + const adapter = new TestTextAdapterWithModelOptions() + + // model-a should accept foo (and baseOnly which is shared) + chat({ + adapter, + model: 'model-a', + messages: [{ role: 'user', content: 'Hello' }], + modelOptions: { + baseOnly: true, + foo: 42, + }, + }) + + // model-a should NOT accept bar (model-b specific) + chat({ + adapter, + model: 'model-a', + messages: [{ role: 'user', content: 'Hello' }], + modelOptions: { + baseOnly: true, // shared property - OK + bar: 'invalid-for-model-a', + }, + }) + + // model-b should accept bar (and baseOnly which is shared) + chat({ + adapter, + model: 'model-b', + messages: [{ role: 'user', content: 'Hello' }], + modelOptions: { + baseOnly: true, + bar: 'valid-for-model-b', + }, + }) + + // model-b should NOT accept foo (model-a specific) + chat({ + adapter, + model: 'model-b', + messages: [{ role: 'user', content: 'Hello' }], + modelOptions: { + baseOnly: true, // shared property - OK + foo: 42, + }, + }) + }) +}) + +// =========================== +// Text Adapter Input Modality Constraint Tests +// =========================== + +describe('chat() text adapter input modality constraints', () => { + it('should allow text content on text-only model', () => { + const adapter = new TestMultimodalAdapter() + + // Text content should work for text-only-model + chat({ + adapter, + model: 'text-only-model', + messages: [{ role: 'user', content: 'Hello, how are you?' }], + }) + + // String content should also work + chat({ + adapter, + model: 'text-only-model', + messages: [{ role: 'user', content: 'Hello' }], + }) + }) + + it('should reject image content on text-only model', () => { + const adapter = new TestMultimodalAdapter() + + chat({ + adapter, + model: 'text-only-model', + messages: [ + { + role: 'user', + content: [ + { + type: 'image', + source: { type: 'url', value: 'https://example.com/image.png' }, + }, + ], + }, + ], + }) + }) + + it('should reject document content on text-only model', () => { + const adapter = new TestMultimodalAdapter() + + chat({ + adapter, + model: 'text-only-model', + messages: [ + { + role: 'user', + content: [ + { + type: 'document', + source: { type: 'url', value: 'https://example.com/doc.pdf' }, + }, + ], + }, + ], + }) + }) + + it('should reject audio content on text-only model', () => { + const adapter = new TestMultimodalAdapter() + + chat({ + adapter, + model: 'text-only-model', + messages: [ + { + role: 'user', + content: [ + { + type: 'audio', + source: { type: 'url', value: 'https://example.com/audio.mp3' }, + }, + ], + }, + ], + }) + }) + + it('should allow text and image content on text-image model', () => { + const adapter = new TestMultimodalAdapter() + + // Text content should work + chat({ + adapter, + model: 'text-image-model', + messages: [{ role: 'user', content: 'Hello' }], + }) + + // Image content should work with proper metadata type + chat({ + adapter, + model: 'text-image-model', + messages: [ + { + role: 'user', + content: [ + { type: 'text', content: 'What is in this image?' }, + { + type: 'image', + source: { type: 'url', value: 'https://example.com/image.png' }, + metadata: { altText: 'A photo' }, + }, + ], + }, + ], + }) + }) + + it('should reject document content on text-image model', () => { + const adapter = new TestMultimodalAdapter() + + chat({ + adapter, + model: 'text-image-model', + messages: [ + { + role: 'user', + content: [ + { + type: 'document', + source: { type: 'url', value: 'https://example.com/doc.pdf' }, + }, + ], + }, + ], + }) + }) + + it('should reject audio content on text-image model', () => { + const adapter = new TestMultimodalAdapter() + + chat({ + adapter, + model: 'text-image-model', + messages: [ + { + role: 'user', + content: [ + { + type: 'audio', + source: { type: 'url', value: 'https://example.com/audio.mp3' }, + }, + ], + }, + ], + }) + }) + + it('should allow all supported modalities on multimodal model', () => { + const adapter = new TestMultimodalAdapter() + + // All supported content types should work on multimodal-model + chat({ + adapter, + model: 'multimodal-model', + messages: [ + { + role: 'user', + content: [ + { type: 'text', content: 'Analyze these files' }, + { + type: 'image', + source: { type: 'url', value: 'https://example.com/image.png' }, + }, + { + type: 'audio', + source: { type: 'url', value: 'https://example.com/audio.mp3' }, + }, + { + type: 'document', + source: { type: 'url', value: 'https://example.com/doc.pdf' }, + }, + ], + }, + ], + }) + }) + + it('should reject video content on multimodal model that does not support video', () => { + const adapter = new TestMultimodalAdapter() + + chat({ + adapter, + model: 'multimodal-model', + messages: [ + { + role: 'user', + content: [ + { + type: 'video', + source: { type: 'url', value: 'https://example.com/video.mp4' }, + }, + ], + }, + ], + }) + }) + + it('should enforce adapter-specific metadata types on content parts', () => { + const adapter = new TestMultimodalAdapter() + + // Valid metadata for image (TestImageMetadata has altText) + chat({ + adapter, + model: 'text-image-model', + messages: [ + { + role: 'user', + content: [ + { + type: 'image', + source: { type: 'url', value: 'https://example.com/image.png' }, + metadata: { altText: 'Description' }, + }, + ], + }, + ], + }) + }) +}) + +// =========================== +// Image Adapter Type Tests +// =========================== + +describe('chat() image adapter type safety', () => { + it('should have size determined by the model', () => { + const imageAdapter = new TestImageAdapter() + + // image-model-1 supports 256x256, 512x512, 1024x1024 + chat({ + adapter: imageAdapter, + model: 'image-model-1', + prompt: 'A beautiful sunset', + size: '512x512', // valid for image-model-1 + }) + + // image-model-2 supports 1024x1024, 1792x1024, 1024x1792 + chat({ + adapter: imageAdapter, + model: 'image-model-2', + prompt: 'A beautiful sunset', + size: '1792x1024', // valid for image-model-2 + }) + }) + + it('should return ImageGenerationResult type', () => { + const imageAdapter = new TestImageAdapter() + + const result = chat({ + adapter: imageAdapter, + model: 'image-model-1', + prompt: 'A beautiful sunset', + }) + + expectTypeOf(result).toExtend>() + }) + + it('should error on invalid size', () => { + const imageAdapter = new TestImageAdapter() + + chat({ + adapter: imageAdapter, + model: 'image-model-1', + prompt: 'A beautiful sunset', + size: '2048x2048', + }) + }) + + it('should error when size valid for one model is used with another', () => { + const imageAdapter = new TestImageAdapter() + + // 1792x1024 is valid for image-model-2 but NOT for image-model-1 + chat({ + adapter: imageAdapter, + model: 'image-model-1', + prompt: 'A beautiful sunset', + size: '1792x1024', + }) + + // 256x256 is valid for image-model-1 but NOT for image-model-2 + chat({ + adapter: imageAdapter, + model: 'image-model-2', + prompt: 'A beautiful sunset', + size: '256x256', + }) + }) + + it('should have model-specific provider options for image adapter', () => { + const imageAdapter = new TestImageAdapter() + + // image-model-1 supports style option + chat({ + adapter: imageAdapter, + model: 'image-model-1', + prompt: 'A beautiful sunset', + modelOptions: { + quality: 'hd', // shared + style: 'vivid', // model-1 specific + }, + }) + + // image-model-1 should NOT accept background (model-2 specific) + chat({ + adapter: imageAdapter, + model: 'image-model-1', + prompt: 'A beautiful sunset', + modelOptions: { + background: 'transparent', + }, + }) + + // image-model-2 supports background option + chat({ + adapter: imageAdapter, + model: 'image-model-2', + prompt: 'A beautiful sunset', + modelOptions: { + quality: 'hd', // shared + background: 'transparent', // model-2 specific + }, + }) + + // image-model-2 should NOT accept style (model-1 specific) + chat({ + adapter: imageAdapter, + model: 'image-model-2', + prompt: 'A beautiful sunset', + modelOptions: { + style: 'vivid', + }, + }) + }) + + it('should reject text-specific properties on image adapter', () => { + const imageAdapter = new TestImageAdapter() + + chat({ + adapter: imageAdapter, + model: 'image-model-1', + prompt: 'A beautiful sunset', + messages: [{ role: 'user', content: 'Hello' }], + }) + + chat({ + adapter: imageAdapter, + model: 'image-model-1', + prompt: 'A beautiful sunset', + tools: [], + }) + + chat({ + adapter: imageAdapter, + model: 'image-model-1', + prompt: 'A beautiful sunset', + systemPrompts: ['You are helpful'], + }) + }) + + it('should reject embedding-specific properties on image adapter', () => { + const imageAdapter = new TestImageAdapter() + + chat({ + adapter: imageAdapter, + model: 'image-model-1', + prompt: 'A beautiful sunset', + input: 'not allowed on image adapter', + }) + + chat({ + adapter: imageAdapter, + model: 'image-model-1', + prompt: 'A beautiful sunset', + dimensions: 1024, + }) + }) + + it('should reject summarize-specific properties on image adapter', () => { + const imageAdapter = new TestImageAdapter() + + chat({ + adapter: imageAdapter, + model: 'image-model-1', + prompt: 'A beautiful sunset', + text: 'not allowed on image adapter', + }) + + chat({ + adapter: imageAdapter, + model: 'image-model-1', + prompt: 'A beautiful sunset', + maxLength: 500, + }) + + chat({ + adapter: imageAdapter, + model: 'image-model-1', + prompt: 'A beautiful sunset', + style: 'bullet-points', + }) + }) + + it('should reject providerOptions from other adapters on image adapter', () => { + const imageAdapter = new TestImageAdapter() + + chat({ + adapter: imageAdapter, + model: 'image-model-1', + prompt: 'A beautiful sunset', + modelOptions: { + temperature: 0.7, + }, + }) + + chat({ + adapter: imageAdapter, + model: 'image-model-1', + prompt: 'A beautiful sunset', + modelOptions: { + maxTokens: 100, + }, + }) + + chat({ + adapter: imageAdapter, + model: 'image-model-1', + prompt: 'A beautiful sunset', + modelOptions: { + encodingFormat: 'float', + }, + }) + }) +}) + +// =========================== +// Embedding Adapter Type Tests +// =========================== + +describe('chat() embedding adapter type safety', () => { + it('should reject text-specific properties on embedding adapter', () => { + const embedAdapter = new TestEmbedAdapter() + + chat({ + adapter: embedAdapter, + model: 'text-embedding-3-small', + input: 'Hello', + messages: [{ role: 'user', content: 'Hello' }], + }) + + chat({ + adapter: embedAdapter, + model: 'text-embedding-3-small', + input: 'Hello', + tools: [], + }) + + chat({ + adapter: embedAdapter, + model: 'text-embedding-3-small', + input: 'Hello', + systemPrompts: ['You are helpful'], + }) + + chat({ + adapter: embedAdapter, + model: 'text-embedding-3-small', + input: 'Hello', + outputSchema: {}, + }) + }) + + it('should reject summarize-specific properties on embedding adapter', () => { + const embedAdapter = new TestEmbedAdapter() + + chat({ + adapter: embedAdapter, + model: 'text-embedding-3-small', + input: 'Hello', + text: 'not allowed on embedding adapter', + }) + + chat({ + adapter: embedAdapter, + model: 'text-embedding-3-small', + input: 'Hello', + maxLength: 500, + }) + + chat({ + adapter: embedAdapter, + model: 'text-embedding-3-small', + input: 'Hello', + style: 'bullet-points', + }) + + chat({ + adapter: embedAdapter, + model: 'text-embedding-3-small', + input: 'Hello', + focus: 'key points', + }) + }) + + it('should reject image-specific properties on embedding adapter', () => { + const embedAdapter = new TestEmbedAdapter() + + chat({ + adapter: embedAdapter, + model: 'text-embedding-3-small', + input: 'Hello', + prompt: 'not allowed on embedding adapter', + }) + + chat({ + adapter: embedAdapter, + model: 'text-embedding-3-small', + input: 'Hello', + size: '1024x1024', + }) + + chat({ + adapter: embedAdapter, + model: 'text-embedding-3-small', + input: 'Hello', + n: 4, + }) + }) + + it('should reject providerOptions from other adapters on embedding adapter', () => { + const embedAdapter = new TestEmbedAdapter() + + chat({ + adapter: embedAdapter, + model: 'text-embedding-3-small', + input: 'Hello', + modelOptions: { + temperature: 0.7, + }, + }) + + chat({ + adapter: embedAdapter, + model: 'text-embedding-3-small', + input: 'Hello', + modelOptions: { + maxTokens: 100, + }, + }) + + chat({ + adapter: embedAdapter, + model: 'text-embedding-3-small', + input: 'Hello', + modelOptions: { + quality: 'hd', + }, + }) + }) +}) + +// =========================== +// Summarize Adapter Type Tests +// =========================== + +describe('chat() summarize adapter type safety', () => { + it('should reject text-specific properties on summarize adapter', () => { + const summarizeAdapter = new TestSummarizeAdapter() + + chat({ + adapter: summarizeAdapter, + model: 'summarize-v1', + text: 'Long text to summarize', + messages: [{ role: 'user', content: 'Hello' }], + }) + + chat({ + adapter: summarizeAdapter, + model: 'summarize-v1', + text: 'Long text to summarize', + tools: [], + }) + + chat({ + adapter: summarizeAdapter, + model: 'summarize-v1', + text: 'Long text to summarize', + systemPrompts: ['You are helpful'], + }) + + chat({ + adapter: summarizeAdapter, + model: 'summarize-v1', + text: 'Long text to summarize', + outputSchema: {}, + }) + }) + + it('should reject embedding-specific properties on summarize adapter', () => { + const summarizeAdapter = new TestSummarizeAdapter() + + chat({ + adapter: summarizeAdapter, + model: 'summarize-v1', + text: 'Long text to summarize', + input: 'not allowed on summarize adapter', + }) + + chat({ + adapter: summarizeAdapter, + model: 'summarize-v1', + text: 'Long text to summarize', + dimensions: 1024, + }) + }) + + it('should reject image-specific properties on summarize adapter', () => { + const summarizeAdapter = new TestSummarizeAdapter() + + chat({ + adapter: summarizeAdapter, + model: 'summarize-v1', + text: 'Long text to summarize', + prompt: 'not allowed on summarize adapter', + }) + + chat({ + adapter: summarizeAdapter, + model: 'summarize-v1', + text: 'Long text to summarize', + size: '1024x1024', + }) + + chat({ + adapter: summarizeAdapter, + model: 'summarize-v1', + text: 'Long text to summarize', + n: 4, + }) + }) + + it('should reject providerOptions from other adapters on summarize adapter', () => { + const summarizeAdapter = new TestSummarizeAdapter() + + chat({ + adapter: summarizeAdapter, + model: 'summarize-v1', + text: 'Long text to summarize', + modelOptions: { + temperature: 0.7, + }, + }) + + chat({ + adapter: summarizeAdapter, + model: 'summarize-v1', + text: 'Long text to summarize', + modelOptions: { + maxTokens: 100, + }, + }) + + chat({ + adapter: summarizeAdapter, + model: 'summarize-v1', + text: 'Long text to summarize', + modelOptions: { + encodingFormat: 'float', + }, + }) + + chat({ + adapter: summarizeAdapter, + model: 'summarize-v1', + text: 'Long text to summarize', + modelOptions: { + quality: 'hd', + }, + }) + }) +}) + +// =========================== +// createOptions Type Tests +// =========================== + +describe('createChatOptions() type inference', () => { + it('should return typed options for text adapter', () => { + const textAdapter = new TestTextAdapter() + + const options = createChatOptions({ + adapter: textAdapter, + model: 'gpt-4o', + messages: [{ role: 'user', content: 'Hello' }], + }) + + // Options should have the correct adapter type + expectTypeOf(options.adapter).toMatchTypeOf() + // Options should have the correct model type + expectTypeOf(options.model).toEqualTypeOf<'gpt-4o'>() + }) + + it('should enforce valid model for text adapter', () => { + const textAdapter = new TestTextAdapter() + + // This should work - valid model + createChatOptions({ + adapter: textAdapter, + model: 'gpt-4o', + messages: [{ role: 'user', content: 'Hello' }], + }) + + // invalid model should error + createChatOptions({ + adapter: textAdapter, + model: 'invalid-model', + messages: [{ role: 'user', content: 'Hello' }], + }) + }) + + it('should enforce valid model for embedding adapter', () => { + const embedAdapter = new TestEmbedAdapter() + + // This should work - valid model + createChatOptions({ + adapter: embedAdapter, + model: 'text-embedding-3-small', + input: 'Hello', + }) + + // invalid model should error + createChatOptions({ + adapter: embedAdapter, + model: 'invalid-embedding-model', + input: 'Hello', + }) + }) + + it('should enforce valid model for summarize adapter', () => { + const summarizeAdapter = new TestSummarizeAdapter() + + // This should work - valid model + createChatOptions({ + adapter: summarizeAdapter, + model: 'summarize-v1', + text: 'Text to summarize', + }) + + // invalid model should error + createChatOptions({ + adapter: summarizeAdapter, + model: 'invalid-summarize-model', + text: 'Text to summarize', + }) + }) + + it('should enforce strict providerOptions for text adapter', () => { + const textAdapter = new TestTextAdapter() + + // This should work - valid provider options + createChatOptions({ + adapter: textAdapter, + model: 'gpt-4o', + messages: [{ role: 'user', content: 'Hello' }], + modelOptions: { + temperature: 0.7, + maxTokens: 100, + }, + }) + + // invalid property should error + createChatOptions({ + adapter: textAdapter, + model: 'gpt-4o', + messages: [{ role: 'user', content: 'Hello' }], + modelOptions: { + temperature: 0.7, + invalidProperty: 'should-error', + }, + }) + }) + + it('should narrow providerOptions based on model (per-model map)', () => { + const adapter = new TestTextAdapterWithModelOptions() + + // model-a should accept both baseOnly and foo + createChatOptions({ + adapter, + model: 'model-a', + messages: [{ role: 'user', content: 'Hello' }], + modelOptions: { + baseOnly: true, + foo: 123, + }, + }) + + // model-a should NOT accept bar (it's model-b specific) + createChatOptions({ + adapter, + model: 'model-a', + messages: [{ role: 'user', content: 'Hello' }], + modelOptions: { + bar: 'nope', + }, + }) + + // model-b should accept both baseOnly and bar + createChatOptions({ + adapter, + model: 'model-b', + messages: [{ role: 'user', content: 'Hello' }], + modelOptions: { + baseOnly: true, + bar: 'ok', + }, + }) + + // model-b should NOT accept foo (it's model-a specific) + createChatOptions({ + adapter, + model: 'model-b', + messages: [{ role: 'user', content: 'Hello' }], + modelOptions: { + foo: 123, + }, + }) + }) + + it('should return options that can be spread into chat()', () => { + const textAdapter = new TestTextAdapter() + + const options = createChatOptions({ + adapter: textAdapter, + model: 'gpt-4o', + messages: [{ role: 'user', content: 'Hello' }], + }) + + // Should be able to spread into chat() and get correct return type + const result = chat({ + ...options, + }) + + expectTypeOf(result).toMatchTypeOf>() + }) + + it('should work with image adapter', () => { + const imageAdapter = new TestImageAdapter() + + // Valid options for image-model-1 + createChatOptions({ + adapter: imageAdapter, + model: 'image-model-1', + prompt: 'A beautiful sunset', + size: '512x512', + }) + + // Invalid size for image-model-1 + createChatOptions({ + adapter: imageAdapter, + model: 'image-model-1', + prompt: 'A beautiful sunset', + size: '1792x1024', + }) + }) + + it('should not allow mixing activity-specific options', () => { + const textAdapter = new TestTextAdapter() + + createChatOptions({ + adapter: textAdapter, + model: 'gpt-4o', + messages: [{ role: 'user', content: 'Hello' }], + input: 'not allowed on text adapter', + }) + + createChatOptions({ + adapter: textAdapter, + model: 'gpt-4o', + messages: [{ role: 'user', content: 'Hello' }], + text: 'not allowed on text adapter', + }) + + createChatOptions({ + adapter: textAdapter, + model: 'gpt-4o', + messages: [{ role: 'user', content: 'Hello' }], + prompt: 'not allowed on text adapter', + }) + }) +}) diff --git a/packages/typescript/smoke-tests/adapters/src/tests/emb-embedding.ts b/packages/typescript/smoke-tests/adapters/src/tests/emb-embedding.ts new file mode 100644 index 00000000..936c5537 --- /dev/null +++ b/packages/typescript/smoke-tests/adapters/src/tests/emb-embedding.ts @@ -0,0 +1,84 @@ +import { embedding } from '@tanstack/ai' +import { writeDebugFile } from '../harness' +import type { AdapterContext, TestOutcome } from '../harness' + +/** + * EMB: Embedding Test + * + * Tests vector embedding generation by providing text inputs + * and verifying we get valid numeric vectors. + */ +export async function runEMB( + adapterContext: AdapterContext, +): Promise { + const testName = 'emb-embedding' + const adapterName = adapterContext.adapterName + + // Skip if no embedding adapter is available + if (!adapterContext.embeddingAdapter) { + console.log( + `[${adapterName}] — ${testName}: Ignored (no embedding adapter)`, + ) + return { passed: true, ignored: true } + } + + const model = adapterContext.embeddingModel || adapterContext.model + const inputs = [ + 'The Eiffel Tower is located in Paris.', + 'The Colosseum is located in Rome.', + ] + + const debugData: Record = { + adapter: adapterName, + test: testName, + model, + timestamp: new Date().toISOString(), + input: { inputs }, + } + + try { + const result = await embedding({ + adapter: adapterContext.embeddingAdapter, + model, + input: inputs, + }) + + const embeddings: Array> = result.embeddings || [] + const lengths = embeddings.map((e: Array) => e?.length || 0) + const vectorsAreNumeric = embeddings.every( + (vec: Array) => + Array.isArray(vec) && vec.every((n: number) => typeof n === 'number'), + ) + const passed = + embeddings.length === inputs.length && + vectorsAreNumeric && + lengths.every((len: number) => len > 0) + + debugData.summary = { + embeddingLengths: lengths, + firstEmbeddingPreview: embeddings[0]?.slice(0, 8), + usage: result.usage, + } + debugData.result = { + passed, + error: passed ? undefined : 'Embeddings missing, empty, or invalid', + } + + await writeDebugFile(adapterName, testName, debugData) + + console.log( + `[${adapterName}] ${passed ? '✅' : '❌'} ${testName}${ + passed ? '' : `: ${debugData.result.error}` + }`, + ) + + return { passed, error: debugData.result.error } + } catch (error: any) { + const message = error?.message || String(error) + debugData.summary = { error: message } + debugData.result = { passed: false, error: message } + await writeDebugFile(adapterName, testName, debugData) + console.log(`[${adapterName}] ❌ ${testName}: ${message}`) + return { passed: false, error: message } + } +}