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/README.md b/README.md
index 77acf865..4b3d3750 100644
--- a/README.md
+++ b/README.md
@@ -38,7 +38,9 @@
A powerful, type-safe AI SDK for building AI-powered applications.
- Provider-agnostic adapters (OpenAI, Anthropic, Gemini, Ollama, etc.)
+- **Tree-shakeable adapters** - Import only what you need for smaller bundles
- **Multimodal content support** - Send images, audio, video, and documents
+- **Image generation** - Generate images with OpenAI DALL-E/GPT-Image and Gemini Imagen
- Chat completion, streaming, and agent loop strategies
- Headless chat state management with adapters (SSE, HTTP stream, custom)
- Isomorphic type-safe tools with server/client execution
@@ -46,6 +48,30 @@ A powerful, type-safe AI SDK for building AI-powered applications.
### Read the docs →
+## Tree-Shakeable Adapters
+
+Import only the functionality you need for smaller bundle sizes:
+
+```typescript
+// Only chat functionality - no embedding or summarization code bundled
+import { openaiText } from '@tanstack/ai-openai/adapters'
+import { generate } from '@tanstack/ai'
+
+const textAdapter = openaiText()
+
+const result = generate({
+ adapter: textAdapter,
+ model: 'gpt-4o',
+ messages: [{ role: 'user', content: [{ type: 'text', content: 'Hello!' }] }],
+})
+
+for await (const chunk of result) {
+ console.log(chunk)
+}
+```
+
+Available adapters: `openaiText`, `openaiEmbed`, `openaiSummarize`, `anthropicText`, `geminiText`, `ollamaText`, and more.
+
## Bonus: TanStack Start Integration
TanStack AI works with **any** framework (Next.js, Express, Remix, etc.).
diff --git a/docs/adapters/anthropic.md b/docs/adapters/anthropic.md
index 422f68bf..5f255a49 100644
--- a/docs/adapters/anthropic.md
+++ b/docs/adapters/anthropic.md
@@ -1,9 +1,9 @@
---
title: Anthropic Adapter
-slug: /adapters/anthropic
+id: anthropic-adapter
---
-The Anthropic adapter provides access to Claude models, including Claude 3.5 Sonnet, Claude 3 Opus, and more.
+The Anthropic adapter provides access to Claude models, including Claude Sonnet 4.5, Claude Opus 4.5, and more.
## Installation
@@ -14,63 +14,72 @@ npm install @tanstack/ai-anthropic
## Basic Usage
```typescript
-import { chat } from "@tanstack/ai";
-import { anthropic } from "@tanstack/ai-anthropic";
+import { ai } from "@tanstack/ai";
+import { anthropicText } from "@tanstack/ai-anthropic";
-const adapter = anthropic();
+const adapter = anthropicText();
-const stream = chat({
+const stream = ai({
adapter,
messages: [{ role: "user", content: "Hello!" }],
- model: "claude-3-5-sonnet-20241022",
+ model: "claude-sonnet-4-5-20250929",
});
```
## Basic Usage - Custom API Key
```typescript
-import { chat } from "@tanstack/ai";
-import { createAnthropic } from "@tanstack/ai-anthropic";
+import { ai } from "@tanstack/ai";
+import { createAnthropicText } from "@tanstack/ai-anthropic";
-const adapter = createAnthropic(process.env.ANTHROPIC_API_KEY, {
+const adapter = createAnthropicText(process.env.ANTHROPIC_API_KEY!, {
// ... your config options
- });
+});
-const stream = chat({
+const stream = ai({
adapter,
messages: [{ role: "user", content: "Hello!" }],
- model: "claude-3-5-sonnet-20241022",
+ model: "claude-sonnet-4-5-20250929",
});
```
## Configuration
```typescript
-import { anthropic, type AnthropicConfig } from "@tanstack/ai-anthropic";
+import { createAnthropicText, type AnthropicTextConfig } from "@tanstack/ai-anthropic";
-const config: AnthropicConfig = {
- // ... your config options
+const config: AnthropicTextConfig = {
+ baseURL: "https://api.anthropic.com", // Optional, for custom endpoints
};
-const adapter = anthropic(config);
+const adapter = createAnthropicText(process.env.ANTHROPIC_API_KEY!, config);
```
-
+
+## Available Models
+
+### Chat Models
+
+- `claude-sonnet-4-5-20250929` - Claude Sonnet 4.5 (balanced)
+- `claude-opus-4-5-20251101` - Claude Opus 4.5 (most capable)
+- `claude-haiku-4-0-20250514` - Claude Haiku 4.0 (fastest)
+- `claude-3-5-sonnet-20241022` - Claude 3.5 Sonnet
+- `claude-3-opus-20240229` - Claude 3 Opus
## Example: Chat Completion
```typescript
-import { chat, toStreamResponse } from "@tanstack/ai";
-import { anthropic } from "@tanstack/ai-anthropic";
+import { ai, toStreamResponse } from "@tanstack/ai";
+import { anthropicText } from "@tanstack/ai-anthropic";
-const adapter = anthropic();
+const adapter = anthropicText();
export async function POST(request: Request) {
const { messages } = await request.json();
- const stream = chat({
+ const stream = ai({
adapter,
messages,
- model: "claude-3-5-sonnet-20241022",
+ model: "claude-sonnet-4-5-20250929",
});
return toStreamResponse(stream);
@@ -80,11 +89,11 @@ export async function POST(request: Request) {
## Example: With Tools
```typescript
-import { chat, toolDefinition } from "@tanstack/ai";
-import { anthropic } from "@tanstack/ai-anthropic";
+import { ai, toolDefinition } from "@tanstack/ai";
+import { anthropicText } from "@tanstack/ai-anthropic";
import { z } from "zod";
-const adapter = anthropic();
+const adapter = anthropicText();
const searchDatabaseDef = toolDefinition({
name: "search_database",
@@ -96,43 +105,39 @@ const searchDatabaseDef = toolDefinition({
const searchDatabase = searchDatabaseDef.server(async ({ query }) => {
// Search database
- return { results: [...] };
+ return { results: [] };
});
-const stream = chat({
+const stream = ai({
adapter,
messages,
- model: "claude-3-5-sonnet-20241022",
+ model: "claude-sonnet-4-5-20250929",
tools: [searchDatabase],
});
```
## Provider Options
-Anthropic supports provider-specific options:
+Anthropic supports various provider-specific options:
```typescript
-const stream = chat({
- adapter: anthropic(),
+const stream = ai({
+ adapter: anthropicText(),
messages,
- model: "claude-3-5-sonnet-20241022",
+ model: "claude-sonnet-4-5-20250929",
providerOptions: {
- thinking: {
- type: "enabled",
- budgetTokens: 1000,
- },
- cacheControl: {
- type: "ephemeral",
- ttl: "5m",
- },
- sendReasoning: true,
+ max_tokens: 4096,
+ temperature: 0.7,
+ top_p: 0.9,
+ top_k: 40,
+ stop_sequences: ["END"],
},
});
```
### Thinking (Extended Thinking)
-Enable extended thinking with a token budget. This allows Claude to show its reasoning process, which is streamed as `thinking` chunks and displayed as `ThinkingPart` in messages:
+Enable extended thinking with a token budget. This allows Claude to show its reasoning process, which is streamed as `thinking` chunks:
```typescript
providerOptions: {
@@ -154,23 +159,51 @@ When thinking is enabled, the model's reasoning process is streamed separately f
### Prompt Caching
-Cache prompts for better performance:
+Cache prompts for better performance and reduced costs:
```typescript
-messages: [
- { role: "user", content: [{
- type: "text",
- content: "What is the capital of France?",
- metadata: {
- cache_control: {
- type: "ephemeral",
- ttl: "5m",
- }
- }
- }]}
-]
+const stream = ai({
+ adapter: anthropicText(),
+ messages: [
+ {
+ role: "user",
+ content: [
+ {
+ type: "text",
+ content: "What is the capital of France?",
+ metadata: {
+ cache_control: {
+ type: "ephemeral",
+ },
+ },
+ },
+ ],
+ },
+ ],
+ model: "claude-sonnet-4-5-20250929",
+});
```
+## Summarization
+
+Anthropic supports text summarization:
+
+```typescript
+import { ai } from "@tanstack/ai";
+import { anthropicSummarize } from "@tanstack/ai-anthropic";
+
+const adapter = anthropicSummarize();
+
+const result = await ai({
+ adapter,
+ model: "claude-sonnet-4-5-20250929",
+ text: "Your long text to summarize...",
+ maxLength: 100,
+ style: "concise", // "concise" | "bullet-points" | "paragraph"
+});
+
+console.log(result.summary);
+```
## Environment Variables
@@ -182,15 +215,44 @@ ANTHROPIC_API_KEY=sk-ant-...
## API Reference
-### `anthropic(config)`
+### `anthropicText(config?)`
+
+Creates an Anthropic text/chat adapter using environment variables.
+
+**Returns:** An Anthropic text adapter instance.
+
+### `createAnthropicText(apiKey, config?)`
+
+Creates an Anthropic text/chat adapter with an explicit API key.
+
+**Parameters:**
+
+- `apiKey` - Your Anthropic API key
+- `config.baseURL?` - Custom base URL (optional)
+
+**Returns:** An Anthropic text adapter instance.
+
+### `anthropicSummarize(config?)`
+
+Creates an Anthropic summarization adapter using environment variables.
-Creates an Anthropic adapter instance.
+**Returns:** An Anthropic summarize adapter instance.
+
+### `createAnthropicSummarize(apiKey, config?)`
+
+Creates an Anthropic summarization adapter with an explicit API key.
**Parameters:**
-- `config.apiKey` - Anthropic API key (required)
+- `apiKey` - Your Anthropic API key
+- `config.baseURL?` - Custom base URL (optional)
+
+**Returns:** An Anthropic summarize adapter instance.
+
+## Limitations
-**Returns:** An Anthropic adapter instance.
+- **Embeddings**: Anthropic does not support embeddings natively. Use OpenAI or Gemini for embedding needs.
+- **Image Generation**: Anthropic does not support image generation. Use OpenAI or Gemini for image generation.
## Next Steps
diff --git a/docs/adapters/gemini.md b/docs/adapters/gemini.md
index 6dbb14cc..e8a938a6 100644
--- a/docs/adapters/gemini.md
+++ b/docs/adapters/gemini.md
@@ -3,7 +3,7 @@ title: Gemini Adapter
id: gemini-adapter
---
-The Google Gemini adapter provides access to Google's Gemini models, including Gemini Pro and Gemini Ultra.
+The Google Gemini adapter provides access to Google's Gemini models, including text generation, embeddings, image generation with Imagen, and experimental text-to-speech.
## Installation
@@ -14,68 +14,86 @@ npm install @tanstack/ai-gemini
## Basic Usage
```typescript
-import { chat } from "@tanstack/ai";
-import { gemini } from "@tanstack/ai-gemini";
+import { ai } from "@tanstack/ai";
+import { geminiText } from "@tanstack/ai-gemini";
-const adapter = gemini();
+const adapter = geminiText();
-const stream = chat({
+const stream = ai({
adapter,
messages: [{ role: "user", content: "Hello!" }],
- model: "gemini-2.5-pro",
+ model: "gemini-2.0-flash-exp",
});
```
## Basic Usage - Custom API Key
```typescript
-import { chat } from "@tanstack/ai";
-import { createGemini } from "@tanstack/ai-gemini";
-const adapter = createGemini(process.env.GEMINI_API_KEY, {
+import { ai } from "@tanstack/ai";
+import { createGeminiText } from "@tanstack/ai-gemini";
+
+const adapter = createGeminiText(process.env.GEMINI_API_KEY!, {
// ... your config options
- });
-const stream = chat({
+});
+
+const stream = ai({
adapter,
messages: [{ role: "user", content: "Hello!" }],
- model: "gemini-2.5-pro",
+ model: "gemini-2.0-flash-exp",
});
```
## Configuration
```typescript
-import { gemini, type GeminiConfig } from "@tanstack/ai-gemini";
+import { createGeminiText, type GeminiTextConfig } from "@tanstack/ai-gemini";
-const config: GeminiConfig = {
- baseURL: "https://generativelanguage.googleapis.com/v1", // Optional
+const config: GeminiTextConfig = {
+ baseURL: "https://generativelanguage.googleapis.com/v1beta", // Optional
};
-const adapter = gemini(config);
+const adapter = createGeminiText(process.env.GEMINI_API_KEY!, config);
```
## Available Models
### Chat Models
-- `gemini-2.5-pro` - Gemini Pro model
-- `gemini-2.5-pro-vision` - Gemini Pro with vision capabilities
-- `gemini-ultra` - Gemini Ultra model (when available)
+- `gemini-2.0-flash-exp` - Gemini 2.0 Flash (fast, efficient)
+- `gemini-2.0-flash-lite` - Gemini 2.0 Flash Lite (fastest)
+- `gemini-2.5-pro` - Gemini 2.5 Pro (most capable)
+- `gemini-2.5-flash` - Gemini 2.5 Flash
+- `gemini-exp-1206` - Experimental Pro model
+
+### Embedding Models
+
+- `gemini-embedding-001` - Text embedding model
+- `text-embedding-004` - Latest embedding model
+
+### Image Generation Models
+
+- `imagen-3.0-generate-002` - Imagen 3.0
+- `gemini-2.0-flash-preview-image-generation` - Gemini with image generation
+
+### Text-to-Speech Models (Experimental)
+
+- `gemini-2.5-flash-preview-tts` - Gemini TTS
## Example: Chat Completion
```typescript
-import { chat, toStreamResponse } from "@tanstack/ai";
-import { gemini } from "@tanstack/ai-gemini";
+import { ai, toStreamResponse } from "@tanstack/ai";
+import { geminiText } from "@tanstack/ai-gemini";
-const adapter = gemini();
+const adapter = geminiText();
export async function POST(request: Request) {
const { messages } = await request.json();
- const stream = chat({
+ const stream = ai({
adapter,
messages,
- model: "gemini-2.5-pro",
+ model: "gemini-2.0-flash-exp",
});
return toStreamResponse(stream);
@@ -85,15 +103,15 @@ export async function POST(request: Request) {
## Example: With Tools
```typescript
-import { chat, toolDefinition } from "@tanstack/ai";
-import { gemini } from "@tanstack/ai-gemini";
+import { ai, toolDefinition } from "@tanstack/ai";
+import { geminiText } from "@tanstack/ai-gemini";
import { z } from "zod";
-const adapter = gemini();
+const adapter = geminiText();
const getCalendarEventsDef = toolDefinition({
name: "get_calendar_events",
- description: "Get calendar events",
+ description: "Get calendar events for a date",
inputSchema: z.object({
date: z.string(),
}),
@@ -101,13 +119,13 @@ const getCalendarEventsDef = toolDefinition({
const getCalendarEvents = getCalendarEventsDef.server(async ({ date }) => {
// Fetch calendar events
- return { events: [...] };
+ return { events: [] };
});
-const stream = chat({
+const stream = ai({
adapter,
messages,
- model: "gemini-2.5-pro",
+ model: "gemini-2.0-flash-exp",
tools: [getCalendarEvents],
});
```
@@ -117,43 +135,247 @@ const stream = chat({
Gemini supports various provider-specific options:
```typescript
-const stream = chat({
- adapter: gemini(),
+const stream = ai({
+ adapter: geminiText(),
messages,
- model: "gemini-2.5-pro",
- providerOptions: {
- maxOutputTokens: 1000,
+ model: "gemini-2.0-flash-exp",
+ providerOptions: {
+ maxOutputTokens: 2048,
+ temperature: 0.7,
+ topP: 0.9,
topK: 40,
+ stopSequences: ["END"],
+ },
+});
+```
+
+### Thinking
+
+Enable thinking for models that support it:
+
+```typescript
+providerOptions: {
+ thinking: {
+ includeThoughts: true,
+ },
+}
+```
+
+### Structured Output
+
+Configure structured output format:
+
+```typescript
+providerOptions: {
+ responseMimeType: "application/json",
+}
+```
+
+## Embeddings
+
+Generate text embeddings for semantic search and similarity:
+
+```typescript
+import { ai } from "@tanstack/ai";
+import { geminiEmbed } from "@tanstack/ai-gemini";
+
+const adapter = geminiEmbed();
+
+const result = await ai({
+ adapter,
+ model: "gemini-embedding-001",
+ input: "The quick brown fox jumps over the lazy dog",
+});
+
+console.log(result.embeddings);
+```
+
+### Batch Embeddings
+
+```typescript
+const result = await ai({
+ adapter: geminiEmbed(),
+ model: "gemini-embedding-001",
+ input: [
+ "First text to embed",
+ "Second text to embed",
+ "Third text to embed",
+ ],
+});
+```
+
+### Embedding Provider Options
+
+```typescript
+const result = await ai({
+ adapter: geminiEmbed(),
+ model: "gemini-embedding-001",
+ input: "...",
+ providerOptions: {
+ taskType: "RETRIEVAL_DOCUMENT", // or "RETRIEVAL_QUERY", "SEMANTIC_SIMILARITY", etc.
},
});
```
+## Summarization
+
+Summarize long text content:
+
+```typescript
+import { ai } from "@tanstack/ai";
+import { geminiSummarize } from "@tanstack/ai-gemini";
+
+const adapter = geminiSummarize();
+
+const result = await ai({
+ adapter,
+ model: "gemini-2.0-flash-exp",
+ text: "Your long text to summarize...",
+ maxLength: 100,
+ style: "concise", // "concise" | "bullet-points" | "paragraph"
+});
+
+console.log(result.summary);
+```
+
+## Image Generation
+
+Generate images with Imagen:
+
+```typescript
+import { ai } from "@tanstack/ai";
+import { geminiImage } from "@tanstack/ai-gemini";
+
+const adapter = geminiImage();
+
+const result = await ai({
+ adapter,
+ model: "imagen-3.0-generate-002",
+ prompt: "A futuristic cityscape at sunset",
+ numberOfImages: 1,
+});
+
+console.log(result.images);
+```
+
+### Image Provider Options
+
+```typescript
+const result = await ai({
+ adapter: geminiImage(),
+ model: "imagen-3.0-generate-002",
+ prompt: "...",
+ providerOptions: {
+ aspectRatio: "16:9", // "1:1" | "3:4" | "4:3" | "9:16" | "16:9"
+ personGeneration: "DONT_ALLOW", // Control person generation
+ safetyFilterLevel: "BLOCK_SOME", // Safety filtering
+ },
+});
+```
+
+## Text-to-Speech (Experimental)
+
+> **Note:** Gemini TTS is experimental and may require the Live API for full functionality.
+
+Generate speech from text:
+
+```typescript
+import { ai } from "@tanstack/ai";
+import { geminiTTS } from "@tanstack/ai-gemini";
+
+const adapter = geminiTTS();
+
+const result = await ai({
+ adapter,
+ model: "gemini-2.5-flash-preview-tts",
+ text: "Hello from Gemini TTS!",
+});
+
+console.log(result.audio); // Base64 encoded audio
+```
+
## Environment Variables
Set your API key in environment variables:
```bash
GEMINI_API_KEY=your-api-key-here
+# or
+GOOGLE_API_KEY=your-api-key-here
```
## Getting an API Key
-1. Go to [Google AI Studio](https://makersuite.google.com/app/apikey)
+1. Go to [Google AI Studio](https://aistudio.google.com/apikey)
2. Create a new API key
3. Add it to your environment variables
## API Reference
-### `gemini(config)`
+### `geminiText(config?)`
+
+Creates a Gemini text/chat adapter using environment variables.
-Creates a Gemini adapter instance.
+**Returns:** A Gemini text adapter instance.
+
+### `createGeminiText(apiKey, config?)`
+
+Creates a Gemini text/chat adapter with an explicit API key.
**Parameters:**
-- `config.apiKey` - Gemini API key (required)
+- `apiKey` - Your Gemini API key
- `config.baseURL?` - Custom base URL (optional)
-**Returns:** A Gemini adapter instance.
+**Returns:** A Gemini text adapter instance.
+
+### `geminiEmbed(config?)`
+
+Creates a Gemini embedding adapter using environment variables.
+
+**Returns:** A Gemini embed adapter instance.
+
+### `createGeminiEmbed(apiKey, config?)`
+
+Creates a Gemini embedding adapter with an explicit API key.
+
+**Returns:** A Gemini embed adapter instance.
+
+### `geminiSummarize(config?)`
+
+Creates a Gemini summarization adapter using environment variables.
+
+**Returns:** A Gemini summarize adapter instance.
+
+### `createGeminiSummarize(apiKey, config?)`
+
+Creates a Gemini summarization adapter with an explicit API key.
+
+**Returns:** A Gemini summarize adapter instance.
+
+### `geminiImage(config?)`
+
+Creates a Gemini image generation adapter using environment variables.
+
+**Returns:** A Gemini image adapter instance.
+
+### `createGeminiImage(apiKey, config?)`
+
+Creates a Gemini image generation adapter with an explicit API key.
+
+**Returns:** A Gemini image adapter instance.
+
+### `geminiTTS(config?)`
+
+Creates a Gemini TTS adapter using environment variables.
+
+**Returns:** A Gemini TTS adapter instance.
+
+### `createGeminiTTS(apiKey, config?)`
+
+Creates a Gemini TTS adapter with an explicit API key.
+
+**Returns:** A Gemini TTS adapter instance.
## Next Steps
diff --git a/docs/adapters/ollama.md b/docs/adapters/ollama.md
index 9fbf65bd..fb9e20ab 100644
--- a/docs/adapters/ollama.md
+++ b/docs/adapters/ollama.md
@@ -3,7 +3,7 @@ title: Ollama Adapter
id: ollama-adapter
---
-The Ollama adapter provides access to local models running via Ollama, allowing you to run AI models on your own infrastructure.
+The Ollama adapter provides access to local models running via Ollama, allowing you to run AI models on your own infrastructure with full privacy and no API costs.
## Installation
@@ -14,14 +14,27 @@ npm install @tanstack/ai-ollama
## Basic Usage
```typescript
-import { chat } from "@tanstack/ai";
-import { ollama } from "@tanstack/ai-ollama";
+import { ai } from "@tanstack/ai";
+import { ollamaText } from "@tanstack/ai-ollama";
-const adapter = ollama({
- baseURL: "http://localhost:11434", // Default Ollama URL
+const adapter = ollamaText();
+
+const stream = ai({
+ adapter,
+ messages: [{ role: "user", content: "Hello!" }],
+ model: "llama3",
});
+```
+
+## Basic Usage - Custom Host
-const stream = chat({
+```typescript
+import { ai } from "@tanstack/ai";
+import { createOllamaText } from "@tanstack/ai-ollama";
+
+const adapter = createOllamaText("http://your-server:11434");
+
+const stream = ai({
adapter,
messages: [{ role: "user", content: "Hello!" }],
model: "llama3",
@@ -31,41 +44,49 @@ const stream = chat({
## Configuration
```typescript
-import { ollama, type OllamaConfig } from "@tanstack/ai-ollama";
+import { createOllamaText } from "@tanstack/ai-ollama";
-const config: OllamaConfig = {
- baseURL: "http://localhost:11434", // Ollama server URL
- // No API key needed for local Ollama
-};
+// Default localhost
+const adapter = createOllamaText();
-const adapter = ollama(config);
+// Custom host
+const adapter = createOllamaText("http://your-server:11434");
```
-## Available Models
+## Available Models
-To see available models, run:
+To see available models on your Ollama instance:
```bash
ollama list
```
+### Popular Models
+
+- `llama3` / `llama3.1` / `llama3.2` - Meta's Llama models
+- `mistral` / `mistral:7b` - Mistral AI models
+- `mixtral` - Mixtral MoE model
+- `codellama` - Code-focused Llama
+- `phi3` - Microsoft's Phi models
+- `gemma` / `gemma2` - Google's Gemma models
+- `qwen2` / `qwen2.5` - Alibaba's Qwen models
+- `deepseek-coder` - DeepSeek coding model
+
## Example: Chat Completion
```typescript
-import { chat, toStreamResponse } from "@tanstack/ai";
-import { ollama } from "@tanstack/ai-ollama";
+import { ai, toStreamResponse } from "@tanstack/ai";
+import { ollamaText } from "@tanstack/ai-ollama";
-const adapter = ollama({
- baseURL: "http://localhost:11434",
-});
+const adapter = ollamaText();
export async function POST(request: Request) {
const { messages } = await request.json();
- const stream = chat({
+ const stream = ai({
adapter,
messages,
- model: "llama3", // Use a model you have installed
+ model: "llama3",
});
return toStreamResponse(stream);
@@ -75,13 +96,11 @@ export async function POST(request: Request) {
## Example: With Tools
```typescript
-import { chat, toolDefinition } from "@tanstack/ai";
-import { ollama } from "@tanstack/ai-ollama";
+import { ai, toolDefinition } from "@tanstack/ai";
+import { ollamaText } from "@tanstack/ai-ollama";
import { z } from "zod";
-const adapter = ollama({
- baseURL: "http://localhost:11434",
-});
+const adapter = ollamaText();
const getLocalDataDef = toolDefinition({
name: "get_local_data",
@@ -96,7 +115,7 @@ const getLocalData = getLocalDataDef.server(async ({ key }) => {
return { data: "..." };
});
-const stream = chat({
+const stream = ai({
adapter,
messages,
model: "llama3",
@@ -104,78 +123,235 @@ const stream = chat({
});
```
-## Setting Up Ollama
-
-1. **Install Ollama:**
-
- ```bash
- # macOS
- brew install ollama
-
- # Linux
- curl -fsSL https://ollama.com/install.sh | sh
-
- # Windows
- # Download from https://ollama.com
- ```
-
-2. **Pull a model:**
-
- ```bash
- ollama pull llama3
- ```
-
-3. **Start Ollama server:**
- ```bash
- ollama serve
- ```
+**Note:** Tool support varies by model. Models like `llama3`, `mistral`, and `qwen2` generally have good tool calling support.
## Provider Options
Ollama supports various provider-specific options:
```typescript
-const stream = chat({
- adapter: ollama({ baseURL: "http://localhost:11434" }),
+const stream = ai({
+ adapter: ollamaText(),
messages,
model: "llama3",
providerOptions: {
temperature: 0.7,
- numPredict: 1000,
- topP: 0.9,
- topK: 40,
+ top_p: 0.9,
+ top_k: 40,
+ num_predict: 1000, // Max tokens to generate
+ repeat_penalty: 1.1,
+ num_ctx: 4096, // Context window size
+ num_gpu: -1, // GPU layers (-1 = auto)
},
});
```
-## Custom Ollama Server
+### Advanced Options
+
+```typescript
+providerOptions: {
+ // Sampling
+ temperature: 0.7,
+ top_p: 0.9,
+ top_k: 40,
+ min_p: 0.05,
+ typical_p: 1.0,
+
+ // Generation
+ num_predict: 1000,
+ repeat_penalty: 1.1,
+ repeat_last_n: 64,
+ penalize_newline: false,
+
+ // Performance
+ num_ctx: 4096,
+ num_batch: 512,
+ num_gpu: -1,
+ num_thread: 0, // 0 = auto
+
+ // Memory
+ use_mmap: true,
+ use_mlock: false,
+
+ // Mirostat sampling
+ mirostat: 0, // 0 = disabled, 1 = Mirostat, 2 = Mirostat 2.0
+ mirostat_tau: 5.0,
+ mirostat_eta: 0.1,
+}
+```
+
+## Embeddings
-If you're running Ollama on a different host or port:
+Generate text embeddings locally:
```typescript
-const adapter = ollama({
- baseURL: "http://your-server:11434",
+import { ai } from "@tanstack/ai";
+import { ollamaEmbed } from "@tanstack/ai-ollama";
+
+const adapter = ollamaEmbed();
+
+const result = await ai({
+ adapter,
+ model: "nomic-embed-text", // or "mxbai-embed-large"
+ input: "The quick brown fox jumps over the lazy dog",
});
+
+console.log(result.embeddings);
+```
+
+### Embedding Models
+
+First, pull an embedding model:
+
+```bash
+ollama pull nomic-embed-text
+# or
+ollama pull mxbai-embed-large
+```
+
+### Batch Embeddings
+
+```typescript
+const result = await ai({
+ adapter: ollamaEmbed(),
+ model: "nomic-embed-text",
+ input: [
+ "First text to embed",
+ "Second text to embed",
+ "Third text to embed",
+ ],
+});
+```
+
+## Summarization
+
+Summarize long text content locally:
+
+```typescript
+import { ai } from "@tanstack/ai";
+import { ollamaSummarize } from "@tanstack/ai-ollama";
+
+const adapter = ollamaSummarize();
+
+const result = await ai({
+ adapter,
+ model: "llama3",
+ text: "Your long text to summarize...",
+ maxLength: 100,
+ style: "concise", // "concise" | "bullet-points" | "paragraph"
+});
+
+console.log(result.summary);
+```
+
+## Setting Up Ollama
+
+### 1. Install Ollama
+
+```bash
+# macOS
+brew install ollama
+
+# Linux
+curl -fsSL https://ollama.com/install.sh | sh
+
+# Windows
+# Download from https://ollama.com
+```
+
+### 2. Pull a Model
+
+```bash
+ollama pull llama3
+```
+
+### 3. Start Ollama Server
+
+```bash
+ollama serve
+```
+
+The server runs on `http://localhost:11434` by default.
+
+## Running on a Remote Server
+
+```typescript
+const adapter = createOllamaText("http://your-server:11434");
+```
+
+To expose Ollama on a network interface:
+
+```bash
+OLLAMA_HOST=0.0.0.0:11434 ollama serve
+```
+
+## Environment Variables
+
+Optionally set the host in environment variables:
+
+```bash
+OLLAMA_HOST=http://localhost:11434
```
## API Reference
-### `ollama(config)`
+### `ollamaText(options?)`
-Creates an Ollama adapter instance.
+Creates an Ollama text/chat adapter.
**Parameters:**
-- `config.baseURL` - Ollama server URL (default: `http://localhost:11434`)
+- `options.model?` - Default model (optional)
+
+**Returns:** An Ollama text adapter instance.
+
+### `createOllamaText(host?, options?)`
+
+Creates an Ollama text/chat adapter with a custom host.
+
+**Parameters:**
+
+- `host` - Ollama server URL (default: `http://localhost:11434`)
+- `options.model?` - Default model (optional)
+
+**Returns:** An Ollama text adapter instance.
+
+### `ollamaEmbed(options?)`
-**Returns:** An Ollama adapter instance.
+Creates an Ollama embedding adapter.
+
+**Returns:** An Ollama embed adapter instance.
+
+### `createOllamaEmbed(host?, options?)`
+
+Creates an Ollama embedding adapter with a custom host.
+
+**Returns:** An Ollama embed adapter instance.
+
+### `ollamaSummarize(options?)`
+
+Creates an Ollama summarization adapter.
+
+**Returns:** An Ollama summarize adapter instance.
+
+### `createOllamaSummarize(host?, options?)`
+
+Creates an Ollama summarization adapter with a custom host.
+
+**Returns:** An Ollama summarize adapter instance.
## Benefits of Ollama
- ✅ **Privacy** - Data stays on your infrastructure
-- ✅ **Cost** - No API costs
+- ✅ **Cost** - No API costs after hardware
- ✅ **Customization** - Use any compatible model
- ✅ **Offline** - Works without internet
+- ✅ **Speed** - No network latency for local deployment
+
+## Limitations
+
+- **Image Generation**: Ollama does not support image generation. Use OpenAI or Gemini for image generation.
+- **Performance**: Depends on your hardware (GPU recommended for larger models)
## Next Steps
diff --git a/docs/adapters/openai.md b/docs/adapters/openai.md
index 1d1392b0..7c4bf4d9 100644
--- a/docs/adapters/openai.md
+++ b/docs/adapters/openai.md
@@ -3,7 +3,7 @@ title: OpenAI Adapter
id: openai-adapter
---
-The OpenAI adapter provides access to OpenAI's GPT models, including GPT-4, GPT-3.5, and more.
+The OpenAI adapter provides access to OpenAI's models, including GPT-4o, GPT-5, embeddings, image generation (DALL-E), text-to-speech (TTS), and audio transcription (Whisper).
## Installation
@@ -14,12 +14,12 @@ npm install @tanstack/ai-openai
## Basic Usage
```typescript
-import { chat } from "@tanstack/ai";
-import { openai } from "@tanstack/ai-openai";
+import { ai } from "@tanstack/ai";
+import { openaiText } from "@tanstack/ai-openai";
-const adapter = openai();
+const adapter = openaiText();
-const stream = chat({
+const stream = ai({
adapter,
messages: [{ role: "user", content: "Hello!" }],
model: "gpt-4o",
@@ -29,12 +29,14 @@ const stream = chat({
## Basic Usage - Custom API Key
```typescript
-import { chat } from "@tanstack/ai";
-import { createOpenAI } from "@tanstack/ai-openai";
-const adapter = createOpenAI(process.env.OPENAI_API_KEY!, {
+import { ai } from "@tanstack/ai";
+import { createOpenaiText } from "@tanstack/ai-openai";
+
+const adapter = createOpenaiText(process.env.OPENAI_API_KEY!, {
// ... your config options
- });
-const stream = chat({
+});
+
+const stream = ai({
adapter,
messages: [{ role: "user", content: "Hello!" }],
model: "gpt-4o",
@@ -44,28 +46,61 @@ const stream = chat({
## Configuration
```typescript
-import { openai, type OpenAIConfig } from "@tanstack/ai-openai";
+import { createOpenaiText, type OpenAITextConfig } from "@tanstack/ai-openai";
-const config: OpenAIConfig = {
+const config: OpenAITextConfig = {
organization: "org-...", // Optional
baseURL: "https://api.openai.com/v1", // Optional, for custom endpoints
};
-const adapter = openai(config);
+const adapter = createOpenaiText(process.env.OPENAI_API_KEY!, config);
```
-
+
+## Available Models
+
+### Chat Models
+
+- `gpt-4o` - GPT-4o (recommended)
+- `gpt-4o-mini` - GPT-4o Mini (faster, cheaper)
+- `gpt-5` - GPT-5 (with reasoning support)
+- `o3` - O3 reasoning model
+- `o3-mini` - O3 Mini
+
+### Embedding Models
+
+- `text-embedding-3-small` - Small embedding model
+- `text-embedding-3-large` - Large embedding model
+- `text-embedding-ada-002` - Legacy embedding model
+
+### Image Models
+
+- `gpt-image-1` - Latest image generation model
+- `dall-e-3` - DALL-E 3
+
+### Text-to-Speech Models
+
+- `tts-1` - Standard TTS (fast)
+- `tts-1-hd` - High-definition TTS
+- `gpt-4o-audio-preview` - GPT-4o with audio output
+
+### Transcription Models
+
+- `whisper-1` - Whisper large-v2
+- `gpt-4o-transcribe` - GPT-4o transcription
+- `gpt-4o-mini-transcribe` - GPT-4o Mini transcription
+
## Example: Chat Completion
```typescript
-import { chat, toStreamResponse } from "@tanstack/ai";
-import { openai } from "@tanstack/ai-openai";
+import { ai, toStreamResponse } from "@tanstack/ai";
+import { openaiText } from "@tanstack/ai-openai";
-const adapter = openai();
+const adapter = openaiText();
export async function POST(request: Request) {
const { messages } = await request.json();
- const stream = chat({
+ const stream = ai({
adapter,
messages,
model: "gpt-4o",
@@ -78,11 +113,11 @@ export async function POST(request: Request) {
## Example: With Tools
```typescript
-import { chat, toolDefinition } from "@tanstack/ai";
-import { openai } from "@tanstack/ai-openai";
+import { ai, toolDefinition } from "@tanstack/ai";
+import { openaiText } from "@tanstack/ai-openai";
import { z } from "zod";
-const adapter = openai();
+const adapter = openaiText();
const getWeatherDef = toolDefinition({
name: "get_weather",
@@ -97,7 +132,7 @@ const getWeather = getWeatherDef.server(async ({ location }) => {
return { temperature: 72, conditions: "sunny" };
});
-const stream = chat({
+const stream = ai({
adapter,
messages,
model: "gpt-4o",
@@ -110,23 +145,24 @@ const stream = chat({
OpenAI supports various provider-specific options:
```typescript
-const stream = chat({
- adapter: openai(),
+const stream = ai({
+ adapter: openaiText(),
messages,
model: "gpt-4o",
providerOptions: {
temperature: 0.7,
- maxTokens: 1000,
- topP: 0.9,
- frequencyPenalty: 0.5,
- presencePenalty: 0.5,
+ max_tokens: 1000,
+ top_p: 0.9,
+ frequency_penalty: 0.5,
+ presence_penalty: 0.5,
+ stop: ["END"],
},
});
```
### Reasoning
-Enable reasoning for models that support it (e.g., GPT-5). This allows the model to show its reasoning process, which is streamed as `thinking` chunks:
+Enable reasoning for models that support it (e.g., GPT-5, O3). This allows the model to show its reasoning process, which is streamed as `thinking` chunks:
```typescript
providerOptions: {
@@ -136,10 +172,190 @@ providerOptions: {
},
}
```
-
When reasoning is enabled, the model's reasoning process is streamed separately from the response text and appears as a collapsible thinking section in the UI.
+## Embeddings
+
+Generate text embeddings for semantic search and similarity:
+
+```typescript
+import { ai } from "@tanstack/ai";
+import { openaiEmbed } from "@tanstack/ai-openai";
+
+const adapter = openaiEmbed();
+
+const result = await ai({
+ adapter,
+ model: "text-embedding-3-small",
+ input: "The quick brown fox jumps over the lazy dog",
+});
+
+console.log(result.embeddings); // Array of embedding vectors
+```
+
+### Batch Embeddings
+
+```typescript
+const result = await ai({
+ adapter: openaiEmbed(),
+ model: "text-embedding-3-small",
+ input: [
+ "First text to embed",
+ "Second text to embed",
+ "Third text to embed",
+ ],
+});
+
+// result.embeddings contains an array of vectors
+```
+
+### Embedding Provider Options
+
+```typescript
+const result = await ai({
+ adapter: openaiEmbed(),
+ model: "text-embedding-3-small",
+ input: "...",
+ providerOptions: {
+ dimensions: 512, // Reduce dimensions for smaller storage
+ },
+});
+```
+
+## Summarization
+
+Summarize long text content:
+
+```typescript
+import { ai } from "@tanstack/ai";
+import { openaiSummarize } from "@tanstack/ai-openai";
+
+const adapter = openaiSummarize();
+
+const result = await ai({
+ adapter,
+ model: "gpt-4o-mini",
+ text: "Your long text to summarize...",
+ maxLength: 100,
+ style: "concise", // "concise" | "bullet-points" | "paragraph"
+});
+
+console.log(result.summary);
+```
+
+## Image Generation
+
+Generate images with DALL-E:
+
+```typescript
+import { ai } from "@tanstack/ai";
+import { openaiImage } from "@tanstack/ai-openai";
+
+const adapter = openaiImage();
+
+const result = await ai({
+ adapter,
+ model: "gpt-image-1",
+ prompt: "A futuristic cityscape at sunset",
+ numberOfImages: 1,
+ size: "1024x1024",
+});
+
+console.log(result.images);
+```
+
+### Image Provider Options
+
+```typescript
+const result = await ai({
+ adapter: openaiImage(),
+ model: "gpt-image-1",
+ prompt: "...",
+ providerOptions: {
+ quality: "hd", // "standard" | "hd"
+ style: "natural", // "natural" | "vivid"
+ },
+});
+```
+
+## Text-to-Speech
+
+Generate speech from text:
+
+```typescript
+import { ai } from "@tanstack/ai";
+import { openaiTTS } from "@tanstack/ai-openai";
+
+const adapter = openaiTTS();
+
+const result = await ai({
+ adapter,
+ model: "tts-1",
+ text: "Hello, welcome to TanStack AI!",
+ voice: "alloy",
+ format: "mp3",
+});
+
+// result.audio contains base64-encoded audio
+console.log(result.format); // "mp3"
+```
+
+### TTS Voices
+
+Available voices: `alloy`, `echo`, `fable`, `onyx`, `nova`, `shimmer`, `ash`, `ballad`, `coral`, `sage`, `verse`
+
+### TTS Provider Options
+
+```typescript
+const result = await ai({
+ adapter: openaiTTS(),
+ model: "tts-1-hd",
+ text: "High quality speech",
+ providerOptions: {
+ speed: 1.0, // 0.25 to 4.0
+ },
+});
+```
+
+## Transcription
+
+Transcribe audio to text:
+
+```typescript
+import { ai } from "@tanstack/ai";
+import { openaiTranscription } from "@tanstack/ai-openai";
+
+const adapter = openaiTranscription();
+
+const result = await ai({
+ adapter,
+ model: "whisper-1",
+ audio: audioFile, // File object or base64 string
+ language: "en",
+});
+
+console.log(result.text); // Transcribed text
+```
+
+### Transcription Provider Options
+
+```typescript
+const result = await ai({
+ adapter: openaiTranscription(),
+ model: "whisper-1",
+ audio: audioFile,
+ providerOptions: {
+ response_format: "verbose_json", // Get timestamps
+ temperature: 0,
+ prompt: "Technical terms: API, SDK",
+ },
+});
+
+// Access segments with timestamps
+console.log(result.segments);
+```
+
## Environment Variables
Set your API key in environment variables:
@@ -150,16 +366,83 @@ OPENAI_API_KEY=sk-...
## API Reference
-### `openai(config)`
+### `openaiText(config?)`
-Creates an OpenAI adapter instance.
+Creates an OpenAI text/chat adapter using environment variables.
+
+**Returns:** An OpenAI text adapter instance.
+
+### `createOpenaiText(apiKey, config?)`
+
+Creates an OpenAI text/chat adapter with an explicit API key.
**Parameters:**
-
+
+- `apiKey` - Your OpenAI API key
- `config.organization?` - Organization ID (optional)
- `config.baseURL?` - Custom base URL (optional)
-**Returns:** An OpenAI adapter instance.
+**Returns:** An OpenAI text adapter instance.
+
+### `openaiEmbed(config?)`
+
+Creates an OpenAI embedding adapter using environment variables.
+
+**Returns:** An OpenAI embed adapter instance.
+
+### `createOpenaiEmbed(apiKey, config?)`
+
+Creates an OpenAI embedding adapter with an explicit API key.
+
+**Returns:** An OpenAI embed adapter instance.
+
+### `openaiSummarize(config?)`
+
+Creates an OpenAI summarization adapter using environment variables.
+
+**Returns:** An OpenAI summarize adapter instance.
+
+### `createOpenaiSummarize(apiKey, config?)`
+
+Creates an OpenAI summarization adapter with an explicit API key.
+
+**Returns:** An OpenAI summarize adapter instance.
+
+### `openaiImage(config?)`
+
+Creates an OpenAI image generation adapter using environment variables.
+
+**Returns:** An OpenAI image adapter instance.
+
+### `createOpenaiImage(apiKey, config?)`
+
+Creates an OpenAI image generation adapter with an explicit API key.
+
+**Returns:** An OpenAI image adapter instance.
+
+### `openaiTTS(config?)`
+
+Creates an OpenAI TTS adapter using environment variables.
+
+**Returns:** An OpenAI TTS adapter instance.
+
+### `createOpenaiTTS(apiKey, config?)`
+
+Creates an OpenAI TTS adapter with an explicit API key.
+
+**Returns:** An OpenAI TTS adapter instance.
+
+### `openaiTranscription(config?)`
+
+Creates an OpenAI transcription adapter using environment variables.
+
+**Returns:** An OpenAI transcription adapter instance.
+
+### `createOpenaiTranscription(apiKey, config?)`
+
+Creates an OpenAI transcription adapter with an explicit API key.
+
+**Returns:** An OpenAI transcription adapter instance.
## Next Steps
diff --git a/docs/api/ai.md b/docs/api/ai.md
index 6f9240f4..f84f3db0 100644
--- a/docs/api/ai.md
+++ b/docs/api/ai.md
@@ -11,16 +11,16 @@ The core AI library for TanStack AI.
npm install @tanstack/ai
```
-## `chat(options)`
+## `ai(options)`
Creates a streaming chat response.
```typescript
-import { chat } from "@tanstack/ai";
-import { openai } from "@tanstack/ai-openai";
+import { ai } from "@tanstack/ai";
+import { openaiText } from "@tanstack/ai-openai";
-const stream = chat({
- adapter: openai(),
+const stream = ai({
+ adapter: openaiText(),
messages: [{ role: "user", content: "Hello!" }],
model: "gpt-4o",
tools: [myTool],
@@ -31,7 +31,7 @@ const stream = chat({
### Parameters
-- `adapter` - An AI adapter instance (e.g., `openai()`, `anthropic()`)
+- `adapter` - An AI adapter instance (e.g., `openaiText()`, `anthropicText()`)
- `messages` - Array of chat messages
- `model` - Model identifier (type-safe based on adapter)
- `tools?` - Array of tools for function calling
@@ -49,11 +49,11 @@ An async iterable of `StreamChunk`.
Creates a text summarization.
```typescript
-import { summarize } from "@tanstack/ai";
-import { openai } from "@tanstack/ai-openai";
+import { ai } from "@tanstack/ai";
+import { openaiSummarize } from "@tanstack/ai-openai";
-const result = await summarize({
- adapter: openai(),
+const result = await ai({
+ adapter: openaiSummarize(),
model: "gpt-4o",
text: "Long text to summarize...",
maxLength: 100,
@@ -78,11 +78,11 @@ A `SummarizationResult` with the summary text.
Creates embeddings for text input.
```typescript
-import { embedding } from "@tanstack/ai";
-import { openai } from "@tanstack/ai-openai";
+import { ai } from "@tanstack/ai";
+import { openaiEmbed } from "@tanstack/ai-openai";
-const result = await embedding({
- adapter: openai(),
+const result = await ai({
+ adapter: openaiEmbed(),
model: "text-embedding-3-small",
input: "Text to embed",
});
@@ -124,8 +124,8 @@ const myClientTool = myToolDef.client(async ({ param }) => {
return { result: "..." };
});
-// Use directly in chat() (server-side, no execute)
-chat({
+// Use directly in ai() (server-side, no execute)
+ai({
tools: [myToolDef],
// ...
});
@@ -136,8 +136,8 @@ const myServerTool = myToolDef.server(async ({ param }) => {
return { result: "..." };
});
-// Use directly in chat() (server-side, no execute)
-chat({
+// Use directly in ai() (server-side, no execute)
+ai({
tools: [myServerTool],
// ...
});
@@ -161,11 +161,11 @@ A `ToolDefinition` object with `.server()` and `.client()` methods for creating
Converts a stream to a ReadableStream in Server-Sent Events format.
```typescript
-import { toServerSentEventsStream, chat } from "@tanstack/ai";
-import { openai } from "@tanstack/ai-openai";
+import { ai, toServerSentEventsStream } from "@tanstack/ai";
+import { openaiText } from "@tanstack/ai-openai";
-const stream = chat({
- adapter: openai(),
+const stream = ai({
+ adapter: openaiText(),
messages: [...],
model: "gpt-4o",
});
@@ -189,11 +189,11 @@ A `ReadableStream` in Server-Sent Events format. Each chunk is:
Converts a stream to an HTTP Response with proper SSE headers.
```typescript
-import { toStreamResponse, chat } from "@tanstack/ai";
-import { openai } from "@tanstack/ai-openai";
+import { ai, toStreamResponse } from "@tanstack/ai";
+import { openaiText } from "@tanstack/ai-openai";
-const stream = chat({
- adapter: openai(),
+const stream = ai({
+ adapter: openaiText(),
messages: [...],
model: "gpt-4o",
});
@@ -214,11 +214,11 @@ A `Response` object suitable for HTTP endpoints with SSE headers (`Content-Type:
Creates an agent loop strategy that limits iterations.
```typescript
-import { maxIterations, chat } from "@tanstack/ai";
-import { openai } from "@tanstack/ai-openai";
+import { ai, maxIterations } from "@tanstack/ai";
+import { openaiText } from "@tanstack/ai-openai";
-const stream = chat({
- adapter: openai(),
+const stream = ai({
+ adapter: openaiText(),
messages: [...],
model: "gpt-4o",
agentLoopStrategy: maxIterations(20),
@@ -293,32 +293,94 @@ interface Tool {
## Usage Examples
```typescript
-import { chat, summarize, embedding } from "@tanstack/ai";
-import { openai } from "@tanstack/ai-openai";
+import { ai } from "@tanstack/ai";
+import {
+ openaiText,
+ openaiSummarize,
+ openaiEmbed,
+ openaiImage,
+} from "@tanstack/ai-openai";
+
+// --- Streaming chat
+const stream = ai({
+ adapter: openaiText(),
+ messages: [{ role: "user", content: "Hello!" }],
+ model: "gpt-4o",
+});
-const adapter = openai();
+// --- One-shot chat response
+const response = await ai({
+ adapter: openaiText(),
+ messages: [{ role: "user", content: "What's the capital of France?" }],
+ model: "gpt-4o",
+ oneShot: true, // Resolves with a single, complete response
+});
-// Streaming chat
-const stream = chat({
- adapter,
- messages: [{ role: "user", content: "Hello!" }],
+// --- Structured response
+const parsed = await ai({
+ adapter: openaiText(),
+ messages: [{ role: "user", content: "Summarize this text in JSON with keys 'summary' and 'keywords': ... " }],
model: "gpt-4o",
+ parse: (content) => {
+ // Example: Expecting JSON output from model
+ try {
+ return JSON.parse(content);
+ } catch {
+ return { summary: "", keywords: [] };
+ }
+ },
});
-// Summarization
-const summary = await summarize({
- adapter,
+// --- Structured response with tools
+import { toolDefinition } from "@tanstack/ai";
+const weatherTool = toolDefinition({
+ name: "getWeather",
+ description: "Get the current weather for a city",
+ parameters: {
+ city: { type: "string", description: "City name" },
+ },
+ async execute({ city }) {
+ // Implementation that fetches weather info
+ return { temperature: 72, condition: "Sunny" };
+ },
+});
+
+const toolResult = await ai({
+ adapter: openaiText(),
+ model: "gpt-4o",
+ messages: [
+ { role: "user", content: "What's the weather in Paris?" }
+ ],
+ tools: [weatherTool],
+ parse: (content, toolsOutput) => ({
+ answer: content,
+ weather: toolsOutput.getWeather,
+ }),
+});
+
+// --- Summarization
+const summary = await ai({
+ adapter: openaiSummarize(),
model: "gpt-4o",
text: "Long text to summarize...",
maxLength: 100,
});
-// Embeddings
-const embeddings = await embedding({
- adapter,
+// --- Embeddings
+const embeddings = await ai({
+ adapter: openaiEmbed(),
model: "text-embedding-3-small",
input: "Text to embed",
});
+
+// --- Image generation
+const image = await ai({
+ adapter: openaiImage(),
+ model: "dall-e-3",
+ prompt: "A futuristic city skyline at sunset",
+ n: 1, // number of images
+ size: "1024x1024",
+});
```
## Next Steps
diff --git a/docs/config.json b/docs/config.json
index d75a3b7b..d8a12298 100644
--- a/docs/config.json
+++ b/docs/config.json
@@ -69,6 +69,14 @@
{
"label": "Per-Model Type Safety",
"to": "guides/per-model-type-safety"
+ },
+ {
+ "label": "Text-to-Speech",
+ "to": "guides/text-to-speech"
+ },
+ {
+ "label": "Transcription",
+ "to": "guides/transcription"
}
]
},
@@ -163,12 +171,12 @@
"defaultCollapsed": true,
"children": [
{
- "label": "chat",
- "to": "reference/functions/chat"
+ "label": "text",
+ "to": "reference/functions/text"
},
{
- "label": "chatOptions",
- "to": "reference/functions/chatOptions"
+ "label": "textOptions",
+ "to": "reference/functions/textOptions"
},
{
"label": "combineStrategies",
@@ -274,12 +282,12 @@
"to": "reference/interfaces/BaseStreamChunk"
},
{
- "label": "ChatCompletionChunk",
- "to": "reference/interfaces/ChatCompletionChunk"
+ "label": "TextCompletionChunk",
+ "to": "reference/interfaces/TextCompletionChunk"
},
{
- "label": "ChatOptions",
- "to": "reference/interfaces/ChatOptions"
+ "label": "TextOptions",
+ "to": "reference/interfaces/TextOptions"
},
{
"label": "ChunkRecording",
@@ -457,12 +465,12 @@
"to": "reference/type-aliases/AnyClientTool"
},
{
- "label": "ChatStreamOptionsForModel",
- "to": "reference/type-aliases/ChatStreamOptionsForModel"
+ "label": "TextStreamOptionsForModel",
+ "to": "reference/type-aliases/TextStreamOptionsForModel"
},
{
- "label": "ChatStreamOptionsUnion",
- "to": "reference/type-aliases/ChatStreamOptionsUnion"
+ "label": "TextStreamOptionsUnion",
+ "to": "reference/type-aliases/TextStreamOptionsUnion"
},
{
"label": "ConstrainedContent",
diff --git a/docs/getting-started/overview.md b/docs/getting-started/overview.md
index 67395478..c432044e 100644
--- a/docs/getting-started/overview.md
+++ b/docs/getting-started/overview.md
@@ -24,7 +24,7 @@ The framework-agnostic core of TanStack AI provides the building blocks for crea
- **Express** - Node.js server
- **Remix Router v7** - Loaders and actions
-TanStack AI lets you define a tool once and provide environment-specific implementations. Using `toolDefinition()` to declare the tool’s input/output types and the server behavior with `.server()` (or a client implementation with `.client()`). These isomorphic tools can be invoked from the AI runtime regardless of framework.
+TanStack AI lets you define a tool once and provide environment-specific implementations. Using `toolDefinition()` to declare the tool's input/output types and the server behavior with `.server()` (or a client implementation with `.client()`). These isomorphic tools can be invoked from the AI runtime regardless of framework.
```typescript
import { toolDefinition } from '@tanstack/ai'
@@ -42,7 +42,7 @@ const getProducts = getProductsDef.server(async ({ query }) => {
})
// Use in AI chat
-chat({ tools: [getProducts] })
+ai({ tools: [getProducts] })
```
## Core Packages
@@ -94,4 +94,4 @@ With the help of adapters, TanStack AI can connect to various LLM providers. Ava
- [Quick Start Guide](./quick-start) - Get up and running in minutes
- [Tools Guide](../guides/tools) - Learn about the isomorphic tool system
-- [API Reference](../api/ai) - Explore the full API
\ No newline at end of file
+- [API Reference](../api/ai) - Explore the full API
diff --git a/docs/getting-started/quick-start.md b/docs/getting-started/quick-start.md
index ff0dcd8b..1b068121 100644
--- a/docs/getting-started/quick-start.md
+++ b/docs/getting-started/quick-start.md
@@ -22,8 +22,8 @@ First, create an API route that handles chat requests. Here's a simplified examp
```typescript
// app/api/chat/route.ts (Next.js)
// or src/routes/api/chat.ts (TanStack Start)
-import { chat, toStreamResponse } from "@tanstack/ai";
-import { openai } from "@tanstack/ai-openai";
+import { ai, toStreamResponse } from "@tanstack/ai";
+import { openaiText } from "@tanstack/ai-openai";
export async function POST(request: Request) {
// Check for API key
@@ -43,8 +43,8 @@ export async function POST(request: Request) {
try {
// Create a streaming chat response
- const stream = chat({
- adapter: openai(),
+ const stream = ai({
+ adapter: openaiText(),
messages,
model: "gpt-4o",
conversationId
@@ -176,7 +176,7 @@ You now have a working chat application. The `useChat` hook handles:
## Using Tools
-Since TanStack AI is framework-agnostic, you can define and use tools in any environment. Here’s a quick example of defining a tool and using it in a chat:
+Since TanStack AI is framework-agnostic, you can define and use tools in any environment. Here's a quick example of defining a tool and using it in a chat:
```typescript
import { toolDefinition } from '@tanstack/ai'
@@ -190,7 +190,7 @@ const getProducts = getProductsDef.server(async ({ query }) => {
return await db.products.search(query)
})
-chat({ tools: [getProducts] })
+ai({ tools: [getProducts] })
```
## Next Steps
diff --git a/docs/guides/agentic-cycle.md b/docs/guides/agentic-cycle.md
index 2e68c1e7..8d15ddda 100644
--- a/docs/guides/agentic-cycle.md
+++ b/docs/guides/agentic-cycle.md
@@ -121,8 +121,8 @@ const getClothingAdvice = getClothingAdviceDef.server(async ({ temperature, cond
export async function POST(request: Request) {
const { messages } = await request.json();
- const stream = chat({
- adapter: openai(),
+ const stream = ai({
+ adapter: openaiText(),
messages,
model: "gpt-4o",
tools: [getWeather, getClothingAdvice],
@@ -137,4 +137,4 @@ export async function POST(request: Request) {
**Agentic Cycle**:
1. LLM calls `get_weather({city: "San Francisco"})` → Returns `{temp: 62, conditions: "cloudy"}`
2. LLM calls `get_clothing_advice({temperature: 62, conditions: "cloudy"})` → Returns `{recommendation: "Light jacket recommended"}`
-3. LLM generates: "The weather in San Francisco is 62°F and cloudy. I recommend wearing a light jacket."
\ No newline at end of file
+3. LLM generates: "The weather in San Francisco is 62°F and cloudy. I recommend wearing a light jacket."
diff --git a/docs/guides/client-tools.md b/docs/guides/client-tools.md
index a7b19cb5..3011cbb0 100644
--- a/docs/guides/client-tools.md
+++ b/docs/guides/client-tools.md
@@ -93,15 +93,15 @@ To give the LLM access to client tools, pass the tool definitions (not implement
```typescript
// api/chat/route.ts
-import { chat, toServerSentEventsStream } from "@tanstack/ai";
-import { openai } from "@tanstack/ai-openai";
+import { ai, toServerSentEventsStream } from "@tanstack/ai";
+import { openaiText } from "@tanstack/ai-openai";
import { updateUIDef, saveToLocalStorageDef } from "@/tools/definitions";
export async function POST(request: Request) {
const { messages } = await request.json();
- const stream = chat({
- adapter: openai(),
+ const stream = ai({
+ adapter: openaiText(),
messages,
model: "gpt-4o",
tools: [updateUIDef, saveToLocalStorageDef], // Pass definitions
@@ -232,7 +232,7 @@ messages.forEach((message) => {
## Tool States
Client tools go through a small set of observable lifecycle states you can surface in the UI to indicate progress:
-- `awaiting-input` — the model intends to call the tool but arguments haven’t arrived yet.
+- `awaiting-input` — the model intends to call the tool but arguments haven't arrived yet.
- `input-streaming` — the model is streaming the tool arguments (partial input may be available).
- `input-complete` — all arguments have been received and the tool is executing.
- `completed` — the tool finished; part.output contains the result (or error details).
@@ -297,17 +297,17 @@ const addToCartClient = addToCartDef.client((input) => {
});
// Server: Pass definition for client execution
-chat({ tools: [addToCartDef] }); // Client will execute
+ai({ tools: [addToCartDef] }); // Client will execute
// Or pass server implementation for server execution
-chat({ tools: [addToCartServer] }); // Server will execute
+ai({ tools: [addToCartServer] }); // Server will execute
```
## Best Practices
- **Keep client tools simple** - Since client tools run in the browser, avoid heavy computations or large dependencies that could bloat your bundle size.
- **Handle errors gracefully** - Define clear error handling in your tool implementations and return meaningful error messages in your output schema.
-- **Update UI reactively** - Use your framework’s state management (eg. React/Vue/Solid) to update the UI in response to tool executions.
+- **Update UI reactively** - Use your framework's state management (eg. React/Vue/Solid) to update the UI in response to tool executions.
- **Secure sensitive data** - Never store sensitive data (like API keys or personal info) in local storage or expose it via client tools.
- **Provide feedback** - Use tool states to inform users about ongoing operations and results of client tool executions (loading spinners, success messages, error alerts).
- **Type everything** - Leverage TypeScript and Zod schemas for full type safety from tool definitions to implementations to usage.
diff --git a/docs/guides/image-generation.md b/docs/guides/image-generation.md
new file mode 100644
index 00000000..27469144
--- /dev/null
+++ b/docs/guides/image-generation.md
@@ -0,0 +1,233 @@
+# Image Generation
+
+TanStack AI provides support for image generation through dedicated image adapters. This guide covers how to use the image generation functionality with OpenAI and Gemini providers.
+
+## Overview
+
+Image generation is handled by image adapters that follow the same tree-shakeable architecture as other adapters in TanStack AI. The image adapters support:
+
+- **OpenAI**: DALL-E 2, DALL-E 3, GPT-Image-1, and GPT-Image-1-Mini models
+- **Gemini**: Imagen 3 and Imagen 4 models
+
+## Basic Usage
+
+### OpenAI Image Generation
+
+```typescript
+import { ai } from '@tanstack/ai'
+import { openaiImage } from '@tanstack/ai-openai'
+
+// Create an image adapter (uses OPENAI_API_KEY from environment)
+const adapter = openaiImage()
+
+// Generate an image
+const result = await ai({
+ adapter,
+ model: 'dall-e-3',
+ prompt: 'A beautiful sunset over mountains',
+})
+
+console.log(result.images[0].url) // URL to the generated image
+```
+
+### Gemini Image Generation
+
+```typescript
+import { ai } from '@tanstack/ai'
+import { geminiImage } from '@tanstack/ai-gemini'
+
+// Create an image adapter (uses GOOGLE_API_KEY from environment)
+const adapter = geminiImage()
+
+// Generate an image
+const result = await ai({
+ adapter,
+ model: 'imagen-3.0-generate-002',
+ prompt: 'A futuristic cityscape at night',
+})
+
+console.log(result.images[0].b64Json) // Base64 encoded image
+```
+
+## Options
+
+### Common Options
+
+All image adapters support these common options:
+
+| Option | Type | Description |
+|--------|------|-------------|
+| `prompt` | `string` | Text description of the image to generate (required) |
+| `numberOfImages` | `number` | Number of images to generate |
+| `size` | `string` | Size of the generated image in WIDTHxHEIGHT format |
+
+### Size Options
+
+#### OpenAI Models
+
+| Model | Supported Sizes |
+|-------|----------------|
+| `gpt-image-1` | `1024x1024`, `1536x1024`, `1024x1536`, `auto` |
+| `gpt-image-1-mini` | `1024x1024`, `1536x1024`, `1024x1536`, `auto` |
+| `dall-e-3` | `1024x1024`, `1792x1024`, `1024x1792` |
+| `dall-e-2` | `256x256`, `512x512`, `1024x1024` |
+
+#### Gemini Models
+
+Gemini uses aspect ratios internally, but TanStack AI accepts WIDTHxHEIGHT format and converts them:
+
+| Size | Aspect Ratio |
+|------|-------------|
+| `1024x1024` | 1:1 |
+| `1920x1080` | 16:9 |
+| `1080x1920` | 9:16 |
+
+Alternatively, you can specify the aspect ratio directly in provider options:
+
+```typescript
+const result = await ai({
+ adapter,
+ model: 'imagen-4.0-generate-001',
+ prompt: 'A landscape photo',
+ providerOptions: {
+ aspectRatio: '16:9'
+ }
+})
+```
+
+## Provider Options
+
+### OpenAI Provider Options
+
+OpenAI models support model-specific provider options:
+
+#### GPT-Image-1 / GPT-Image-1-Mini
+
+```typescript
+const result = await ai({
+ adapter,
+ model: 'gpt-image-1',
+ prompt: 'A cat wearing a hat',
+ providerOptions: {
+ quality: 'high', // 'high' | 'medium' | 'low' | 'auto'
+ background: 'transparent', // 'transparent' | 'opaque' | 'auto'
+ outputFormat: 'png', // 'png' | 'jpeg' | 'webp'
+ moderation: 'low', // 'low' | 'auto'
+ }
+})
+```
+
+#### DALL-E 3
+
+```typescript
+const result = await ai({
+ adapter,
+ model: 'dall-e-3',
+ prompt: 'A futuristic car',
+ providerOptions: {
+ quality: 'hd', // 'hd' | 'standard'
+ style: 'vivid', // 'vivid' | 'natural'
+ }
+})
+```
+
+### Gemini Provider Options
+
+```typescript
+const result = await ai({
+ adapter,
+ model: 'imagen-4.0-generate-001',
+ prompt: 'A beautiful garden',
+ providerOptions: {
+ aspectRatio: '16:9',
+ personGeneration: 'ALLOW_ADULT', // 'DONT_ALLOW' | 'ALLOW_ADULT' | 'ALLOW_ALL'
+ negativePrompt: 'blurry, low quality',
+ addWatermark: true,
+ outputMimeType: 'image/png', // 'image/png' | 'image/jpeg' | 'image/webp'
+ }
+})
+```
+
+## Response Format
+
+The image generation result includes:
+
+```typescript
+interface ImageGenerationResult {
+ id: string // Unique identifier for this generation
+ model: string // The model used
+ images: GeneratedImage[] // Array of generated images
+ usage?: {
+ inputTokens: number
+ outputTokens: number
+ totalTokens: number
+ }
+}
+
+interface GeneratedImage {
+ b64Json?: string // Base64 encoded image data
+ url?: string // URL to the image (OpenAI only)
+ revisedPrompt?: string // Revised prompt (OpenAI only)
+}
+```
+
+## Model Availability
+
+### OpenAI Models
+
+| Model | Images per Request |
+|-------|-------------------|
+| `gpt-image-1` | 1-10 |
+| `gpt-image-1-mini` | 1-10 |
+| `dall-e-3` | 1 |
+| `dall-e-2` | 1-10 |
+
+### Gemini Models
+
+| Model | Images per Request |
+|-------|-------------------|
+| `imagen-3.0-generate-002` | 1-4 |
+| `imagen-4.0-generate-001` | 1-4 |
+| `imagen-4.0-fast-generate-001` | 1-4 |
+| `imagen-4.0-ultra-generate-001` | 1-4 |
+
+## Error Handling
+
+Image generation can fail for various reasons. The adapters validate inputs before making API calls:
+
+```typescript
+try {
+ const result = await ai({
+ adapter,
+ model: 'dall-e-3',
+ prompt: 'A cat',
+ size: '512x512', // Invalid size for DALL-E 3
+ })
+} catch (error) {
+ console.error(error.message)
+ // "Size "512x512" is not supported by model "dall-e-3".
+ // Supported sizes: 1024x1024, 1792x1024, 1024x1792"
+}
+```
+
+## Environment Variables
+
+The image adapters use the same environment variables as the text adapters:
+
+- **OpenAI**: `OPENAI_API_KEY`
+- **Gemini**: `GOOGLE_API_KEY` or `GEMINI_API_KEY`
+
+## Explicit API Keys
+
+For production use or when you need explicit control:
+
+```typescript
+import { createOpenaiImage } from '@tanstack/ai-openai'
+import { createGeminiImage } from '@tanstack/ai-gemini'
+
+// OpenAI
+const openaiAdapter = createOpenaiImage('your-openai-api-key')
+
+// Gemini
+const geminiAdapter = createGeminiImage('your-google-api-key')
+```
diff --git a/docs/guides/multimodal-content.md b/docs/guides/multimodal-content.md
index 88c8d71a..e645a72b 100644
--- a/docs/guides/multimodal-content.md
+++ b/docs/guides/multimodal-content.md
@@ -53,13 +53,11 @@ const imageUrlPart: ImagePart = {
Messages can have `content` as either a string or an array of `ContentPart`:
```typescript
-import { chat } from '@tanstack/ai'
-import { OpenAI } from '@tanstack/ai-openai'
+import { ai } from '@tanstack/ai'
+import { openaiText } from '@tanstack/ai-openai'
-const openai = new OpenAI({ apiKey: 'your-key' })
-
-const response = await chat({
- adapter: openai,
+const response = await ai({
+ adapter: openaiText(),
model: 'gpt-4o',
messages: [
{
@@ -86,9 +84,9 @@ const response = await chat({
OpenAI supports images and audio in their vision and audio models:
```typescript
-import { OpenAI } from '@tanstack/ai-openai'
+import { openaiText } from '@tanstack/ai-openai'
-const openai = new OpenAI({ apiKey: 'your-key' })
+const adapter = openaiText()
// Image with detail level metadata
const message = {
@@ -113,9 +111,9 @@ const message = {
Anthropic's Claude models support images and PDF documents:
```typescript
-import { Anthropic } from '@tanstack/ai-anthropic'
+import { anthropicText } from '@tanstack/ai-anthropic'
-const anthropic = new Anthropic({ apiKey: 'your-key' })
+const adapter = anthropicText()
// Image with media type
const imageMessage = {
@@ -152,9 +150,9 @@ const docMessage = {
Google's Gemini models support a wide range of modalities:
```typescript
-import { GeminiAdapter } from '@tanstack/ai-gemini'
+import { geminiText } from '@tanstack/ai-gemini'
-const gemini = new GeminiAdapter({ apiKey: 'your-key' })
+const adapter = geminiText()
// Image with mimeType
const message = {
@@ -179,9 +177,9 @@ const message = {
Ollama supports images in compatible models:
```typescript
-import { OllamaAdapter } from '@tanstack/ai-ollama'
+import { ollamaText } from '@tanstack/ai-ollama'
-const ollama = new OllamaAdapter({ host: 'http://localhost:11434' })
+const adapter = ollamaText({ baseURL: 'http://localhost:11434' })
// Image as base64
const message = {
@@ -278,19 +276,19 @@ import type { GeminiMediaMetadata } from '@tanstack/ai-gemini'
When receiving messages from external sources (like `request.json()`), the data is typed as `any`, which can bypass TypeScript's type checking. Use `assertMessages` to restore type safety:
```typescript
-import { assertMessages, chat } from '@tanstack/ai'
-import { openai } from '@tanstack/ai-openai'
+import { ai, assertMessages } from '@tanstack/ai'
+import { openaiText } from '@tanstack/ai-openai'
// In an API route handler
const { messages: incomingMessages } = await request.json()
-const adapter = openai()
+const adapter = openaiText()
// Assert incoming messages are compatible with gpt-4o (text + image only)
const typedMessages = assertMessages({ adapter, model: 'gpt-4o' }, incomingMessages)
// Now TypeScript will properly check any additional messages you add
-const stream = chat({
+const stream = ai({
adapter,
model: 'gpt-4o',
messages: [
diff --git a/docs/guides/per-model-type-safety.md b/docs/guides/per-model-type-safety.md
index ffa91256..9d259bbb 100644
--- a/docs/guides/per-model-type-safety.md
+++ b/docs/guides/per-model-type-safety.md
@@ -12,13 +12,13 @@ The AI SDK provides **model-specific type safety** for `providerOptions`. Each m
### ✅ Correct Usage
```typescript
-import { chat } from "@tanstack/ai";
-import { openai } from "@tanstack/ai-openai";
+import { ai } from "@tanstack/ai";
+import { openaiText } from "@tanstack/ai-openai";
-const adapter = openai();
+const adapter = openaiText();
// ✅ gpt-5 supports structured outputs - `text` is allowed
-const validCall = chat({
+const validCall = ai({
adapter,
model: "gpt-5",
messages: [],
@@ -38,8 +38,8 @@ const validCall = chat({
```typescript
// ❌ gpt-4-turbo does NOT support structured outputs - `text` is rejected
-const invalidCall = chat({
- adapter: openai(),
+const invalidCall = ai({
+ adapter: openaiText(),
model: "gpt-4-turbo",
messages: [],
providerOptions: {
diff --git a/docs/guides/server-tools.md b/docs/guides/server-tools.md
index 0a8e43ea..703f26e5 100644
--- a/docs/guides/server-tools.md
+++ b/docs/guides/server-tools.md
@@ -137,18 +137,18 @@ const searchProducts = searchProductsDef.server(async ({ query, limit = 10 }) =>
## Using Server Tools
-Pass tools to the `chat` method:
+Pass tools to the `ai` function:
```typescript
-import { chat, toStreamResponse } from "@tanstack/ai";
-import { openai } from "@tanstack/ai-openai";
+import { ai, toStreamResponse } from "@tanstack/ai";
+import { openaiText } from "@tanstack/ai-openai";
import { getUserData, searchProducts } from "./tools";
export async function POST(request: Request) {
const { messages } = await request.json();
- const stream = chat({
- adapter: openai(),
+ const stream = ai({
+ adapter: openaiText(),
messages,
model: "gpt-4o",
tools: [getUserData, searchProducts],
@@ -202,12 +202,12 @@ export const searchProducts = searchProductsDef.server(async ({ query }) => {
});
// api/chat/route.ts
-import { chat } from "@tanstack/ai";
-import { openai } from "@tanstack/ai-openai";
+import { ai } from "@tanstack/ai";
+import { openaiText } from "@tanstack/ai-openai";
import { getUserData, searchProducts } from "@/tools/server";
-const stream = chat({
- adapter: openai(),
+const stream = ai({
+ adapter: openaiText(),
messages,
model: "gpt-4o",
tools: [getUserData, searchProducts],
diff --git a/docs/guides/streaming.md b/docs/guides/streaming.md
index da2a806b..abab88e9 100644
--- a/docs/guides/streaming.md
+++ b/docs/guides/streaming.md
@@ -7,14 +7,14 @@ TanStack AI supports streaming responses for real-time chat experiences. Streami
## How Streaming Works
-When you use `chat()`, it returns an async iterable stream of chunks:
+When you use `ai()`, it returns an async iterable stream of chunks:
```typescript
-import { chat } from "@tanstack/ai";
-import { openai } from "@tanstack/ai-openai";
+import { ai } from "@tanstack/ai";
+import { openaiText } from "@tanstack/ai-openai";
-const stream = chat({
- adapter: openai(),
+const stream = ai({
+ adapter: openaiText(),
messages,
model: "gpt-4o",
});
@@ -30,14 +30,14 @@ for await (const chunk of stream) {
Convert the stream to an HTTP response using `toStreamResponse`:
```typescript
-import { chat, toStreamResponse } from "@tanstack/ai";
-import { openai } from "@tanstack/ai-openai";
+import { ai, toStreamResponse } from "@tanstack/ai";
+import { openaiText } from "@tanstack/ai-openai";
export async function POST(request: Request) {
const { messages } = await request.json();
- const stream = chat({
- adapter: openai(),
+ const stream = ai({
+ adapter: openaiText(),
messages,
model: "gpt-4o",
});
diff --git a/docs/guides/text-to-speech.md b/docs/guides/text-to-speech.md
new file mode 100644
index 00000000..5a14deaa
--- /dev/null
+++ b/docs/guides/text-to-speech.md
@@ -0,0 +1,248 @@
+# Text-to-Speech (TTS)
+
+TanStack AI provides support for text-to-speech generation through dedicated TTS adapters. This guide covers how to convert text into spoken audio using OpenAI and Gemini providers.
+
+## Overview
+
+Text-to-speech (TTS) is handled by TTS adapters that follow the same tree-shakeable architecture as other adapters in TanStack AI. The TTS adapters support:
+
+- **OpenAI**: TTS-1, TTS-1-HD, and audio-capable GPT-4o models
+- **Gemini**: Gemini 2.5 Flash TTS (experimental)
+
+## Basic Usage
+
+### OpenAI Text-to-Speech
+
+```typescript
+import { ai } from '@tanstack/ai'
+import { openaiTTS } from '@tanstack/ai-openai'
+
+// Create a TTS adapter (uses OPENAI_API_KEY from environment)
+const adapter = openaiTTS()
+
+// Generate speech from text
+const result = await ai({
+ adapter,
+ model: 'tts-1',
+ text: 'Hello, welcome to TanStack AI!',
+ voice: 'alloy',
+})
+
+// result.audio contains base64-encoded audio data
+console.log(result.format) // 'mp3'
+console.log(result.contentType) // 'audio/mpeg'
+```
+
+### Gemini Text-to-Speech (Experimental)
+
+```typescript
+import { ai } from '@tanstack/ai'
+import { geminiTTS } from '@tanstack/ai-gemini'
+
+// Create a TTS adapter (uses GOOGLE_API_KEY from environment)
+const adapter = geminiTTS()
+
+// Generate speech from text
+const result = await ai({
+ adapter,
+ model: 'gemini-2.5-flash-preview-tts',
+ text: 'Hello from Gemini TTS!',
+})
+
+console.log(result.audio) // Base64 encoded audio
+```
+
+## Options
+
+### Common Options
+
+All TTS adapters support these common options:
+
+| Option | Type | Description |
+|--------|------|-------------|
+| `text` | `string` | The text to convert to speech (required) |
+| `voice` | `string` | The voice to use for generation |
+| `format` | `string` | Output audio format (e.g., "mp3", "wav") |
+
+### OpenAI Voice Options
+
+OpenAI provides several distinct voices:
+
+| Voice | Description |
+|-------|-------------|
+| `alloy` | Neutral, balanced voice |
+| `echo` | Warm, conversational voice |
+| `fable` | Expressive, storytelling voice |
+| `onyx` | Deep, authoritative voice |
+| `nova` | Friendly, upbeat voice |
+| `shimmer` | Clear, gentle voice |
+| `ash` | Calm, measured voice |
+| `ballad` | Melodic, flowing voice |
+| `coral` | Bright, energetic voice |
+| `sage` | Wise, thoughtful voice |
+| `verse` | Poetic, rhythmic voice |
+
+### OpenAI Format Options
+
+| Format | Description |
+|--------|-------------|
+| `mp3` | MP3 audio (default) |
+| `opus` | Opus audio (good for streaming) |
+| `aac` | AAC audio |
+| `flac` | FLAC audio (lossless) |
+| `wav` | WAV audio (uncompressed) |
+| `pcm` | Raw PCM audio |
+
+## Provider Options
+
+### OpenAI Provider Options
+
+```typescript
+const result = await ai({
+ adapter: openaiTTS(),
+ model: 'tts-1-hd',
+ text: 'High quality speech synthesis',
+ voice: 'nova',
+ format: 'mp3',
+ providerOptions: {
+ speed: 1.0, // 0.25 to 4.0
+ },
+})
+```
+
+| Option | Type | Description |
+|--------|------|-------------|
+| `speed` | `number` | Playback speed (0.25 to 4.0, default 1.0) |
+| `instructions` | `string` | Voice style instructions (GPT-4o audio models only) |
+
+> **Note:** The `instructions` and `stream_format` options are only available with `gpt-4o-audio-preview` and `gpt-4o-mini-audio-preview` models, not with `tts-1` or `tts-1-hd`.
+
+## Response Format
+
+The TTS result includes:
+
+```typescript
+interface TTSResult {
+ id: string // Unique identifier for this generation
+ model: string // The model used
+ audio: string // Base64-encoded audio data
+ format: string // Audio format (e.g., "mp3")
+ contentType: string // MIME type (e.g., "audio/mpeg")
+ duration?: number // Duration in seconds (if available)
+}
+```
+
+## Playing Audio in the Browser
+
+```typescript
+// Convert base64 to audio and play
+function playAudio(result: TTSResult) {
+ const audioData = atob(result.audio)
+ const bytes = new Uint8Array(audioData.length)
+ for (let i = 0; i < audioData.length; i++) {
+ bytes[i] = audioData.charCodeAt(i)
+ }
+
+ const blob = new Blob([bytes], { type: result.contentType })
+ const url = URL.createObjectURL(blob)
+
+ const audio = new Audio(url)
+ audio.play()
+
+ // Clean up when done
+ audio.onended = () => URL.revokeObjectURL(url)
+}
+```
+
+## Saving Audio to File (Node.js)
+
+```typescript
+import { writeFile } from 'fs/promises'
+
+async function saveAudio(result: TTSResult, filename: string) {
+ const audioBuffer = Buffer.from(result.audio, 'base64')
+ await writeFile(filename, audioBuffer)
+ console.log(`Saved to ${filename}`)
+}
+
+// Usage
+const result = await ai({
+ adapter: openaiTTS(),
+ model: 'tts-1',
+ text: 'Hello world!',
+})
+
+await saveAudio(result, 'output.mp3')
+```
+
+## Model Availability
+
+### OpenAI Models
+
+| Model | Quality | Speed | Use Case |
+|-------|---------|-------|----------|
+| `tts-1` | Standard | Fast | Real-time applications |
+| `tts-1-hd` | High | Slower | Production audio |
+| `gpt-4o-audio-preview` | Highest | Variable | Advanced voice control |
+| `gpt-4o-mini-audio-preview` | High | Fast | Balanced quality/speed |
+
+### Gemini Models
+
+| Model | Status | Notes |
+|-------|--------|-------|
+| `gemini-2.5-flash-preview-tts` | Experimental | May require Live API for full features |
+
+## Error Handling
+
+```typescript
+try {
+ const result = await ai({
+ adapter: openaiTTS(),
+ model: 'tts-1',
+ text: 'Hello!',
+ })
+} catch (error) {
+ if (error.message.includes('exceeds maximum length')) {
+ console.error('Text is too long (max 4096 characters)')
+ } else if (error.message.includes('Speed must be between')) {
+ console.error('Invalid speed value')
+ } else {
+ console.error('TTS error:', error.message)
+ }
+}
+```
+
+## Environment Variables
+
+The TTS adapters use the same environment variables as other adapters:
+
+- **OpenAI**: `OPENAI_API_KEY`
+- **Gemini**: `GOOGLE_API_KEY` or `GEMINI_API_KEY`
+
+## Explicit API Keys
+
+For production use or when you need explicit control:
+
+```typescript
+import { createOpenaiTTS } from '@tanstack/ai-openai'
+import { createGeminiTTS } from '@tanstack/ai-gemini'
+
+// OpenAI
+const openaiAdapter = createOpenaiTTS('your-openai-api-key')
+
+// Gemini
+const geminiAdapter = createGeminiTTS('your-google-api-key')
+```
+
+## Best Practices
+
+1. **Text Length**: OpenAI TTS supports up to 4096 characters per request. For longer content, split into chunks.
+
+2. **Voice Selection**: Choose voices appropriate for your content—use `onyx` for authoritative content, `nova` for friendly interactions.
+
+3. **Format Selection**: Use `mp3` for general use, `opus` for streaming, `wav` for further processing.
+
+4. **Caching**: Cache generated audio to avoid regenerating the same content.
+
+5. **Error Handling**: Always handle errors gracefully, especially for user-facing applications.
+
diff --git a/docs/guides/tool-approval.md b/docs/guides/tool-approval.md
index 0479a112..bc14e48d 100644
--- a/docs/guides/tool-approval.md
+++ b/docs/guides/tool-approval.md
@@ -56,15 +56,15 @@ const sendEmail = sendEmailDef.server(async ({ to, subject, body }) => {
On the server, tools with `needsApproval: true` will pause execution and wait for approval:
```typescript
-import { chat, toStreamResponse } from "@tanstack/ai";
-import { openai } from "@tanstack/ai-openai";
+import { ai, toStreamResponse } from "@tanstack/ai";
+import { openaiText } from "@tanstack/ai-openai";
import { sendEmail } from "./tools";
export async function POST(request: Request) {
const { messages } = await request.json();
- const stream = chat({
- adapter: openai(),
+ const stream = ai({
+ adapter: openaiText(),
messages,
model: "gpt-4o",
tools: [sendEmail],
diff --git a/docs/guides/tool-architecture.md b/docs/guides/tool-architecture.md
index f691f510..de800320 100644
--- a/docs/guides/tool-architecture.md
+++ b/docs/guides/tool-architecture.md
@@ -68,16 +68,16 @@ sequenceDiagram
**Server (API Route):**
```typescript
-import { chat, toStreamResponse } from "@tanstack/ai";
-import { openai } from "@tanstack/ai-openai";
+import { ai, toStreamResponse } from "@tanstack/ai";
+import { openaiText } from "@tanstack/ai-openai";
import { getWeather, sendEmail } from "./tools";
export async function POST(request: Request) {
const { messages } = await request.json();
// Create streaming chat with tools
- const stream = chat({
- adapter: openai(),
+ const stream = ai({
+ adapter: openaiText(),
messages,
model: "gpt-4o",
tools: [getWeather, sendEmail], // Tool definitions passed here
diff --git a/docs/guides/tools.md b/docs/guides/tools.md
index 9f361696..46226832 100644
--- a/docs/guides/tools.md
+++ b/docs/guides/tools.md
@@ -173,8 +173,8 @@ const getWeatherServer = getWeatherDef.server(async (args) => {
### Server-Side
```typescript
-import { chat, toStreamResponse } from "@tanstack/ai";
-import { openai } from "@tanstack/ai-openai";
+import { ai, toStreamResponse } from "@tanstack/ai";
+import { openaiText } from "@tanstack/ai-openai";
import { getWeatherDef } from "./tools";
export async function POST(request: Request) {
@@ -186,8 +186,8 @@ export async function POST(request: Request) {
return await response.json();
});
- const stream = chat({
- adapter: openai(),
+ const stream = ai({
+ adapter: openaiText(),
messages,
model: "gpt-4o",
tools: [getWeather], // Pass server tools
@@ -223,16 +223,16 @@ const saveToStorage = saveToStorageDef.client((input) => {
// Create typed tools array (no 'as const' needed!)
const tools = clientTools(updateUI, saveToStorage);
-const chatOptions = createChatClientOptions({
+const textOptions = createChatClientOptions({
connection: fetchServerSentEvents("/api/chat"),
tools,
});
// Infer message types for full type safety
-type ChatMessages = InferChatMessages;
+type ChatMessages = InferChatMessages;
function ChatComponent() {
- const { messages, sendMessage } = useChat(chatOptions);
+ const { messages, sendMessage } = useChat(textOptions);
// messages is now fully typed with tool names and outputs!
return ;
@@ -279,8 +279,8 @@ const addToCartClient = addToCartDef.client((input) => {
On the server, pass the definition (for client execution) or server implementation:
```typescript
-chat({
- adapter: openai(),
+ai({
+ adapter: openaiText(),
messages,
tools: [addToCartDef], // Client will execute, or
tools: [addToCartServer], // Server will execute
diff --git a/docs/guides/transcription.md b/docs/guides/transcription.md
new file mode 100644
index 00000000..ff55ae14
--- /dev/null
+++ b/docs/guides/transcription.md
@@ -0,0 +1,337 @@
+# Audio Transcription
+
+TanStack AI provides support for audio transcription (speech-to-text) through dedicated transcription adapters. This guide covers how to convert spoken audio into text using OpenAI's Whisper and GPT-4o transcription models.
+
+## Overview
+
+Audio transcription is handled by transcription adapters that follow the same tree-shakeable architecture as other adapters in TanStack AI.
+
+Currently supported:
+- **OpenAI**: Whisper-1, GPT-4o-transcribe, GPT-4o-mini-transcribe
+
+## Basic Usage
+
+### OpenAI Transcription
+
+```typescript
+import { ai } from '@tanstack/ai'
+import { openaiTranscription } from '@tanstack/ai-openai'
+
+// Create a transcription adapter (uses OPENAI_API_KEY from environment)
+const adapter = openaiTranscription()
+
+// Transcribe audio from a file
+const audioFile = new File([audioBuffer], 'audio.mp3', { type: 'audio/mpeg' })
+
+const result = await ai({
+ adapter,
+ model: 'whisper-1',
+ audio: audioFile,
+ language: 'en',
+})
+
+console.log(result.text) // The transcribed text
+```
+
+### Using Base64 Audio
+
+```typescript
+import { readFile } from 'fs/promises'
+
+// Read audio file as base64
+const audioBuffer = await readFile('recording.mp3')
+const base64Audio = audioBuffer.toString('base64')
+
+const result = await ai({
+ adapter: openaiTranscription(),
+ model: 'whisper-1',
+ audio: base64Audio,
+})
+
+console.log(result.text)
+```
+
+### Using Data URLs
+
+```typescript
+const dataUrl = `data:audio/mpeg;base64,${base64AudioData}`
+
+const result = await ai({
+ adapter: openaiTranscription(),
+ model: 'whisper-1',
+ audio: dataUrl,
+})
+```
+
+## Options
+
+### Common Options
+
+| Option | Type | Description |
+|--------|------|-------------|
+| `audio` | `File \| string` | Audio data (File object or base64 string) - required |
+| `language` | `string` | Language code (e.g., "en", "es", "fr") |
+
+### Supported Languages
+
+Whisper supports many languages. Common codes include:
+
+| Code | Language |
+|------|----------|
+| `en` | English |
+| `es` | Spanish |
+| `fr` | French |
+| `de` | German |
+| `it` | Italian |
+| `pt` | Portuguese |
+| `ja` | Japanese |
+| `ko` | Korean |
+| `zh` | Chinese |
+| `ru` | Russian |
+
+> **Tip:** Providing the correct language code improves accuracy and reduces latency.
+
+## Provider Options
+
+### OpenAI Provider Options
+
+```typescript
+const result = await ai({
+ adapter: openaiTranscription(),
+ model: 'whisper-1',
+ audio: audioFile,
+ providerOptions: {
+ response_format: 'verbose_json', // Get detailed output with timestamps
+ temperature: 0, // Lower = more deterministic
+ prompt: 'Technical terms: API, SDK, CLI', // Guide transcription
+ },
+})
+```
+
+| Option | Type | Description |
+|--------|------|-------------|
+| `response_format` | `string` | Output format: "json", "text", "srt", "verbose_json", "vtt" |
+| `temperature` | `number` | Sampling temperature (0 to 1) |
+| `prompt` | `string` | Optional text to guide transcription style |
+| `include` | `string[]` | Timestamp granularity: ["word"], ["segment"], or both |
+
+### Response Formats
+
+| Format | Description |
+|--------|-------------|
+| `json` | Simple JSON with text |
+| `text` | Plain text only |
+| `srt` | SubRip subtitle format |
+| `verbose_json` | Detailed JSON with timestamps and segments |
+| `vtt` | WebVTT subtitle format |
+
+## Response Format
+
+The transcription result includes:
+
+```typescript
+interface TranscriptionResult {
+ id: string // Unique identifier
+ model: string // Model used
+ text: string // Full transcribed text
+ language?: string // Detected/specified language
+ duration?: number // Audio duration in seconds
+ segments?: Array<{ // Timestamped segments
+ start: number // Start time in seconds
+ end: number // End time in seconds
+ text: string // Segment text
+ words?: Array<{ // Word-level timestamps
+ word: string
+ start: number
+ end: number
+ confidence?: number
+ }>
+ }>
+}
+```
+
+## Complete Example
+
+```typescript
+import { ai } from '@tanstack/ai'
+import { openaiTranscription } from '@tanstack/ai-openai'
+import { readFile } from 'fs/promises'
+
+async function transcribeAudio(filepath: string) {
+ const adapter = openaiTranscription()
+
+ // Read the audio file
+ const audioBuffer = await readFile(filepath)
+ const audioFile = new File(
+ [audioBuffer],
+ filepath.split('/').pop()!,
+ { type: 'audio/mpeg' }
+ )
+
+ // Transcribe with detailed output
+ const result = await ai({
+ adapter,
+ model: 'whisper-1',
+ audio: audioFile,
+ language: 'en',
+ providerOptions: {
+ response_format: 'verbose_json',
+ include: ['segment', 'word'],
+ },
+ })
+
+ console.log('Full text:', result.text)
+ console.log('Duration:', result.duration, 'seconds')
+
+ // Print segments with timestamps
+ if (result.segments) {
+ for (const segment of result.segments) {
+ console.log(`[${segment.start.toFixed(2)}s - ${segment.end.toFixed(2)}s]: ${segment.text}`)
+ }
+ }
+
+ return result
+}
+
+// Usage
+await transcribeAudio('./meeting-recording.mp3')
+```
+
+## Model Availability
+
+### OpenAI Models
+
+| Model | Description | Use Case |
+|-------|-------------|----------|
+| `whisper-1` | Whisper large-v2 | General transcription |
+| `gpt-4o-transcribe` | GPT-4o-based transcription | Higher accuracy |
+| `gpt-4o-transcribe-diarize` | With speaker diarization | Multi-speaker audio |
+| `gpt-4o-mini-transcribe` | Faster, lighter model | Cost-effective |
+
+### Supported Audio Formats
+
+OpenAI supports these audio formats:
+
+- `mp3` - MPEG Audio Layer 3
+- `mp4` - MPEG-4 Audio
+- `mpeg` - MPEG Audio
+- `mpga` - MPEG Audio
+- `m4a` - MPEG-4 Audio
+- `wav` - Waveform Audio
+- `webm` - WebM Audio
+- `flac` - Free Lossless Audio Codec
+- `ogg` - Ogg Vorbis
+
+> **Note:** Maximum file size is 25 MB.
+
+## Browser Usage
+
+### Recording and Transcribing
+
+```typescript
+async function recordAndTranscribe() {
+ // Request microphone access
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
+ const mediaRecorder = new MediaRecorder(stream)
+ const chunks: Blob[] = []
+
+ mediaRecorder.ondataavailable = (e) => chunks.push(e.data)
+
+ mediaRecorder.onstop = async () => {
+ const audioBlob = new Blob(chunks, { type: 'audio/webm' })
+ const audioFile = new File([audioBlob], 'recording.webm', { type: 'audio/webm' })
+
+ // Send to your API endpoint for transcription
+ const formData = new FormData()
+ formData.append('audio', audioFile)
+
+ const response = await fetch('/api/transcribe', {
+ method: 'POST',
+ body: formData,
+ })
+
+ const result = await response.json()
+ console.log('Transcription:', result.text)
+ }
+
+ // Start recording
+ mediaRecorder.start()
+
+ // Stop after 10 seconds
+ setTimeout(() => mediaRecorder.stop(), 10000)
+}
+```
+
+### Server API Endpoint
+
+```typescript
+// api/transcribe.ts
+import { ai } from '@tanstack/ai'
+import { openaiTranscription } from '@tanstack/ai-openai'
+
+export async function POST(request: Request) {
+ const formData = await request.formData()
+ const audioFile = formData.get('audio') as File
+
+ const adapter = openaiTranscription()
+
+ const result = await ai({
+ adapter,
+ model: 'whisper-1',
+ audio: audioFile,
+ })
+
+ return Response.json(result)
+}
+```
+
+## Error Handling
+
+```typescript
+try {
+ const result = await ai({
+ adapter: openaiTranscription(),
+ model: 'whisper-1',
+ audio: audioFile,
+ })
+} catch (error) {
+ if (error.message.includes('Invalid file format')) {
+ console.error('Unsupported audio format')
+ } else if (error.message.includes('File too large')) {
+ console.error('Audio file exceeds 25 MB limit')
+ } else if (error.message.includes('Audio file is too short')) {
+ console.error('Audio must be at least 0.1 seconds')
+ } else {
+ console.error('Transcription error:', error.message)
+ }
+}
+```
+
+## Environment Variables
+
+The transcription adapter uses:
+
+- `OPENAI_API_KEY`: Your OpenAI API key
+
+## Explicit API Keys
+
+```typescript
+import { createOpenaiTranscription } from '@tanstack/ai-openai'
+
+const adapter = createOpenaiTranscription('your-openai-api-key')
+```
+
+## Best Practices
+
+1. **Audio Quality**: Better audio quality leads to more accurate transcriptions. Reduce background noise when possible.
+
+2. **Language Specification**: Always specify the language if known—this improves accuracy and speed.
+
+3. **File Size**: Keep audio files under 25 MB. For longer recordings, split into chunks.
+
+4. **Format Selection**: MP3 offers a good balance of quality and size. Use WAV or FLAC for highest quality.
+
+5. **Prompting**: Use the `prompt` option to provide context or expected vocabulary (e.g., technical terms, names).
+
+6. **Timestamps**: Request `verbose_json` format and enable `include: ['word', 'segment']` when you need timing information for captions or synchronization.
+
diff --git a/docs/guides/tree-shakeable-adapters.md b/docs/guides/tree-shakeable-adapters.md
new file mode 100644
index 00000000..cda03b2f
--- /dev/null
+++ b/docs/guides/tree-shakeable-adapters.md
@@ -0,0 +1,209 @@
+# Tree-Shakeable Adapters
+
+TanStack AI provides tree-shakeable adapters that allow you to import only the functionality you need, resulting in smaller bundle sizes.
+
+## Overview
+
+Instead of importing a monolithic adapter that includes chat, embedding, and summarization capabilities all at once, you can now import only the specific functionality you need:
+
+- **Text Adapters** - For chat and text generation
+- **Embed Adapters** - For creating embeddings
+- **Summarize Adapters** - For text summarization
+
+## Installation
+
+Each provider package (e.g., `@tanstack/ai-openai`, `@tanstack/ai-anthropic`) exports tree-shakeable adapters:
+
+```ts
+// Import only what you need
+import { openaiText } from '@tanstack/ai-openai'
+import { openaiEmbed } from '@tanstack/ai-openai'
+import { openaiSummarize } from '@tanstack/ai-openai'
+```
+
+## Available Adapters
+
+### OpenAI
+
+```ts
+import {
+ openaiText, // Chat/text generation
+ openaiEmbed, // Embeddings
+ openaiSummarize, // Summarization
+ createOpenAIText,
+ createOpenAIEmbed,
+ createOpenAISummarize,
+} from '@tanstack/ai-openai'
+```
+
+### Anthropic
+
+```ts
+import {
+ anthropicText, // Chat/text generation
+ anthropicSummarize, // Summarization
+ createAnthropicText,
+ createAnthropicSummarize,
+} from '@tanstack/ai-anthropic'
+```
+
+> Note: Anthropic does not support embeddings natively.
+
+### Gemini
+
+```ts
+import {
+ geminiText, // Chat/text generation
+ geminiEmbed, // Embeddings
+ geminiSummarize, // Summarization
+ createGeminiText,
+ createGeminiEmbed,
+ createGeminiSummarize,
+} from '@tanstack/ai-gemini'
+```
+
+### Ollama
+
+```ts
+import {
+ ollamaText, // Chat/text generation
+ ollamaEmbed, // Embeddings
+ ollamaSummarize, // Summarization
+ createOllamaText,
+ createOllamaEmbed,
+ createOllamaSummarize,
+} from '@tanstack/ai-ollama'
+```
+
+## Usage
+
+### Basic Usage
+
+Each adapter type has two ways to create instances:
+
+1. **Factory function** (recommended for quick setup):
+
+```ts
+import { openaiText } from '@tanstack/ai-openai'
+
+const textAdapter = openaiText()
+
+```
+
+2. **Class constructor** (for more control):
+
+```ts
+import { createOpenAIText } from '@tanstack/ai-openai/adapters'
+
+const textAdapter = createOpenAIText({
+ apiKey: 'your-api-key',
+ // additional configuration...
+})
+```
+
+### Using the `generate` Function
+
+The `generate` function provides a unified API that adapts based on the adapter type:
+
+```ts
+import { generate } from '@tanstack/ai'
+import { openaiText, openaiEmbed, openaiSummarize } from '@tanstack/ai-openai/adapters'
+
+// Chat generation - returns AsyncIterable
+const chatResult = generate({
+ adapter: openaiText(),
+ model: 'gpt-4o',
+ messages: [{ role: 'user', content: [{ type: 'text', content: 'Hello!' }] }],
+})
+
+for await (const chunk of chatResult) {
+ console.log(chunk)
+}
+
+// Embeddings - returns Promise
+const embedResult = await generate({
+ adapter: openaiEmbed(),
+ model: 'text-embedding-3-small',
+ input: ['Hello, world!'],
+})
+
+console.log(embedResult.embeddings)
+
+// Summarization - returns Promise
+const summarizeResult = await generate({
+ adapter: openaiSummarize(),
+ model: 'gpt-4o-mini',
+ text: 'Long text to summarize...',
+})
+
+console.log(summarizeResult.summary)
+```
+
+### Type Safety
+
+Each adapter provides full type safety for its supported models and options:
+
+```ts
+import { openaiText, type OpenAITextModel } from '@tanstack/ai-openai'
+
+const adapter = openaiText()
+
+// TypeScript knows the exact models supported
+const model: OpenAITextModel = 'gpt-4o' // ✓ Valid
+const model2: OpenAITextModel = 'invalid' // ✗ Type error
+```
+
+## Migration from Monolithic Adapters
+
+The legacy monolithic adapters are still available but deprecated:
+
+```ts
+// Legacy (deprecated)
+import { openai } from '@tanstack/ai-openai'
+
+// New tree-shakeable approach
+import { openaiText, openaiEmbed } from '@tanstack/ai-openai/adapters'
+```
+
+## Bundle Size Benefits
+
+Using tree-shakeable adapters means:
+
+- Only the code you use is included in your bundle
+- Unused adapter types are completely eliminated
+- Smaller bundles lead to faster load times
+
+For example, if you only need chat functionality:
+
+```ts
+// Only chat code is bundled
+import { openaiText } from '@tanstack/ai-openai'
+```
+
+vs.
+
+```ts
+// All functionality is bundled (chat, embed, summarize)
+import { openai } from '@tanstack/ai-openai'
+```
+
+## Adapter Types
+
+Each adapter type implements a specific interface:
+
+- `ChatAdapter` - Provides `chatStream()` method for streaming chat responses
+- `EmbeddingAdapter` - Provides `createEmbeddings()` method for vector embeddings
+- `SummarizeAdapter` - Provides `summarize()` method for text summarization
+
+All adapters have a `kind` property that indicates their type:
+
+```ts
+const textAdapter = openaiText()
+console.log(textAdapter.kind) // 'chat'
+
+const embedAdapter = openaiEmbed()
+console.log(embedAdapter.kind) // 'embedding'
+
+const summarizeAdapter = openaiSummarize()
+console.log(summarizeAdapter.kind) // 'summarize'
+```
diff --git a/docs/guides/video-generation.md b/docs/guides/video-generation.md
new file mode 100644
index 00000000..54f61258
--- /dev/null
+++ b/docs/guides/video-generation.md
@@ -0,0 +1,331 @@
+# Video Generation (Experimental)
+
+> **⚠️ EXPERIMENTAL FEATURE WARNING**
+>
+> Video generation is an **experimental feature** that is subject to significant changes. Please read the caveats below carefully before using this feature.
+>
+> **Key Caveats:**
+> - The API may change without notice in future versions
+> - OpenAI's Sora API is in limited availability and may require organization verification
+> - Video generation uses a jobs/polling architecture, which differs from other synchronous activities
+> - Pricing, rate limits, and quotas may vary and are subject to change
+> - Not all features described here may be available in your OpenAI account
+
+## Overview
+
+TanStack AI provides experimental support for video generation through dedicated video adapters. Unlike image generation, video generation is an **asynchronous operation** that uses a jobs/polling pattern:
+
+1. **Create a job** - Submit a prompt and receive a job ID
+2. **Poll for status** - Check the job status until it's complete
+3. **Retrieve the video** - Get the URL to download/view the generated video
+
+Currently supported:
+- **OpenAI**: Sora-2 and Sora-2-Pro models (when available)
+
+## Basic Usage
+
+### Creating a Video Job
+
+```typescript
+import { ai } from '@tanstack/ai'
+import { openaiVideo } from '@tanstack/ai-openai'
+
+// Create a video adapter (uses OPENAI_API_KEY from environment)
+const adapter = openaiVideo()
+
+// Start a video generation job
+const { jobId, model } = await ai({
+ adapter,
+ model: 'sora-2',
+ prompt: 'A golden retriever puppy playing in a field of sunflowers',
+})
+
+console.log('Job started:', jobId)
+```
+
+### Polling for Status
+
+```typescript
+// Check the status of the job
+const status = await ai({
+ adapter,
+ model: 'sora-2',
+ jobId,
+ request: 'status',
+})
+
+console.log('Status:', status.status) // 'pending' | 'processing' | 'completed' | 'failed'
+console.log('Progress:', status.progress) // 0-100 (if available)
+
+if (status.status === 'failed') {
+ console.error('Error:', status.error)
+}
+```
+
+### Getting the Video URL
+
+```typescript
+// Only call this after status is 'completed'
+const { url, expiresAt } = await ai({
+ adapter,
+ model: 'sora-2',
+ jobId,
+ request: 'url',
+})
+
+console.log('Video URL:', url)
+console.log('Expires at:', expiresAt)
+```
+
+### Complete Example with Polling Loop
+
+```typescript
+import { ai } from '@tanstack/ai'
+import { openaiVideo } from '@tanstack/ai-openai'
+
+async function generateVideo(prompt: string) {
+ const adapter = openaiVideo()
+
+ // 1. Create the job
+ const { jobId } = await ai({
+ adapter,
+ model: 'sora-2',
+ prompt,
+ size: '1280x720',
+ duration: 8, // 4, 8, or 12 seconds
+ })
+
+ console.log('Job created:', jobId)
+
+ // 2. Poll for completion
+ let status = 'pending'
+ while (status !== 'completed' && status !== 'failed') {
+ // Wait 5 seconds between polls
+ await new Promise((resolve) => setTimeout(resolve, 5000))
+
+ const result = await ai({
+ adapter,
+ model: 'sora-2',
+ jobId,
+ request: 'status',
+ })
+
+ status = result.status
+ console.log(`Status: ${status}${result.progress ? ` (${result.progress}%)` : ''}`)
+
+ if (result.status === 'failed') {
+ throw new Error(result.error || 'Video generation failed')
+ }
+ }
+
+ // 3. Get the video URL
+ const { url } = await ai({
+ adapter,
+ model: 'sora-2',
+ jobId,
+ request: 'url',
+ })
+
+ return url
+}
+
+// Usage
+const videoUrl = await generateVideo('A cat playing piano in a jazz bar')
+console.log('Video ready:', videoUrl)
+```
+
+## Options
+
+### Job Creation Options
+
+| Option | Type | Description |
+|--------|------|-------------|
+| `prompt` | `string` | Text description of the video to generate (required) |
+| `size` | `string` | Video resolution in WIDTHxHEIGHT format |
+| `duration` | `number` | Video duration in seconds (maps to `seconds` parameter in API) |
+| `providerOptions` | `object` | Provider-specific options |
+
+### Supported Sizes
+
+Based on [OpenAI API docs](https://platform.openai.com/docs/api-reference/videos/create):
+
+| Size | Description |
+|------|-------------|
+| `1280x720` | 720p landscape (16:9) - default |
+| `720x1280` | 720p portrait (9:16) |
+| `1792x1024` | Wide landscape |
+| `1024x1792` | Tall portrait |
+
+### Supported Durations
+
+The API uses the `seconds` parameter. Allowed values:
+
+- `4` seconds
+- `8` seconds (default)
+- `12` seconds
+
+## Provider Options
+
+### OpenAI Provider Options
+
+Based on the [OpenAI Sora API](https://platform.openai.com/docs/api-reference/videos/create):
+
+```typescript
+const { jobId } = await ai({
+ adapter,
+ model: 'sora-2',
+ prompt: 'A beautiful sunset over the ocean',
+ size: '1280x720', // '1280x720', '720x1280', '1792x1024', '1024x1792'
+ duration: 8, // 4, 8, or 12 seconds
+ providerOptions: {
+ size: '1280x720', // Alternative way to specify size
+ seconds: 8, // Alternative way to specify duration
+ }
+})
+```
+
+## Response Types
+
+### VideoJobResult (from create)
+
+```typescript
+interface VideoJobResult {
+ jobId: string // Unique job identifier for polling
+ model: string // Model used for generation
+}
+```
+
+### VideoStatusResult (from status)
+
+```typescript
+interface VideoStatusResult {
+ jobId: string
+ status: 'pending' | 'processing' | 'completed' | 'failed'
+ progress?: number // 0-100, if available
+ error?: string // Error message if failed
+}
+```
+
+### VideoUrlResult (from url)
+
+```typescript
+interface VideoUrlResult {
+ jobId: string
+ url: string // URL to download/stream the video
+ expiresAt?: Date // When the URL expires
+}
+```
+
+## Model Variants
+
+| Model | Description | Use Case |
+|-------|-------------|----------|
+| `sora-2` | Faster generation, good quality | Rapid iteration, prototyping |
+| `sora-2-pro` | Higher quality, slower | Production-quality output |
+
+## Error Handling
+
+Video generation can fail for various reasons. Always implement proper error handling:
+
+```typescript
+try {
+ const { jobId } = await ai({
+ adapter,
+ model: 'sora-2',
+ prompt: 'A scene',
+ })
+
+ // Poll for status...
+ const status = await ai({
+ adapter,
+ model: 'sora-2',
+ jobId,
+ request: 'status',
+ })
+
+ if (status.status === 'failed') {
+ console.error('Generation failed:', status.error)
+ // Handle failure (e.g., retry, notify user)
+ }
+} catch (error) {
+ if (error.message.includes('Video generation API is not available')) {
+ console.error('Sora API access may be required. Check your OpenAI account.')
+ } else if (error.message.includes('rate limit')) {
+ console.error('Rate limited. Please wait before trying again.')
+ } else {
+ console.error('Unexpected error:', error)
+ }
+}
+```
+
+## Rate Limits and Quotas
+
+> **⚠️ Note:** Rate limits and quotas for video generation are subject to change and may vary by account tier.
+
+Typical considerations:
+- Video generation is computationally expensive
+- Concurrent job limits may apply
+- Monthly generation quotas may exist
+- Longer/higher-quality videos consume more quota
+
+Check the [OpenAI documentation](https://platform.openai.com/docs) for current limits.
+
+## Environment Variables
+
+The video adapter uses the same environment variable as other OpenAI adapters:
+
+- `OPENAI_API_KEY`: Your OpenAI API key
+
+## Explicit API Keys
+
+For production use or when you need explicit control:
+
+```typescript
+import { createOpenaiVideo } from '@tanstack/ai-openai'
+
+const adapter = createOpenaiVideo('your-openai-api-key')
+```
+
+## Differences from Image Generation
+
+| Aspect | Image Generation | Video Generation |
+|--------|-----------------|------------------|
+| API Type | Synchronous | Jobs/Polling |
+| Return Type | `ImageGenerationResult` | `VideoJobResult` → `VideoStatusResult` → `VideoUrlResult` |
+| Wait Time | Seconds | Minutes |
+| Multiple Outputs | `numberOfImages` option | Not supported |
+| Options Field | `prompt`, `size`, `numberOfImages` | `prompt`, `size`, `duration` |
+
+## Known Limitations
+
+> **⚠️ These limitations are subject to change as the feature evolves.**
+
+1. **API Availability**: The Sora API may not be available in all OpenAI accounts
+2. **Generation Time**: Video generation can take several minutes
+3. **URL Expiration**: Generated video URLs may expire after a certain period
+4. **No Real-time Progress**: Progress updates may be limited or delayed
+5. **Audio Limitations**: Audio generation support may be limited
+6. **Prompt Length**: Long prompts may be truncated
+
+## Best Practices
+
+1. **Implement Timeouts**: Set reasonable timeouts for the polling loop
+2. **Handle Failures Gracefully**: Have fallback behavior for failed generations
+3. **Cache URLs**: Store video URLs and check expiration before re-fetching
+4. **User Feedback**: Show clear progress indicators during generation
+5. **Validate Prompts**: Check prompt length and content before submission
+6. **Monitor Usage**: Track generation usage to avoid hitting quotas
+
+## Future Considerations
+
+This feature is experimental. Future versions may include:
+
+- Additional video models and providers
+- Streaming progress updates
+- Video editing and manipulation
+- Audio track generation
+- Batch video generation
+- Custom style/aesthetic controls
+
+Stay tuned to the [TanStack AI changelog](https://github.com/TanStack/ai/blob/main/CHANGELOG.md) for updates.
+
diff --git a/docs/protocol/http-stream-protocol.md b/docs/protocol/http-stream-protocol.md
index 8330cd42..a474301d 100644
--- a/docs/protocol/http-stream-protocol.md
+++ b/docs/protocol/http-stream-protocol.md
@@ -173,15 +173,15 @@ Unlike SSE, HTTP streaming does not provide automatic reconnection:
TanStack AI doesn't provide a built-in NDJSON formatter, but you can create one easily:
```typescript
-import { chat } from '@tanstack/ai';
-import { openai } from '@tanstack/ai-openai';
+import { ai } from '@tanstack/ai';
+import { openaiText } from '@tanstack/ai-openai';
export async function POST(request: Request) {
const { messages } = await request.json();
const encoder = new TextEncoder();
- const stream = chat({
- adapter: openai(),
+ const stream = ai({
+ adapter: openaiText(),
messages,
model: 'gpt-4o',
});
@@ -222,8 +222,8 @@ export async function POST(request: Request) {
```typescript
import express from 'express';
-import { chat } from '@tanstack/ai';
-import { openai } from '@tanstack/ai-openai';
+import { ai } from '@tanstack/ai';
+import { openaiText } from '@tanstack/ai-openai';
const app = express();
app.use(express.json());
@@ -236,8 +236,8 @@ app.post('/api/chat', async (req, res) => {
res.setHeader('Transfer-Encoding', 'chunked');
try {
- const stream = chat({
- adapter: openai(),
+ const stream = ai({
+ adapter: openaiText(),
messages,
model: 'gpt-4o',
});
diff --git a/docs/protocol/sse-protocol.md b/docs/protocol/sse-protocol.md
index 1f9f3d9e..b18713d1 100644
--- a/docs/protocol/sse-protocol.md
+++ b/docs/protocol/sse-protocol.md
@@ -167,14 +167,14 @@ SSE provides automatic reconnection:
TanStack AI provides `toServerSentEventsStream()` and `toStreamResponse()` utilities:
```typescript
-import { chat, toStreamResponse } from '@tanstack/ai';
-import { openai } from '@tanstack/ai-openai';
+import { ai, toStreamResponse } from '@tanstack/ai';
+import { openaiText } from '@tanstack/ai-openai';
export async function POST(request: Request) {
const { messages } = await request.json();
- const stream = chat({
- adapter: openai(),
+ const stream = ai({
+ adapter: openaiText(),
messages,
model: 'gpt-4o',
});
@@ -224,7 +224,7 @@ export async function POST(request: Request) {
const stream = new ReadableStream({
async start(controller) {
try {
- for await (const chunk of chat({ ... })) {
+ for await (const chunk of ai({ ... })) {
const sseData = `data: ${JSON.stringify(chunk)}\n\n`;
controller.enqueue(encoder.encode(sseData));
}
diff --git a/docs/reference/classes/BaseAdapter.md b/docs/reference/classes/BaseAdapter.md
index 1127e644..94e8007c 100644
--- a/docs/reference/classes/BaseAdapter.md
+++ b/docs/reference/classes/BaseAdapter.md
@@ -237,7 +237,7 @@ Defined in: [base-adapter.ts:74](https://github.com/TanStack/ai/blob/main/packag
##### options
-[`ChatOptions`](../interfaces/ChatOptions.md)
+[`TextOptions`](../interfaces/TextOptions.md)
#### Returns
diff --git a/docs/reference/functions/chat.md b/docs/reference/functions/text.md
similarity index 94%
rename from docs/reference/functions/chat.md
rename to docs/reference/functions/text.md
index 16934d76..ec320476 100644
--- a/docs/reference/functions/chat.md
+++ b/docs/reference/functions/text.md
@@ -29,7 +29,7 @@ Includes automatic tool execution loop
### options
-[`ChatStreamOptionsForModel`](../type-aliases/ChatStreamOptionsForModel.md)\<`TAdapter`, `TModel`\>
+[`TextStreamOptionsForModel`](../type-aliases/TextStreamOptionsForModel.md)\<`TAdapter`, `TModel`\>
Chat options
diff --git a/docs/reference/functions/chatOptions.md b/docs/reference/functions/textOptions.md
similarity index 77%
rename from docs/reference/functions/chatOptions.md
rename to docs/reference/functions/textOptions.md
index d776680b..a9c7d386 100644
--- a/docs/reference/functions/chatOptions.md
+++ b/docs/reference/functions/textOptions.md
@@ -1,12 +1,12 @@
---
-id: chatOptions
-title: chatOptions
+id: textOptions
+title: textOptions
---
-# Function: chatOptions()
+# Function: textOptions()
```ts
-function chatOptions(options): Omit, "model" | "providerOptions" | "messages" | "abortController"> & object;
+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)
@@ -25,8 +25,8 @@ Defined in: [utilities/chat-options.ts:3](https://github.com/TanStack/ai/blob/ma
### options
-`Omit`\<[`ChatStreamOptionsUnion`](../type-aliases/ChatStreamOptionsUnion.md)\<`TAdapter`\>, `"model"` \| `"providerOptions"` \| `"messages"` \| `"abortController"`\> & `object`
+`Omit`\<[`TextStreamOptionsUnion`](../type-aliases/TextStreamOptionsUnion.md)\<`TAdapter`\>, `"model"` \| `"providerOptions"` \| `"messages"` \| `"abortController"`\> & `object`
## Returns
-`Omit`\<[`ChatStreamOptionsUnion`](../type-aliases/ChatStreamOptionsUnion.md)\<`TAdapter`\>, `"model"` \| `"providerOptions"` \| `"messages"` \| `"abortController"`\> & `object`
+`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 a3d506c0..b28dc1bc 100644
--- a/docs/reference/index.md
+++ b/docs/reference/index.md
@@ -25,8 +25,8 @@ title: "@tanstack/ai"
- [ApprovalRequestedStreamChunk](interfaces/ApprovalRequestedStreamChunk.md)
- [AudioPart](interfaces/AudioPart.md)
- [BaseStreamChunk](interfaces/BaseStreamChunk.md)
-- [ChatCompletionChunk](interfaces/ChatCompletionChunk.md)
-- [ChatOptions](interfaces/ChatOptions.md)
+- [TextCompletionChunk](interfaces/TextCompletionChunk.md)
+- [TextOptions](interfaces/TextOptions.md)
- [ChunkRecording](interfaces/ChunkRecording.md)
- [ChunkStrategy](interfaces/ChunkStrategy.md)
- [ClientTool](interfaces/ClientTool.md)
@@ -73,8 +73,8 @@ title: "@tanstack/ai"
- [AgentLoopStrategy](type-aliases/AgentLoopStrategy.md)
- [AnyClientTool](type-aliases/AnyClientTool.md)
-- [ChatStreamOptionsForModel](type-aliases/ChatStreamOptionsForModel.md)
-- [ChatStreamOptionsUnion](type-aliases/ChatStreamOptionsUnion.md)
+- [TextStreamOptionsForModel](type-aliases/TextStreamOptionsForModel.md)
+- [TextStreamOptionsUnion](type-aliases/TextStreamOptionsUnion.md)
- [ConstrainedContent](type-aliases/ConstrainedContent.md)
- [ConstrainedModelMessage](type-aliases/ConstrainedModelMessage.md)
- [ContentPart](type-aliases/ContentPart.md)
@@ -101,8 +101,8 @@ title: "@tanstack/ai"
## Functions
-- [chat](functions/chat.md)
-- [chatOptions](functions/chatOptions.md)
+- [chat](functions/text.md)
+- [textOptions](functions/textOptions.md)
- [combineStrategies](functions/combineStrategies.md)
- [convertMessagesToModelMessages](functions/convertMessagesToModelMessages.md)
- [convertZodToJsonSchema](functions/convertZodToJsonSchema.md)
diff --git a/docs/reference/interfaces/AIAdapter.md b/docs/reference/interfaces/AIAdapter.md
index 2ad47311..96bb97d4 100644
--- a/docs/reference/interfaces/AIAdapter.md
+++ b/docs/reference/interfaces/AIAdapter.md
@@ -133,7 +133,7 @@ Defined in: [types.ts:804](https://github.com/TanStack/ai/blob/main/packages/typ
##### options
-[`ChatOptions`](ChatOptions.md)\<`string`, `TChatProviderOptions`\>
+[`TextOptions`](TextOptions.md)\<`string`, `TChatProviderOptions`\>
#### Returns
diff --git a/docs/reference/interfaces/ChatCompletionChunk.md b/docs/reference/interfaces/TextCompletionChunk.md
similarity index 93%
rename from docs/reference/interfaces/ChatCompletionChunk.md
rename to docs/reference/interfaces/TextCompletionChunk.md
index 78235a12..fe2db802 100644
--- a/docs/reference/interfaces/ChatCompletionChunk.md
+++ b/docs/reference/interfaces/TextCompletionChunk.md
@@ -1,9 +1,9 @@
---
-id: ChatCompletionChunk
-title: ChatCompletionChunk
+id: TextCompletionChunk
+title: TextCompletionChunk
---
-# Interface: ChatCompletionChunk
+# Interface: TextCompletionChunk
Defined in: [types.ts:684](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/types.ts#L684)
diff --git a/docs/reference/interfaces/ChatOptions.md b/docs/reference/interfaces/TextOptions.md
similarity index 97%
rename from docs/reference/interfaces/ChatOptions.md
rename to docs/reference/interfaces/TextOptions.md
index 723a13d8..123ad406 100644
--- a/docs/reference/interfaces/ChatOptions.md
+++ b/docs/reference/interfaces/TextOptions.md
@@ -1,9 +1,9 @@
---
-id: ChatOptions
-title: ChatOptions
+id: TextOptions
+title: TextOptions
---
-# Interface: ChatOptions\
+# Interface: TextOptions\
Defined in: [types.ts:548](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/types.ts#L548)
diff --git a/docs/reference/type-aliases/ChatStreamOptionsForModel.md b/docs/reference/type-aliases/TextStreamOptionsForModel.md
similarity index 65%
rename from docs/reference/type-aliases/ChatStreamOptionsForModel.md
rename to docs/reference/type-aliases/TextStreamOptionsForModel.md
index 651be480..f0c05360 100644
--- a/docs/reference/type-aliases/ChatStreamOptionsForModel.md
+++ b/docs/reference/type-aliases/TextStreamOptionsForModel.md
@@ -1,18 +1,18 @@
---
-id: ChatStreamOptionsForModel
-title: ChatStreamOptionsForModel
+id: TextStreamOptionsForModel
+title: TextStreamOptionsForModel
---
-# Type Alias: ChatStreamOptionsForModel\
+# Type Alias: TextStreamOptionsForModel\
```ts
-type ChatStreamOptionsForModel = TAdapter extends AIAdapter ? Omit & object : never;
+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 ChatStreamOptionsUnion which creates a union over all models,
+Unlike TextStreamOptionsUnion which creates a union over all models,
this type takes a specific model and constrains messages accordingly.
## Type Parameters
diff --git a/docs/reference/type-aliases/ChatStreamOptionsUnion.md b/docs/reference/type-aliases/TextStreamOptionsUnion.md
similarity index 68%
rename from docs/reference/type-aliases/ChatStreamOptionsUnion.md
rename to docs/reference/type-aliases/TextStreamOptionsUnion.md
index 02e3cb26..5db11467 100644
--- a/docs/reference/type-aliases/ChatStreamOptionsUnion.md
+++ b/docs/reference/type-aliases/TextStreamOptionsUnion.md
@@ -1,12 +1,12 @@
---
-id: ChatStreamOptionsUnion
-title: ChatStreamOptionsUnion
+id: TextStreamOptionsUnion
+title: TextStreamOptionsUnion
---
-# Type Alias: ChatStreamOptionsUnion\
+# Type Alias: TextStreamOptionsUnion\
```ts
-type ChatStreamOptionsUnion = TAdapter extends AIAdapter ? Models[number] extends infer TModel ? TModel extends string ? Omit & object : never : never : never;
+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)
diff --git a/examples/README.md b/examples/README.md
index 460baa07..9abefa19 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -308,10 +308,10 @@ All examples use SSE for real-time streaming:
**Backend (TypeScript):**
```typescript
-import { chat, toStreamResponse } from '@tanstack/ai'
+import { ai, toStreamResponse } from '@tanstack/ai'
import { openai } from '@tanstack/ai-openai'
-const stream = chat({
+const stream = ai({
adapter: openai(),
model: 'gpt-4o',
messages,
@@ -360,7 +360,7 @@ const client = new ChatClient({
The TypeScript backend (`@tanstack/ai`) automatically handles tool execution:
```typescript
-import { chat, toolDefinition } from '@tanstack/ai'
+import { ai, toolDefinition } from '@tanstack/ai'
import { z } from 'zod'
// Step 1: Define the tool schema
diff --git a/examples/ts-group-chat/chat-server/capnweb-rpc.ts b/examples/ts-group-chat/chat-server/capnweb-rpc.ts
index ffa2bcd1..f9c067ba 100644
--- a/examples/ts-group-chat/chat-server/capnweb-rpc.ts
+++ b/examples/ts-group-chat/chat-server/capnweb-rpc.ts
@@ -1,14 +1,15 @@
// Cap'n Web RPC server implementation for chat
import { RpcTarget } from 'capnweb'
-import { WebSocket } from 'ws'
import { ChatLogic } from './chat-logic.js'
+import type { WebSocket } from 'ws'
+
// Local type definition to avoid importing from @tanstack/ai at module parse time
interface ModelMessage {
role: 'system' | 'user' | 'assistant' | 'tool'
content?: string
toolCallId?: string
- toolCalls?: any[]
+ toolCalls?: Array
}
// Lazy-load claude service to avoid importing AI packages at module parse time
@@ -57,7 +58,7 @@ export const activeServers = new Set()
export const userMessageQueues = new Map>()
// Global registry of client callbacks
-export const clients = new Map()
+export const clients = new Map) => void>()
// Chat Server Implementation (one per connection)
export class ChatServer extends RpcTarget {
@@ -95,7 +96,7 @@ export class ChatServer extends RpcTarget {
console.log(`📬 Exclude user: ${excludeUser || 'none'}`)
let successCount = 0
- const successful: string[] = []
+ const successful: Array = []
for (const username of clients.keys()) {
if (excludeUser && username === excludeUser) {
@@ -150,7 +151,10 @@ export class ChatServer extends RpcTarget {
}
// Client joins the chat
- async joinChat(username: string, notificationCallback: Function) {
+ async joinChat(
+ username: string,
+ notificationCallback: (...args: Array) => void,
+ ) {
console.log(`${username} is joining the chat`)
this.currentUsername = username
@@ -264,7 +268,7 @@ export class ChatServer extends RpcTarget {
)
// Build conversation history for Claude
- const conversationHistory: ModelMessage[] = globalChat
+ const conversationHistory: Array = globalChat
.getMessages()
.map((msg) => ({
role: 'user' as const,
@@ -345,7 +349,7 @@ export class ChatServer extends RpcTarget {
})
// Get conversation history from the current request
- const conversationHistory: ModelMessage[] = globalChat
+ const conversationHistory: Array = globalChat
.getMessages()
.map((msg) => ({
role: 'user' as const,
@@ -409,7 +413,7 @@ export class ChatServer extends RpcTarget {
}
// Stream Claude response (for future use if needed)
- async *streamClaudeResponse(conversationHistory: ModelMessage[]) {
+ async *streamClaudeResponse(conversationHistory: Array) {
const claudeService = await getClaudeService()
yield* claudeService.streamResponse(conversationHistory)
}
diff --git a/examples/ts-group-chat/chat-server/chat-logic.ts b/examples/ts-group-chat/chat-server/chat-logic.ts
index b6d7ac79..6133a55b 100644
--- a/examples/ts-group-chat/chat-server/chat-logic.ts
+++ b/examples/ts-group-chat/chat-server/chat-logic.ts
@@ -8,8 +8,8 @@ export interface ChatMessage {
}
export interface ChatState {
- onlineUsers: string[]
- messages: ChatMessage[]
+ onlineUsers: Array
+ messages: Array
}
// Core chat business logic class
@@ -105,11 +105,11 @@ export class ChatLogic {
}
}
- getMessages(): ChatMessage[] {
+ getMessages(): Array {
return [...this.chatState.messages]
}
- getOnlineUsers(): string[] {
+ getOnlineUsers(): Array {
return [...this.chatState.onlineUsers]
}
}
diff --git a/examples/ts-group-chat/chat-server/claude-service.ts b/examples/ts-group-chat/chat-server/claude-service.ts
index 0d2d6dbc..7853ccd0 100644
--- a/examples/ts-group-chat/chat-server/claude-service.ts
+++ b/examples/ts-group-chat/chat-server/claude-service.ts
@@ -1,6 +1,6 @@
// Claude AI service for handling queued AI responses
-import { anthropic } from '@tanstack/ai-anthropic'
-import { chat, toolDefinition } from '@tanstack/ai'
+import { anthropicText } from '@tanstack/ai-anthropic'
+import { ai, toolDefinition } from '@tanstack/ai'
import type { JSONSchema, ModelMessage, StreamChunk } from '@tanstack/ai'
// Define input schema for getWeather tool using JSONSchema
@@ -92,7 +92,7 @@ export interface ClaudeQueueStatus {
}
export class ClaudeService {
- private adapter = anthropic() // Uses ANTHROPIC_API_KEY from env
+ private adapter = anthropicText() // Uses ANTHROPIC_API_KEY from env
private queue: Array = []
private currentRequest: ClaudeRequest | null = null
private isProcessing = false
@@ -149,7 +149,7 @@ export class ClaudeService {
let chunkCount = 0
let accumulatedContent = ''
- for await (const chunk of chat({
+ for await (const chunk of ai({
adapter: this.adapter,
systemPrompts: [systemMessage],
messages: [...conversationHistory] as any,
diff --git a/examples/ts-group-chat/package.json b/examples/ts-group-chat/package.json
index 72e64073..90137ede 100644
--- a/examples/ts-group-chat/package.json
+++ b/examples/ts-group-chat/package.json
@@ -8,21 +8,21 @@
"test": "exit 0"
},
"dependencies": {
- "@tailwindcss/vite": "^4.1.17",
+ "@tailwindcss/vite": "^4.1.18",
"@tanstack/ai": "workspace:*",
"@tanstack/ai-anthropic": "workspace:*",
"@tanstack/ai-client": "workspace:*",
"@tanstack/ai-react": "workspace:*",
"@tanstack/react-devtools": "^0.8.2",
- "@tanstack/react-router": "^1.139.7",
+ "@tanstack/react-router": "^1.141.1",
"@tanstack/react-router-devtools": "^1.139.7",
"@tanstack/react-router-ssr-query": "^1.139.7",
- "@tanstack/react-start": "^1.139.8",
+ "@tanstack/react-start": "^1.141.1",
"@tanstack/router-plugin": "^1.139.7",
"capnweb": "^0.1.0",
- "react": "^19.2.0",
- "react-dom": "^19.2.0",
- "tailwindcss": "^4.1.17",
+ "react": "^19.2.3",
+ "react-dom": "^19.2.3",
+ "tailwindcss": "^4.1.18",
"vite-tsconfig-paths": "^5.1.4",
"ws": "^8.18.3"
},
@@ -34,10 +34,10 @@
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/ws": "^8.18.1",
- "@vitejs/plugin-react": "^5.1.1",
+ "@vitejs/plugin-react": "^5.1.2",
"jsdom": "^27.2.0",
"typescript": "5.9.3",
- "vite": "^7.2.4",
+ "vite": "^7.2.7",
"vitest": "^4.0.14",
"web-vitals": "^5.1.0"
}
diff --git a/examples/ts-react-chat/package.json b/examples/ts-react-chat/package.json
index 3c4ebfb4..f9a10c40 100644
--- a/examples/ts-react-chat/package.json
+++ b/examples/ts-react-chat/package.json
@@ -9,7 +9,7 @@
"test": "exit 0"
},
"dependencies": {
- "@tailwindcss/vite": "^4.1.17",
+ "@tailwindcss/vite": "^4.1.18",
"@tanstack/ai": "workspace:*",
"@tanstack/ai-anthropic": "workspace:*",
"@tanstack/ai-client": "workspace:*",
@@ -18,25 +18,25 @@
"@tanstack/ai-openai": "workspace:*",
"@tanstack/ai-react": "workspace:*",
"@tanstack/ai-react-ui": "workspace:*",
- "@tanstack/nitro-v2-vite-plugin": "^1.139.0",
+ "@tanstack/nitro-v2-vite-plugin": "^1.141.0",
"@tanstack/react-devtools": "^0.8.2",
- "@tanstack/react-router": "^1.139.7",
+ "@tanstack/react-router": "^1.141.1",
"@tanstack/react-router-devtools": "^1.139.7",
"@tanstack/react-router-ssr-query": "^1.139.7",
- "@tanstack/react-start": "^1.139.8",
+ "@tanstack/react-start": "^1.141.1",
"@tanstack/react-store": "^0.8.0",
"@tanstack/router-plugin": "^1.139.7",
"@tanstack/store": "^0.8.0",
"highlight.js": "^11.11.1",
- "lucide-react": "^0.555.0",
- "react": "^19.2.0",
- "react-dom": "^19.2.0",
+ "lucide-react": "^0.561.0",
+ "react": "^19.2.3",
+ "react-dom": "^19.2.3",
"react-markdown": "^10.1.0",
"rehype-highlight": "^7.0.2",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
- "tailwindcss": "^4.1.17",
+ "tailwindcss": "^4.1.18",
"vite-tsconfig-paths": "^5.1.4",
"zod": "^4.1.13"
},
@@ -48,10 +48,10 @@
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
- "@vitejs/plugin-react": "^5.1.1",
+ "@vitejs/plugin-react": "^5.1.2",
"jsdom": "^27.2.0",
"typescript": "5.9.3",
- "vite": "^7.2.4",
+ "vite": "^7.2.7",
"vitest": "^4.0.14",
"web-vitals": "^5.1.0"
}
diff --git a/examples/ts-react-chat/src/routes/api.tanchat.ts b/examples/ts-react-chat/src/routes/api.tanchat.ts
index 39cf0d5f..56c3303c 100644
--- a/examples/ts-react-chat/src/routes/api.tanchat.ts
+++ b/examples/ts-react-chat/src/routes/api.tanchat.ts
@@ -1,9 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'
-import { chat, maxIterations, toStreamResponse } from '@tanstack/ai'
-import { openai } from '@tanstack/ai-openai'
-import { ollama } from '@tanstack/ai-ollama'
-import { anthropic } from '@tanstack/ai-anthropic'
-import { gemini } from '@tanstack/ai-gemini'
+import { ai, maxIterations, toStreamResponse } from '@tanstack/ai'
+import { openaiText } from '@tanstack/ai-openai'
+import { ollamaText } from '@tanstack/ai-ollama'
+import { anthropicText } from '@tanstack/ai-anthropic'
+import { geminiText } from '@tanstack/ai-gemini'
import {
addToCartToolDef,
addToWishListToolDef,
@@ -73,20 +73,20 @@ export const Route = createFileRoute('/api/tanchat')({
switch (provider) {
case 'anthropic':
- adapter = anthropic()
- defaultModel = 'claude-sonnet-4-5-20250929'
+ adapter = anthropicText()
+ defaultModel = 'claude-sonnet-4-5'
break
case 'gemini':
- adapter = gemini()
+ adapter = geminiText()
defaultModel = 'gemini-2.0-flash-exp'
break
case 'ollama':
- adapter = ollama()
+ adapter = ollamaText()
defaultModel = 'mistral:7b'
break
case 'openai':
default:
- adapter = openai()
+ adapter = openaiText()
defaultModel = 'gpt-4o'
break
}
@@ -97,7 +97,7 @@ export const Route = createFileRoute('/api/tanchat')({
`[API Route] Using provider: ${provider}, model: ${selectedModel}`,
)
- const stream = chat({
+ const stream = ai({
adapter: adapter as any,
model: selectedModel as any,
tools: [
@@ -113,7 +113,7 @@ export const Route = createFileRoute('/api/tanchat')({
abortController,
conversationId,
})
- return toStreamResponse(stream, { abortController })
+ return toStreamResponse(stream as any, { abortController })
} catch (error: any) {
console.error('[API Route] Error in chat request:', {
message: error?.message,
diff --git a/examples/ts-solid-chat/package.json b/examples/ts-solid-chat/package.json
index 87920223..5c16d816 100644
--- a/examples/ts-solid-chat/package.json
+++ b/examples/ts-solid-chat/package.json
@@ -9,7 +9,7 @@
"test": "exit 0"
},
"dependencies": {
- "@tailwindcss/vite": "^4.1.17",
+ "@tailwindcss/vite": "^4.1.18",
"@tanstack/ai": "workspace:*",
"@tanstack/ai-anthropic": "workspace:*",
"@tanstack/ai-client": "workspace:*",
@@ -19,7 +19,7 @@
"@tanstack/ai-openai": "workspace:*",
"@tanstack/ai-solid": "workspace:*",
"@tanstack/ai-solid-ui": "workspace:*",
- "@tanstack/nitro-v2-vite-plugin": "^1.139.0",
+ "@tanstack/nitro-v2-vite-plugin": "^1.141.0",
"@tanstack/router-plugin": "^1.139.7",
"@tanstack/solid-ai-devtools": "workspace:*",
"@tanstack/solid-devtools": "^0.7.15",
@@ -33,7 +33,7 @@
"lucide-solid": "^0.554.0",
"solid-js": "^1.9.10",
"solid-markdown": "^2.1.0",
- "tailwindcss": "^4.1.17",
+ "tailwindcss": "^4.1.18",
"vite-tsconfig-paths": "^5.1.4",
"zod": "^4.1.13"
},
@@ -45,7 +45,7 @@
"@types/node": "^24.10.1",
"jsdom": "^27.2.0",
"typescript": "5.9.3",
- "vite": "^7.2.4",
+ "vite": "^7.2.7",
"vite-plugin-solid": "^2.11.10",
"vitest": "^4.0.14",
"web-vitals": "^5.1.0"
diff --git a/examples/ts-solid-chat/src/routes/api.chat.ts b/examples/ts-solid-chat/src/routes/api.chat.ts
index 8c96e7ae..a86d1d7d 100644
--- a/examples/ts-solid-chat/src/routes/api.chat.ts
+++ b/examples/ts-solid-chat/src/routes/api.chat.ts
@@ -1,6 +1,6 @@
import { createFileRoute } from '@tanstack/solid-router'
-import { chat, maxIterations, toStreamResponse } from '@tanstack/ai'
-import { anthropic } from '@tanstack/ai-anthropic'
+import { ai, maxIterations, toStreamResponse } from '@tanstack/ai'
+import { anthropicText } from '@tanstack/ai-anthropic'
import { serverTools } from '@/lib/guitar-tools'
const SYSTEM_PROMPT = `You are a helpful assistant for a guitar store.
@@ -56,9 +56,9 @@ export const Route = createFileRoute('/api/chat')({
const { messages } = await request.json()
try {
// Use the stream abort signal for proper cancellation handling
- const stream = chat({
- adapter: anthropic(),
- model: 'claude-sonnet-4-5-20250929',
+ const stream = ai({
+ adapter: anthropicText(),
+ model: 'claude-sonnet-4-5',
tools: serverTools,
systemPrompts: [SYSTEM_PROMPT],
agentLoopStrategy: maxIterations(20),
diff --git a/examples/ts-svelte-chat/package.json b/examples/ts-svelte-chat/package.json
index 6c8e552d..7c3a40c7 100644
--- a/examples/ts-svelte-chat/package.json
+++ b/examples/ts-svelte-chat/package.json
@@ -29,13 +29,13 @@
"@sveltejs/adapter-auto": "^3.3.1",
"@sveltejs/kit": "^2.15.10",
"@sveltejs/vite-plugin-svelte": "^5.1.1",
- "@tailwindcss/vite": "^4.1.17",
+ "@tailwindcss/vite": "^4.1.18",
"@types/node": "^24.10.1",
"svelte": "^5.20.0",
"svelte-check": "^4.2.0",
- "tailwindcss": "^4.1.17",
+ "tailwindcss": "^4.1.18",
"tslib": "^2.8.1",
"typescript": "5.9.3",
- "vite": "^7.2.4"
+ "vite": "^7.2.7"
}
}
diff --git a/examples/ts-svelte-chat/src/routes/api/chat/+server.ts b/examples/ts-svelte-chat/src/routes/api/chat/+server.ts
index ee6a3195..9abcdbd4 100644
--- a/examples/ts-svelte-chat/src/routes/api/chat/+server.ts
+++ b/examples/ts-svelte-chat/src/routes/api/chat/+server.ts
@@ -1,11 +1,11 @@
-import { env } from '$env/dynamic/private'
-import { chat, maxIterations, toStreamResponse } from '@tanstack/ai'
-import { openai } from '@tanstack/ai-openai'
-import { ollama } from '@tanstack/ai-ollama'
-import { anthropic } from '@tanstack/ai-anthropic'
-import { gemini } from '@tanstack/ai-gemini'
+import { ai, maxIterations, toStreamResponse } from '@tanstack/ai'
+import { openaiText } from '@tanstack/ai-openai'
+import { ollamaText } from '@tanstack/ai-ollama'
+import { anthropicText } from '@tanstack/ai-anthropic'
+import { geminiText } from '@tanstack/ai-gemini'
import type { RequestHandler } from './$types'
+import { env } from '$env/dynamic/private'
import {
addToCartToolDef,
@@ -81,20 +81,20 @@ export const POST: RequestHandler = async ({ request }) => {
switch (provider) {
case 'anthropic':
- adapter = anthropic()
- defaultModel = 'claude-sonnet-4-5-20250929'
+ adapter = anthropicText()
+ defaultModel = 'claude-sonnet-4-5'
break
case 'gemini':
- adapter = gemini()
+ adapter = geminiText()
defaultModel = 'gemini-2.0-flash-exp'
break
case 'ollama':
- adapter = ollama()
+ adapter = ollamaText()
defaultModel = 'mistral:7b'
break
case 'openai':
default:
- adapter = openai()
+ adapter = openaiText()
defaultModel = 'gpt-4o'
break
}
@@ -102,7 +102,7 @@ export const POST: RequestHandler = async ({ request }) => {
// Determine model - use provided model or default based on provider
const selectedModel = model || defaultModel
- const stream = chat({
+ const stream = ai({
adapter: adapter as any,
model: selectedModel as any,
tools: [
diff --git a/examples/ts-vue-chat/package.json b/examples/ts-vue-chat/package.json
index d35140bb..1f58ba7f 100644
--- a/examples/ts-vue-chat/package.json
+++ b/examples/ts-vue-chat/package.json
@@ -24,17 +24,17 @@
"zod": "^4.1.13"
},
"devDependencies": {
- "@tailwindcss/vite": "^4.1.17",
+ "@tailwindcss/vite": "^4.1.18",
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^5.2.3",
"autoprefixer": "^10.4.21",
"concurrently": "^9.1.2",
"dotenv": "^17.2.3",
"express": "^5.1.0",
- "tailwindcss": "^4.1.17",
+ "tailwindcss": "^4.1.18",
"tsx": "^4.20.6",
"typescript": "5.9.3",
- "vite": "^7.2.4",
+ "vite": "^7.2.7",
"vue-tsc": "^2.2.10"
}
}
diff --git a/examples/ts-vue-chat/vite.config.ts b/examples/ts-vue-chat/vite.config.ts
index 7d3f3093..c8aab18c 100644
--- a/examples/ts-vue-chat/vite.config.ts
+++ b/examples/ts-vue-chat/vite.config.ts
@@ -2,11 +2,11 @@ import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
-import { chat, maxIterations, toStreamResponse } from '@tanstack/ai'
-import { openai } from '@tanstack/ai-openai'
-import { anthropic } from '@tanstack/ai-anthropic'
-import { gemini } from '@tanstack/ai-gemini'
-import { ollama } from '@tanstack/ai-ollama'
+import { ai, maxIterations, toStreamResponse } from '@tanstack/ai'
+import { openaiText } from '@tanstack/ai-openai'
+import { anthropicText } from '@tanstack/ai-anthropic'
+import { geminiText } from '@tanstack/ai-gemini'
+import { ollamaText } from '@tanstack/ai-ollama'
import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'
import dotenv from 'dotenv'
@@ -206,20 +206,20 @@ export default defineConfig({
switch (provider) {
case 'anthropic':
- adapter = anthropic()
+ adapter = anthropicText()
defaultModel = 'claude-sonnet-4-5-20250929'
break
case 'gemini':
- adapter = gemini()
+ adapter = geminiText()
defaultModel = 'gemini-2.0-flash-exp'
break
case 'ollama':
- adapter = ollama()
+ adapter = ollamaText()
defaultModel = 'mistral:7b'
break
case 'openai':
default:
- adapter = openai()
+ adapter = openaiText()
defaultModel = 'gpt-4o'
break
}
@@ -231,7 +231,7 @@ export default defineConfig({
const abortController = new AbortController()
- const stream = chat({
+ const stream = ai({
adapter: adapter as any,
model: selectedModel as any,
tools: [
diff --git a/examples/vanilla-chat/package.json b/examples/vanilla-chat/package.json
index 0f1fa49b..512b87b5 100644
--- a/examples/vanilla-chat/package.json
+++ b/examples/vanilla-chat/package.json
@@ -13,6 +13,6 @@
"@tanstack/ai-client": "workspace:*"
},
"devDependencies": {
- "vite": "^7.2.4"
+ "vite": "^7.2.7"
}
}
diff --git a/package.json b/package.json
index 2b7d740f..eb54e5b2 100644
--- a/package.json
+++ b/package.json
@@ -65,7 +65,7 @@
"sherif": "^1.9.0",
"tinyglobby": "^0.2.15",
"typescript": "5.9.3",
- "vite": "^7.2.4",
+ "vite": "^7.2.7",
"vitest": "^4.0.14"
}
}
diff --git a/packages/typescript/ai-anthropic/package.json b/packages/typescript/ai-anthropic/package.json
index b3b500a7..6e02eb3a 100644
--- a/packages/typescript/ai-anthropic/package.json
+++ b/packages/typescript/ai-anthropic/package.json
@@ -44,10 +44,10 @@
"@tanstack/ai": "workspace:*"
},
"devDependencies": {
- "@vitest/coverage-v8": "4.0.14",
- "zod": "^4.1.13"
+ "@vitest/coverage-v8": "4.0.14"
},
"peerDependencies": {
- "@tanstack/ai": "workspace:*"
+ "@tanstack/ai": "workspace:*",
+ "zod": "^4.0.0"
}
}
diff --git a/packages/typescript/ai-anthropic/src/adapters/summarize.ts b/packages/typescript/ai-anthropic/src/adapters/summarize.ts
new file mode 100644
index 00000000..89a6deee
--- /dev/null
+++ b/packages/typescript/ai-anthropic/src/adapters/summarize.ts
@@ -0,0 +1,118 @@
+import { BaseSummarizeAdapter } from '@tanstack/ai/adapters'
+import { ANTHROPIC_MODELS } from '../model-meta'
+import { createAnthropicClient, getAnthropicApiKeyFromEnv } from '../utils'
+import type { SummarizationOptions, SummarizationResult } from '@tanstack/ai'
+import type { AnthropicClientConfig } from '../utils'
+
+/**
+ * Configuration for Anthropic summarize adapter
+ */
+export interface AnthropicSummarizeConfig extends AnthropicClientConfig {}
+
+/**
+ * Anthropic-specific provider options for summarization
+ */
+export interface AnthropicSummarizeProviderOptions {
+ /** Temperature for response generation (0-1) */
+ temperature?: number
+ /** Maximum tokens in the response */
+ maxTokens?: number
+}
+
+/**
+ * Anthropic Summarize Adapter
+ *
+ * Tree-shakeable adapter for Anthropic summarization functionality.
+ * Import only what you need for smaller bundle sizes.
+ */
+export class AnthropicSummarizeAdapter extends BaseSummarizeAdapter<
+ typeof ANTHROPIC_MODELS,
+ AnthropicSummarizeProviderOptions
+> {
+ readonly kind = 'summarize' as const
+ readonly name = 'anthropic' as const
+ readonly models = ANTHROPIC_MODELS
+
+ private client: ReturnType
+
+ constructor(config: AnthropicSummarizeConfig) {
+ super({})
+ this.client = createAnthropicClient(config)
+ }
+
+ async summarize(options: SummarizationOptions): Promise {
+ const systemPrompt = this.buildSummarizationPrompt(options)
+
+ const response = await this.client.messages.create({
+ model: options.model,
+ messages: [{ role: 'user', content: options.text }],
+ system: systemPrompt,
+ max_tokens: options.maxLength || 500,
+ temperature: 0.3,
+ stream: false,
+ })
+
+ const content = response.content
+ .map((c) => (c.type === 'text' ? c.text : ''))
+ .join('')
+
+ return {
+ id: response.id,
+ model: response.model,
+ summary: content,
+ usage: {
+ promptTokens: response.usage.input_tokens,
+ completionTokens: response.usage.output_tokens,
+ totalTokens: response.usage.input_tokens + response.usage.output_tokens,
+ },
+ }
+ }
+
+ private buildSummarizationPrompt(options: SummarizationOptions): string {
+ let prompt = 'You are a professional summarizer. '
+
+ switch (options.style) {
+ case 'bullet-points':
+ prompt += 'Provide a summary in bullet point format. '
+ break
+ case 'paragraph':
+ prompt += 'Provide a summary in paragraph format. '
+ break
+ case 'concise':
+ prompt += 'Provide a very concise summary in 1-2 sentences. '
+ break
+ default:
+ prompt += 'Provide a clear and concise summary. '
+ }
+
+ if (options.focus && options.focus.length > 0) {
+ prompt += `Focus on the following aspects: ${options.focus.join(', ')}. `
+ }
+
+ if (options.maxLength) {
+ prompt += `Keep the summary under ${options.maxLength} tokens. `
+ }
+
+ return prompt
+ }
+}
+
+/**
+ * Creates an Anthropic summarize adapter with explicit API key
+ */
+export function createAnthropicSummarize(
+ apiKey: string,
+ config?: Omit,
+): AnthropicSummarizeAdapter {
+ return new AnthropicSummarizeAdapter({ apiKey, ...config })
+}
+
+/**
+ * Creates an Anthropic summarize adapter with automatic API key detection
+ */
+export function anthropicSummarize(
+ config?: Omit,
+): AnthropicSummarizeAdapter {
+ const apiKey = getAnthropicApiKeyFromEnv()
+ return createAnthropicSummarize(apiKey, config)
+}
diff --git a/packages/typescript/ai-anthropic/src/adapters/text.ts b/packages/typescript/ai-anthropic/src/adapters/text.ts
new file mode 100644
index 00000000..92ed8219
--- /dev/null
+++ b/packages/typescript/ai-anthropic/src/adapters/text.ts
@@ -0,0 +1,620 @@
+import { BaseTextAdapter } from '@tanstack/ai/adapters'
+import { ANTHROPIC_MODELS } from '../model-meta'
+import { convertToolsToProviderFormat } from '../tools/tool-converter'
+import { validateTextProviderOptions } from '../text/text-provider-options'
+import {
+ convertZodToAnthropicSchema,
+ createAnthropicClient,
+ generateId,
+ getAnthropicApiKeyFromEnv,
+} from '../utils'
+import type {
+ StructuredOutputOptions,
+ StructuredOutputResult,
+} from '@tanstack/ai/adapters'
+import type {
+ Base64ImageSource,
+ Base64PDFSource,
+ DocumentBlockParam,
+ ImageBlockParam,
+ MessageParam,
+ TextBlockParam,
+ URLImageSource,
+ URLPDFSource,
+} from '@anthropic-ai/sdk/resources/messages'
+import type Anthropic_SDK from '@anthropic-ai/sdk'
+import type {
+ ContentPart,
+ ModelMessage,
+ StreamChunk,
+ TextOptions,
+} from '@tanstack/ai'
+import type {
+ AnthropicChatModelProviderOptionsByName,
+ AnthropicModelInputModalitiesByName,
+} from '../model-meta'
+import type {
+ ExternalTextProviderOptions,
+ InternalTextProviderOptions,
+} from '../text/text-provider-options'
+import type {
+ AnthropicDocumentMetadata,
+ AnthropicImageMetadata,
+ AnthropicMessageMetadataByModality,
+ AnthropicTextMetadata,
+} from '../message-types'
+import type { AnthropicClientConfig } from '../utils'
+
+/**
+ * Configuration for Anthropic text adapter
+ */
+export interface AnthropicTextConfig extends AnthropicClientConfig {}
+
+/**
+ * Anthropic-specific provider options for text/chat
+ */
+export type AnthropicTextProviderOptions = ExternalTextProviderOptions
+
+type AnthropicContentBlocks =
+ Extract> extends Array
+ ? Array
+ : never
+type AnthropicContentBlock =
+ AnthropicContentBlocks extends Array ? Block : never
+
+/**
+ * Anthropic Text (Chat) Adapter
+ *
+ * Tree-shakeable adapter for Anthropic chat/text completion functionality.
+ * Import only what you need for smaller bundle sizes.
+ */
+export class AnthropicTextAdapter extends BaseTextAdapter<
+ typeof ANTHROPIC_MODELS,
+ AnthropicTextProviderOptions,
+ AnthropicChatModelProviderOptionsByName,
+ AnthropicModelInputModalitiesByName,
+ AnthropicMessageMetadataByModality
+> {
+ readonly kind = 'text' as const
+ readonly name = 'anthropic' as const
+ readonly models = ANTHROPIC_MODELS
+
+ declare _modelProviderOptionsByName: AnthropicChatModelProviderOptionsByName
+ declare _modelInputModalitiesByName: AnthropicModelInputModalitiesByName
+ declare _messageMetadataByModality: AnthropicMessageMetadataByModality
+
+ private client: Anthropic_SDK
+
+ constructor(config: AnthropicTextConfig) {
+ super({})
+ this.client = createAnthropicClient(config)
+ }
+
+ async *chatStream(
+ options: TextOptions,
+ ): AsyncIterable {
+ try {
+ const requestParams = this.mapCommonOptionsToAnthropic(options)
+
+ const stream = await this.client.beta.messages.create(
+ { ...requestParams, stream: true },
+ {
+ signal: options.request?.signal,
+ headers: options.request?.headers,
+ },
+ )
+
+ yield* this.processAnthropicStream(stream, options.model, () =>
+ generateId(this.name),
+ )
+ } catch (error: unknown) {
+ const err = error as Error & { status?: number; code?: string }
+ yield {
+ type: 'error',
+ id: generateId(this.name),
+ model: options.model,
+ timestamp: Date.now(),
+ error: {
+ message: err.message || 'Unknown error occurred',
+ code: err.code || String(err.status),
+ },
+ }
+ }
+ }
+
+ /**
+ * Generate structured output using Anthropic's tool-based approach.
+ * Anthropic doesn't have native structured output, so we use a tool with the schema
+ * and force the model to call it.
+ */
+ async structuredOutput(
+ options: StructuredOutputOptions,
+ ): Promise> {
+ const { chatOptions, outputSchema } = options
+
+ // Convert Zod schema to Anthropic-compatible JSON Schema
+ const jsonSchema = convertZodToAnthropicSchema(outputSchema)
+
+ const requestParams = this.mapCommonOptionsToAnthropic(chatOptions)
+
+ // Create a tool that will capture the structured output
+ // Ensure the schema has type: 'object' as required by Anthropic's SDK
+ const inputSchema = {
+ type: 'object' as const,
+ ...jsonSchema,
+ }
+
+ const structuredOutputTool = {
+ name: 'structured_output',
+ description:
+ 'Use this tool to provide your response in the required structured format.',
+ input_schema: inputSchema,
+ }
+
+ try {
+ // Make non-streaming request with tool_choice forced to our structured output tool
+ const response = await this.client.messages.create(
+ {
+ ...requestParams,
+ stream: false,
+ tools: [structuredOutputTool],
+ tool_choice: { type: 'tool', name: 'structured_output' },
+ },
+ {
+ signal: chatOptions.request?.signal,
+ headers: chatOptions.request?.headers,
+ },
+ )
+
+ // Extract the tool use content from the response
+ let parsed: unknown = null
+ let rawText = ''
+
+ for (const block of response.content) {
+ if (block.type === 'tool_use' && block.name === 'structured_output') {
+ parsed = block.input
+ rawText = JSON.stringify(block.input)
+ break
+ }
+ }
+
+ if (parsed === null) {
+ // Fallback: try to extract text content and parse as JSON
+ rawText = response.content
+ .map((b) => {
+ if (b.type === 'text') {
+ return b.text
+ }
+ return ''
+ })
+ .join('')
+ try {
+ parsed = JSON.parse(rawText)
+ } catch {
+ throw new Error(
+ `Failed to extract structured output from response. Content: ${rawText.slice(0, 200)}${rawText.length > 200 ? '...' : ''}`,
+ )
+ }
+ }
+
+ return {
+ data: parsed,
+ rawText,
+ }
+ } catch (error: unknown) {
+ const err = error as Error
+ throw new Error(
+ `Structured output generation failed: ${err.message || 'Unknown error occurred'}`,
+ )
+ }
+ }
+
+ private mapCommonOptionsToAnthropic(
+ options: TextOptions,
+ ) {
+ const providerOptions = options.providerOptions as
+ | InternalTextProviderOptions
+ | undefined
+
+ const formattedMessages = this.formatMessages(options.messages)
+ const tools = options.tools
+ ? convertToolsToProviderFormat(options.tools)
+ : undefined
+
+ const validProviderOptions: Partial = {}
+ if (providerOptions) {
+ const validKeys: Array = [
+ 'container',
+ 'context_management',
+ 'mcp_servers',
+ 'service_tier',
+ 'stop_sequences',
+ 'system',
+ 'thinking',
+ 'tool_choice',
+ 'top_k',
+ ]
+ for (const key of validKeys) {
+ if (key in providerOptions) {
+ const value = providerOptions[key]
+ if (key === 'tool_choice' && typeof value === 'string') {
+ ;(validProviderOptions as Record)[key] = {
+ type: value,
+ }
+ } else {
+ ;(validProviderOptions as Record)[key] = value
+ }
+ }
+ }
+ }
+
+ const thinkingBudget =
+ validProviderOptions.thinking?.type === 'enabled'
+ ? validProviderOptions.thinking.budget_tokens
+ : undefined
+ const defaultMaxTokens = options.options?.maxTokens || 1024
+ const maxTokens =
+ thinkingBudget && thinkingBudget >= defaultMaxTokens
+ ? thinkingBudget + 1
+ : defaultMaxTokens
+
+ const requestParams: InternalTextProviderOptions = {
+ model: options.model,
+ max_tokens: maxTokens,
+ temperature: options.options?.temperature,
+ top_p: options.options?.topP,
+ messages: formattedMessages,
+ system: options.systemPrompts?.join('\n'),
+ tools: tools,
+ ...validProviderOptions,
+ }
+ validateTextProviderOptions(requestParams)
+ return requestParams
+ }
+
+ private convertContentPartToAnthropic(
+ part: ContentPart,
+ ): TextBlockParam | ImageBlockParam | DocumentBlockParam {
+ switch (part.type) {
+ case 'text': {
+ const metadata = part.metadata as AnthropicTextMetadata | undefined
+ return {
+ type: 'text',
+ text: part.content,
+ ...metadata,
+ }
+ }
+
+ case 'image': {
+ const metadata = part.metadata as AnthropicImageMetadata | undefined
+ const imageSource: Base64ImageSource | URLImageSource =
+ part.source.type === 'data'
+ ? {
+ type: 'base64',
+ data: part.source.value,
+ media_type: metadata?.mediaType ?? 'image/jpeg',
+ }
+ : {
+ type: 'url',
+ url: part.source.value,
+ }
+ const { mediaType: _mediaType, ...meta } = metadata || {}
+ return {
+ type: 'image',
+ source: imageSource,
+ ...meta,
+ }
+ }
+ case 'document': {
+ const metadata = part.metadata as AnthropicDocumentMetadata | undefined
+ const docSource: Base64PDFSource | URLPDFSource =
+ part.source.type === 'data'
+ ? {
+ type: 'base64',
+ data: part.source.value,
+ media_type: 'application/pdf',
+ }
+ : {
+ type: 'url',
+ url: part.source.value,
+ }
+ return {
+ type: 'document',
+ source: docSource,
+ ...metadata,
+ }
+ }
+ case 'audio':
+ case 'video':
+ throw new Error(
+ `Anthropic does not support ${part.type} content directly`,
+ )
+ default: {
+ const _exhaustiveCheck: never = part
+ throw new Error(
+ `Unsupported content part type: ${(_exhaustiveCheck as ContentPart).type}`,
+ )
+ }
+ }
+ }
+
+ private formatMessages(
+ messages: Array,
+ ): InternalTextProviderOptions['messages'] {
+ const formattedMessages: InternalTextProviderOptions['messages'] = []
+
+ for (const message of messages) {
+ const role = message.role
+
+ if (role === 'tool' && message.toolCallId) {
+ formattedMessages.push({
+ role: 'user',
+ content: [
+ {
+ type: 'tool_result',
+ tool_use_id: message.toolCallId,
+ content:
+ typeof message.content === 'string' ? message.content : '',
+ },
+ ],
+ })
+ continue
+ }
+
+ if (role === 'assistant' && message.toolCalls?.length) {
+ const contentBlocks: AnthropicContentBlocks = []
+
+ if (message.content) {
+ const content =
+ typeof message.content === 'string' ? message.content : ''
+ const textBlock: AnthropicContentBlock = {
+ type: 'text',
+ text: content,
+ }
+ contentBlocks.push(textBlock)
+ }
+
+ for (const toolCall of message.toolCalls) {
+ let parsedInput: unknown = {}
+ try {
+ parsedInput = toolCall.function.arguments
+ ? JSON.parse(toolCall.function.arguments)
+ : {}
+ } catch {
+ parsedInput = toolCall.function.arguments
+ }
+
+ const toolUseBlock: AnthropicContentBlock = {
+ type: 'tool_use',
+ id: toolCall.id,
+ name: toolCall.function.name,
+ input: parsedInput,
+ }
+ contentBlocks.push(toolUseBlock)
+ }
+
+ formattedMessages.push({
+ role: 'assistant',
+ content: contentBlocks,
+ })
+
+ continue
+ }
+
+ if (role === 'user' && Array.isArray(message.content)) {
+ const contentBlocks = message.content.map((part) =>
+ this.convertContentPartToAnthropic(part),
+ )
+ formattedMessages.push({
+ role: 'user',
+ content: contentBlocks,
+ })
+ continue
+ }
+
+ formattedMessages.push({
+ role: role === 'assistant' ? 'assistant' : 'user',
+ content:
+ typeof message.content === 'string'
+ ? message.content
+ : message.content
+ ? message.content.map((c) =>
+ this.convertContentPartToAnthropic(c),
+ )
+ : '',
+ })
+ }
+
+ return formattedMessages
+ }
+
+ private async *processAnthropicStream(
+ stream: AsyncIterable,
+ model: string,
+ genId: () => string,
+ ): AsyncIterable {
+ let accumulatedContent = ''
+ let accumulatedThinking = ''
+ const timestamp = Date.now()
+ const toolCallsMap = new Map<
+ number,
+ { id: string; name: string; input: string }
+ >()
+ let currentToolIndex = -1
+
+ try {
+ for await (const event of stream) {
+ if (event.type === 'content_block_start') {
+ if (event.content_block.type === 'tool_use') {
+ currentToolIndex++
+ toolCallsMap.set(currentToolIndex, {
+ id: event.content_block.id,
+ name: event.content_block.name,
+ input: '',
+ })
+ } else if (event.content_block.type === 'thinking') {
+ accumulatedThinking = ''
+ }
+ } else if (event.type === 'content_block_delta') {
+ if (event.delta.type === 'text_delta') {
+ const delta = event.delta.text
+ accumulatedContent += delta
+ yield {
+ type: 'content',
+ id: genId(),
+ model: model,
+ timestamp,
+ delta,
+ content: accumulatedContent,
+ role: 'assistant',
+ }
+ } else if (event.delta.type === 'thinking_delta') {
+ const delta = event.delta.thinking
+ accumulatedThinking += delta
+ yield {
+ type: 'thinking',
+ id: genId(),
+ model: model,
+ timestamp,
+ delta,
+ content: accumulatedThinking,
+ }
+ } else if (event.delta.type === 'input_json_delta') {
+ const existing = toolCallsMap.get(currentToolIndex)
+ if (existing) {
+ existing.input += event.delta.partial_json
+
+ yield {
+ type: 'tool_call',
+ id: genId(),
+ model: model,
+ timestamp,
+ toolCall: {
+ id: existing.id,
+ type: 'function',
+ function: {
+ name: existing.name,
+ arguments: event.delta.partial_json,
+ },
+ },
+ index: currentToolIndex,
+ }
+ }
+ }
+ } else if (event.type === 'content_block_stop') {
+ const existing = toolCallsMap.get(currentToolIndex)
+ if (existing && existing.input === '') {
+ yield {
+ type: 'tool_call',
+ id: genId(),
+ model: model,
+ timestamp,
+ toolCall: {
+ id: existing.id,
+ type: 'function',
+ function: {
+ name: existing.name,
+ arguments: '{}',
+ },
+ },
+ index: currentToolIndex,
+ }
+ }
+ } else if (event.type === 'message_stop') {
+ yield {
+ type: 'done',
+ id: genId(),
+ model: model,
+ timestamp,
+ finishReason: 'stop',
+ }
+ } else if (event.type === 'message_delta') {
+ if (event.delta.stop_reason) {
+ switch (event.delta.stop_reason) {
+ case 'tool_use': {
+ yield {
+ type: 'done',
+ id: genId(),
+ model: model,
+ timestamp,
+ finishReason: 'tool_calls',
+ usage: {
+ promptTokens: event.usage.input_tokens || 0,
+ completionTokens: event.usage.output_tokens || 0,
+ totalTokens:
+ (event.usage.input_tokens || 0) +
+ (event.usage.output_tokens || 0),
+ },
+ }
+ break
+ }
+ case 'max_tokens': {
+ yield {
+ type: 'error',
+ id: genId(),
+ model: model,
+ timestamp,
+ error: {
+ message:
+ 'The response was cut off because the maximum token limit was reached.',
+ code: 'max_tokens',
+ },
+ }
+ break
+ }
+ default: {
+ yield {
+ type: 'done',
+ id: genId(),
+ model: model,
+ timestamp,
+ finishReason: 'stop',
+ usage: {
+ promptTokens: event.usage.input_tokens || 0,
+ completionTokens: event.usage.output_tokens || 0,
+ totalTokens:
+ (event.usage.input_tokens || 0) +
+ (event.usage.output_tokens || 0),
+ },
+ }
+ }
+ }
+ }
+ }
+ }
+ } catch (error: unknown) {
+ const err = error as Error & { status?: number; code?: string }
+
+ yield {
+ type: 'error',
+ id: genId(),
+ model: model,
+ timestamp,
+ error: {
+ message: err.message || 'Unknown error occurred',
+ code: err.code || String(err.status),
+ },
+ }
+ }
+ }
+}
+
+/**
+ * Creates an Anthropic text adapter with explicit API key
+ */
+export function createAnthropicText(
+ apiKey: string,
+ config?: Omit,
+): AnthropicTextAdapter {
+ return new AnthropicTextAdapter({ apiKey, ...config })
+}
+
+/**
+ * Creates an Anthropic text adapter with automatic API key detection
+ */
+export function anthropicText(
+ config?: Omit,
+): AnthropicTextAdapter {
+ const apiKey = getAnthropicApiKeyFromEnv()
+ return createAnthropicText(apiKey, config)
+}
diff --git a/packages/typescript/ai-anthropic/src/anthropic-adapter.ts b/packages/typescript/ai-anthropic/src/anthropic-adapter.ts
index edfcea79..b95acd90 100644
--- a/packages/typescript/ai-anthropic/src/anthropic-adapter.ts
+++ b/packages/typescript/ai-anthropic/src/anthropic-adapter.ts
@@ -10,7 +10,6 @@ import type {
AnthropicTextMetadata,
} from './message-types'
import type {
- ChatOptions,
ContentPart,
EmbeddingOptions,
EmbeddingResult,
@@ -18,6 +17,7 @@ import type {
StreamChunk,
SummarizationOptions,
SummarizationResult,
+ TextOptions,
} from '@tanstack/ai'
import type {
AnthropicChatModelProviderOptionsByName,
@@ -81,7 +81,7 @@ export class Anthropic extends BaseAdapter<
}
async *chatStream(
- options: ChatOptions,
+ options: TextOptions,
): AsyncIterable {
try {
// Map common options to Anthropic format using the centralized mapping function
@@ -192,7 +192,7 @@ export class Anthropic extends BaseAdapter<
* Handles translation of normalized options to Anthropic's API format
*/
private mapCommonOptionsToAnthropic(
- options: ChatOptions,
+ options: TextOptions,
) {
const providerOptions = options.providerOptions as
| InternalTextProviderOptions
diff --git a/packages/typescript/ai-anthropic/src/index.ts b/packages/typescript/ai-anthropic/src/index.ts
index b4580b15..096bf95a 100644
--- a/packages/typescript/ai-anthropic/src/index.ts
+++ b/packages/typescript/ai-anthropic/src/index.ts
@@ -1,9 +1,46 @@
+// ============================================================================
+// New Tree-Shakeable Adapters (Recommended)
+// ============================================================================
+
+// Text (Chat) adapter - for chat/text completion
+export {
+ AnthropicTextAdapter,
+ anthropicText,
+ createAnthropicText,
+ type AnthropicTextConfig,
+ type AnthropicTextProviderOptions,
+} from './adapters/text'
+
+// Summarize adapter - for text summarization
+export {
+ AnthropicSummarizeAdapter,
+ anthropicSummarize,
+ createAnthropicSummarize,
+ type AnthropicSummarizeConfig,
+ type AnthropicSummarizeProviderOptions,
+} from './adapters/summarize'
+
+// Note: Anthropic does not support embeddings natively
+
+// ============================================================================
+// Legacy Exports (Deprecated - will be removed in future versions)
+// ============================================================================
+
+/**
+ * @deprecated Use `anthropicText()` or `anthropicSummarize()` instead.
+ * This monolithic adapter will be removed in a future version.
+ */
export {
Anthropic,
createAnthropic,
anthropic,
type AnthropicConfig,
} from './anthropic-adapter'
+
+// ============================================================================
+// Type Exports
+// ============================================================================
+
export type {
AnthropicChatModelProviderOptionsByName,
AnthropicModelInputModalitiesByName,
diff --git a/packages/typescript/ai-anthropic/src/tools/custom-tool.ts b/packages/typescript/ai-anthropic/src/tools/custom-tool.ts
index 07ac10a3..05f96dd8 100644
--- a/packages/typescript/ai-anthropic/src/tools/custom-tool.ts
+++ b/packages/typescript/ai-anthropic/src/tools/custom-tool.ts
@@ -1,4 +1,4 @@
-import { convertZodToJsonSchema } from '@tanstack/ai'
+import { convertZodToAnthropicSchema } from '../utils/schema-converter'
import type { Tool } from '@tanstack/ai'
import type { z } from 'zod'
import type { CacheControl } from '../text/text-provider-options'
@@ -29,13 +29,15 @@ export function convertCustomToolToAdapterFormat(tool: Tool): CustomTool {
const metadata =
(tool.metadata as { cacheControl?: CacheControl | null } | undefined) || {}
- // Convert Zod schema to JSON Schema
- const jsonSchema = convertZodToJsonSchema(tool.inputSchema)
+ // Convert Zod schema to Anthropic-compatible JSON Schema
+ const jsonSchema = tool.inputSchema
+ ? convertZodToAnthropicSchema(tool.inputSchema)
+ : { type: 'object', properties: {}, required: [] }
const inputSchema = {
type: 'object' as const,
- properties: jsonSchema?.properties || null,
- required: jsonSchema?.required || null,
+ properties: jsonSchema.properties || null,
+ required: jsonSchema.required || null,
}
return {
diff --git a/packages/typescript/ai-anthropic/src/utils/client.ts b/packages/typescript/ai-anthropic/src/utils/client.ts
new file mode 100644
index 00000000..dddc5caf
--- /dev/null
+++ b/packages/typescript/ai-anthropic/src/utils/client.ts
@@ -0,0 +1,45 @@
+import Anthropic_SDK from '@anthropic-ai/sdk'
+
+export interface AnthropicClientConfig {
+ apiKey: string
+}
+
+/**
+ * Creates an Anthropic SDK client instance
+ */
+export function createAnthropicClient(
+ config: AnthropicClientConfig,
+): Anthropic_SDK {
+ return new Anthropic_SDK({
+ apiKey: config.apiKey,
+ })
+}
+
+/**
+ * Gets Anthropic API key from environment variables
+ * @throws Error if ANTHROPIC_API_KEY is not found
+ */
+export function getAnthropicApiKeyFromEnv(): string {
+ const env =
+ typeof globalThis !== 'undefined' && (globalThis as any).window?.env
+ ? (globalThis as any).window.env
+ : typeof process !== 'undefined'
+ ? process.env
+ : undefined
+ const key = env?.ANTHROPIC_API_KEY
+
+ if (!key) {
+ throw new Error(
+ 'ANTHROPIC_API_KEY is required. Please set it in your environment variables or use the factory function with an explicit API key.',
+ )
+ }
+
+ return key
+}
+
+/**
+ * Generates a unique ID with a prefix
+ */
+export function generateId(prefix: string): string {
+ return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(7)}`
+}
diff --git a/packages/typescript/ai-anthropic/src/utils/index.ts b/packages/typescript/ai-anthropic/src/utils/index.ts
new file mode 100644
index 00000000..b6e55f53
--- /dev/null
+++ b/packages/typescript/ai-anthropic/src/utils/index.ts
@@ -0,0 +1,7 @@
+export {
+ createAnthropicClient,
+ generateId,
+ getAnthropicApiKeyFromEnv,
+ type AnthropicClientConfig,
+} from './client'
+export { convertZodToAnthropicSchema } from './schema-converter'
diff --git a/packages/typescript/ai-anthropic/src/utils/schema-converter.ts b/packages/typescript/ai-anthropic/src/utils/schema-converter.ts
new file mode 100644
index 00000000..5e17ecfa
--- /dev/null
+++ b/packages/typescript/ai-anthropic/src/utils/schema-converter.ts
@@ -0,0 +1,87 @@
+import { toJSONSchema } from 'zod'
+import type { z } from 'zod'
+
+/**
+ * Check if a value is a Zod schema by looking for Zod-specific internals.
+ * Zod schemas have a `_zod` property that contains metadata.
+ */
+function isZodSchema(schema: unknown): schema is z.ZodType {
+ return (
+ typeof schema === 'object' &&
+ schema !== null &&
+ '_zod' in schema &&
+ typeof (schema as any)._zod === 'object'
+ )
+}
+
+/**
+ * Converts a Zod schema to JSON Schema format compatible with Anthropic's API.
+ *
+ * Anthropic accepts standard JSON Schema without special transformations.
+ *
+ * @param schema - Zod schema to convert
+ * @returns JSON Schema object compatible with Anthropic's structured output API
+ *
+ * @example
+ * ```typescript
+ * import { z } from 'zod';
+ *
+ * const zodSchema = z.object({
+ * location: z.string().describe('City name'),
+ * unit: z.enum(['celsius', 'fahrenheit']).optional()
+ * });
+ *
+ * const jsonSchema = convertZodToAnthropicSchema(zodSchema);
+ * // Returns standard JSON Schema
+ * ```
+ */
+export function convertZodToAnthropicSchema(
+ schema: z.ZodType,
+): Record {
+ if (!isZodSchema(schema)) {
+ throw new Error('Expected a Zod schema')
+ }
+
+ // Use Zod's built-in toJSONSchema
+ const jsonSchema = toJSONSchema(schema, {
+ target: 'openapi-3.0',
+ reused: 'ref',
+ })
+
+ // Remove $schema property as it's not needed for LLM providers
+ let result = jsonSchema
+ if (typeof result === 'object' && '$schema' in result) {
+ const { $schema, ...rest } = result
+ result = rest
+ }
+
+ // Ensure object schemas always have type: "object"
+ if (typeof result === 'object') {
+ const isZodObject =
+ typeof schema === 'object' &&
+ 'def' in schema &&
+ schema.def.type === 'object'
+
+ if (isZodObject && !result.type) {
+ result.type = 'object'
+ }
+
+ if (Object.keys(result).length === 0) {
+ result.type = 'object'
+ }
+
+ if ('properties' in result && !result.type) {
+ result.type = 'object'
+ }
+
+ if (result.type === 'object' && !('properties' in result)) {
+ result.properties = {}
+ }
+
+ if (result.type === 'object' && !('required' in result)) {
+ result.required = []
+ }
+ }
+
+ return result
+}
diff --git a/packages/typescript/ai-anthropic/tests/anthropic-adapter.test.ts b/packages/typescript/ai-anthropic/tests/anthropic-adapter.test.ts
index 934a9204..5c9f4f4a 100644
--- a/packages/typescript/ai-anthropic/tests/anthropic-adapter.test.ts
+++ b/packages/typescript/ai-anthropic/tests/anthropic-adapter.test.ts
@@ -1,9 +1,7 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
-import { chat, type Tool, type StreamChunk } from '@tanstack/ai'
-import {
- Anthropic,
- type AnthropicProviderOptions,
-} from '../src/anthropic-adapter'
+import { ai, type Tool, type StreamChunk } from '@tanstack/ai'
+import { AnthropicTextAdapter } from '../src/adapters/text'
+import type { AnthropicProviderOptions } from '../src/anthropic-adapter'
import { z } from 'zod'
const mocks = vi.hoisted(() => {
@@ -37,7 +35,7 @@ vi.mock('@anthropic-ai/sdk', () => {
return { default: MockAnthropic }
})
-const createAdapter = () => new Anthropic({ apiKey: 'test-key' })
+const createAdapter = () => new AnthropicTextAdapter({ apiKey: 'test-key' })
const toolArguments = JSON.stringify({ location: 'Berlin' })
@@ -107,7 +105,7 @@ describe('Anthropic adapter option mapping', () => {
// Consume the stream to trigger the API call
const chunks: StreamChunk[] = []
- for await (const chunk of chat({
+ for await (const chunk of ai({
adapter,
model: 'claude-3-7-sonnet-20250219',
messages: [
diff --git a/packages/typescript/ai-client/package.json b/packages/typescript/ai-client/package.json
index 43f17818..00c52dbb 100644
--- a/packages/typescript/ai-client/package.json
+++ b/packages/typescript/ai-client/package.json
@@ -47,7 +47,7 @@
},
"devDependencies": {
"@vitest/coverage-v8": "4.0.14",
- "vite": "^7.2.4",
+ "vite": "^7.2.7",
"zod": "^4.1.13"
}
}
diff --git a/packages/typescript/ai-client/src/chat-client.ts b/packages/typescript/ai-client/src/chat-client.ts
index 3b9e1787..1d1ba091 100644
--- a/packages/typescript/ai-client/src/chat-client.ts
+++ b/packages/typescript/ai-client/src/chat-client.ts
@@ -26,6 +26,7 @@ export class ChatClient {
private clientToolsRef: { current: Map }
private currentStreamId: string | null = null
private currentMessageId: string | null = null
+ private postStreamActions: Array<() => Promise> = []
private callbacksRef: {
current: {
@@ -323,6 +324,9 @@ export class ChatClient {
} finally {
this.abortController = null
this.setIsLoading(false)
+
+ // Drain any actions that were queued while the stream was in progress
+ await this.drainPostStreamActions()
}
}
@@ -394,10 +398,13 @@ export class ChatClient {
result.errorText,
)
- // Check if we should auto-send
- if (this.shouldAutoSend()) {
- await this.continueFlow()
+ // If stream is in progress, queue continuation check for after it ends
+ if (this.isLoading) {
+ this.queuePostStreamAction(() => this.checkForContinuation())
+ return
}
+
+ await this.checkForContinuation()
}
/**
@@ -433,18 +440,39 @@ export class ChatClient {
// Add response via processor
this.processor.addToolApprovalResponse(response.id, response.approved)
- // Check if we should auto-send
- if (this.shouldAutoSend()) {
- await this.continueFlow()
+ // If stream is in progress, queue continuation check for after it ends
+ if (this.isLoading) {
+ this.queuePostStreamAction(() => this.checkForContinuation())
+ return
}
+
+ await this.checkForContinuation()
}
/**
- * Continue the agent flow with current messages
+ * Queue an action to be executed after the current stream ends
*/
- private async continueFlow(): Promise {
- if (this.isLoading) return
- await this.streamResponse()
+ private queuePostStreamAction(action: () => Promise): void {
+ this.postStreamActions.push(action)
+ }
+
+ /**
+ * Drain and execute all queued post-stream actions
+ */
+ private async drainPostStreamActions(): Promise {
+ while (this.postStreamActions.length > 0) {
+ const action = this.postStreamActions.shift()!
+ await action()
+ }
+ }
+
+ /**
+ * Check if we should continue the flow and do so if needed
+ */
+ private async checkForContinuation(): Promise {
+ if (this.shouldAutoSend()) {
+ await this.streamResponse()
+ }
}
/**
diff --git a/packages/typescript/ai-devtools/package.json b/packages/typescript/ai-devtools/package.json
index 94734697..c27e8353 100644
--- a/packages/typescript/ai-devtools/package.json
+++ b/packages/typescript/ai-devtools/package.json
@@ -55,7 +55,7 @@
"devDependencies": {
"@vitest/coverage-v8": "4.0.14",
"jsdom": "^27.2.0",
- "vite": "^7.2.4",
+ "vite": "^7.2.7",
"vite-plugin-solid": "^2.11.10"
}
}
diff --git a/packages/typescript/ai-devtools/src/store/ai-context.tsx b/packages/typescript/ai-devtools/src/store/ai-context.tsx
index 42af6917..a5e683fb 100644
--- a/packages/typescript/ai-devtools/src/store/ai-context.tsx
+++ b/packages/typescript/ai-devtools/src/store/ai-context.tsx
@@ -1309,7 +1309,7 @@ export const AIProvider: ParentComponent = (props) => {
// ============= Chat Events (for usage tracking) =============
cleanupFns.push(
- aiEventClient.on('chat:started', (e) => {
+ aiEventClient.on('text:started', (e) => {
const streamId = e.payload.streamId
const model = e.payload.model
const provider = e.payload.provider
@@ -1350,7 +1350,7 @@ export const AIProvider: ParentComponent = (props) => {
)
cleanupFns.push(
- aiEventClient.on('chat:completed', (e) => {
+ aiEventClient.on('text:completed', (e) => {
const { requestId, usage } = e.payload
const conversationId = requestToConversation.get(requestId)
@@ -1371,7 +1371,7 @@ export const AIProvider: ParentComponent = (props) => {
)
cleanupFns.push(
- aiEventClient.on('chat:iteration', (e) => {
+ aiEventClient.on('text:iteration', (e) => {
const { requestId, iterationNumber } = e.payload
const conversationId = requestToConversation.get(requestId)
diff --git a/packages/typescript/ai-devtools/vite.config.ts b/packages/typescript/ai-devtools/vite.config.ts
index 6fd73cc1..25e36f4a 100644
--- a/packages/typescript/ai-devtools/vite.config.ts
+++ b/packages/typescript/ai-devtools/vite.config.ts
@@ -4,7 +4,7 @@ import solid from 'vite-plugin-solid'
import packageJson from './package.json'
const config = defineConfig({
- plugins: [solid()],
+ plugins: [solid() as any],
test: {
name: packageJson.name,
dir: './tests',
diff --git a/packages/typescript/ai-gemini/package.json b/packages/typescript/ai-gemini/package.json
index 7dc0491b..f6e0fedb 100644
--- a/packages/typescript/ai-gemini/package.json
+++ b/packages/typescript/ai-gemini/package.json
@@ -45,9 +45,10 @@
},
"devDependencies": {
"@vitest/coverage-v8": "4.0.14",
- "vite": "^7.2.4"
+ "vite": "^7.2.7"
},
"peerDependencies": {
- "@tanstack/ai": "workspace:*"
+ "@tanstack/ai": "workspace:*",
+ "zod": "^4.0.0"
}
}
diff --git a/packages/typescript/ai-gemini/src/adapters/embed.ts b/packages/typescript/ai-gemini/src/adapters/embed.ts
new file mode 100644
index 00000000..f90d9b98
--- /dev/null
+++ b/packages/typescript/ai-gemini/src/adapters/embed.ts
@@ -0,0 +1,118 @@
+import { createGeminiClient, getGeminiApiKeyFromEnv } from '../utils'
+
+import type { GoogleGenAI } from '@google/genai'
+import type { EmbeddingAdapter } from '@tanstack/ai/adapters'
+import type { EmbeddingOptions, EmbeddingResult } from '@tanstack/ai'
+
+/**
+ * Available Gemini embedding models
+ */
+export const GeminiEmbeddingModels = [
+ 'text-embedding-004',
+ 'embedding-001',
+] as const
+
+export type GeminiEmbeddingModel = (typeof GeminiEmbeddingModels)[number]
+
+/**
+ * Provider-specific options for Gemini embeddings
+ */
+export interface GeminiEmbedProviderOptions {
+ taskType?:
+ | 'RETRIEVAL_QUERY'
+ | 'RETRIEVAL_DOCUMENT'
+ | 'SEMANTIC_SIMILARITY'
+ | 'CLASSIFICATION'
+ | 'CLUSTERING'
+ title?: string
+ outputDimensionality?: number
+}
+
+export interface GeminiEmbedAdapterOptions {
+ model?: GeminiEmbeddingModel
+}
+
+/**
+ * Gemini Embedding Adapter
+ * A tree-shakeable embedding adapter for Google Gemini
+ */
+export class GeminiEmbedAdapter implements EmbeddingAdapter<
+ typeof GeminiEmbeddingModels,
+ GeminiEmbedProviderOptions
+> {
+ readonly kind = 'embedding' as const
+ readonly name = 'gemini' as const
+ readonly models = GeminiEmbeddingModels
+
+ /** Type-only property for provider options inference */
+ declare _providerOptions?: GeminiEmbedProviderOptions
+
+ private client: GoogleGenAI
+ private defaultModel: GeminiEmbeddingModel
+
+ constructor(
+ apiKeyOrClient: string | GoogleGenAI,
+ options: GeminiEmbedAdapterOptions = {},
+ ) {
+ this.client =
+ typeof apiKeyOrClient === 'string'
+ ? createGeminiClient({ apiKey: apiKeyOrClient })
+ : apiKeyOrClient
+ this.defaultModel = options.model ?? 'text-embedding-004'
+ }
+
+ async createEmbeddings(options: EmbeddingOptions): Promise {
+ const model = options.model || this.defaultModel
+
+ // Ensure input is an array
+ const inputs = Array.isArray(options.input)
+ ? options.input
+ : [options.input]
+
+ const embeddings: Array> = []
+
+ for (const input of inputs) {
+ const response = await this.client.models.embedContent({
+ model,
+ contents: [{ role: 'user', parts: [{ text: input }] }],
+ config: {
+ outputDimensionality: options.dimensions,
+ },
+ })
+
+ if (response.embeddings?.[0]?.values) {
+ embeddings.push(response.embeddings[0].values)
+ }
+ }
+
+ return {
+ id: `embed-${Date.now()}`,
+ model,
+ embeddings,
+ usage: {
+ promptTokens: 0,
+ totalTokens: 0,
+ },
+ }
+ }
+}
+
+/**
+ * Creates a Gemini embedding adapter with explicit API key
+ */
+export function createGeminiEmbed(
+ apiKey: string,
+ options?: GeminiEmbedAdapterOptions,
+): GeminiEmbedAdapter {
+ return new GeminiEmbedAdapter(apiKey, options)
+}
+
+/**
+ * Creates a Gemini embedding adapter with API key from environment
+ */
+export function geminiEmbed(
+ options?: GeminiEmbedAdapterOptions,
+): GeminiEmbedAdapter {
+ const apiKey = getGeminiApiKeyFromEnv()
+ return new GeminiEmbedAdapter(apiKey, options)
+}
diff --git a/packages/typescript/ai-gemini/src/adapters/image.ts b/packages/typescript/ai-gemini/src/adapters/image.ts
new file mode 100644
index 00000000..f9212370
--- /dev/null
+++ b/packages/typescript/ai-gemini/src/adapters/image.ts
@@ -0,0 +1,176 @@
+import { BaseImageAdapter } from '@tanstack/ai/adapters'
+import { GEMINI_IMAGE_MODELS } from '../model-meta'
+import {
+ createGeminiClient,
+ generateId,
+ getGeminiApiKeyFromEnv,
+} from '../utils'
+import {
+ sizeToAspectRatio,
+ validateImageSize,
+ validateNumberOfImages,
+ validatePrompt,
+} from '../image/image-provider-options'
+import type {
+ GeminiImageModelProviderOptionsByName,
+ GeminiImageModelSizeByName,
+ GeminiImageProviderOptions,
+} from '../image/image-provider-options'
+import type {
+ GeneratedImage,
+ ImageGenerationOptions,
+ ImageGenerationResult,
+} from '@tanstack/ai'
+import type {
+ GenerateImagesConfig,
+ GenerateImagesResponse,
+ GoogleGenAI,
+} from '@google/genai'
+import type { GeminiClientConfig } from '../utils'
+
+/**
+ * Configuration for Gemini image adapter
+ */
+export interface GeminiImageConfig extends GeminiClientConfig {}
+
+/**
+ * Gemini Image Generation Adapter
+ *
+ * Tree-shakeable adapter for Gemini Imagen image generation functionality.
+ * Supports Imagen 3 and Imagen 4 models.
+ *
+ * Features:
+ * - Aspect ratio-based image sizing
+ * - Person generation controls
+ * - Safety filtering
+ * - Watermark options
+ */
+export class GeminiImageAdapter extends BaseImageAdapter<
+ typeof GEMINI_IMAGE_MODELS,
+ GeminiImageProviderOptions,
+ GeminiImageModelProviderOptionsByName,
+ GeminiImageModelSizeByName
+> {
+ readonly kind = 'image' as const
+ readonly name = 'gemini' as const
+ readonly models = GEMINI_IMAGE_MODELS
+
+ declare _modelProviderOptionsByName: GeminiImageModelProviderOptionsByName
+ declare _modelSizeByName: GeminiImageModelSizeByName
+
+ private client: GoogleGenAI
+
+ constructor(config: GeminiImageConfig) {
+ super({})
+ this.client = createGeminiClient(config)
+ }
+
+ async generateImages(
+ options: ImageGenerationOptions,
+ ): Promise {
+ const { model, prompt, numberOfImages, size } = options
+
+ // Validate inputs
+ validatePrompt({ prompt, model })
+ validateImageSize(model, size)
+ validateNumberOfImages(model, numberOfImages)
+
+ // Build request config
+ const config = this.buildConfig(options)
+
+ const response = await this.client.models.generateImages({
+ model,
+ prompt,
+ config,
+ })
+
+ return this.transformResponse(model, response)
+ }
+
+ private buildConfig(
+ options: ImageGenerationOptions,
+ ): GenerateImagesConfig {
+ const { size, numberOfImages, providerOptions } = options
+
+ return {
+ numberOfImages: numberOfImages ?? 1,
+ // Map size to aspect ratio if provided (providerOptions.aspectRatio will override)
+ aspectRatio: size ? sizeToAspectRatio(size) : undefined,
+ ...providerOptions,
+ }
+ }
+
+ private transformResponse(
+ model: string,
+ response: GenerateImagesResponse,
+ ): ImageGenerationResult {
+ const images: Array = (response.generatedImages ?? []).map(
+ (item) => ({
+ b64Json: item.image?.imageBytes,
+ revisedPrompt: item.enhancedPrompt,
+ }),
+ )
+
+ return {
+ id: generateId(this.name),
+ model,
+ images,
+ usage: undefined,
+ }
+ }
+}
+
+/**
+ * Creates a Gemini image adapter with explicit API key
+ *
+ * @param apiKey - Your Google API key
+ * @param config - Optional additional configuration
+ * @returns Configured Gemini image adapter instance
+ *
+ * @example
+ * ```typescript
+ * const adapter = createGeminiImage("your-api-key");
+ *
+ * const result = await ai({
+ * adapter,
+ * model: 'imagen-3.0-generate-002',
+ * prompt: 'A cute baby sea otter'
+ * });
+ * ```
+ */
+export function createGeminiImage(
+ apiKey: string,
+ config?: Omit,
+): GeminiImageAdapter {
+ return new GeminiImageAdapter({ apiKey, ...config })
+}
+
+/**
+ * Creates a Gemini image adapter with automatic API key detection from environment variables.
+ *
+ * Looks for `GOOGLE_API_KEY` or `GEMINI_API_KEY` in:
+ * - `process.env` (Node.js)
+ * - `window.env` (Browser with injected env)
+ *
+ * @param config - Optional configuration (excluding apiKey which is auto-detected)
+ * @returns Configured Gemini image adapter instance
+ * @throws Error if GOOGLE_API_KEY or GEMINI_API_KEY is not found in environment
+ *
+ * @example
+ * ```typescript
+ * // Automatically uses GOOGLE_API_KEY from environment
+ * const adapter = geminiImage();
+ *
+ * const result = await ai({
+ * adapter,
+ * model: 'imagen-4.0-generate-001',
+ * prompt: 'A beautiful sunset over mountains'
+ * });
+ * ```
+ */
+export function geminiImage(
+ config?: Omit,
+): GeminiImageAdapter {
+ const apiKey = getGeminiApiKeyFromEnv()
+ return createGeminiImage(apiKey, config)
+}
diff --git a/packages/typescript/ai-gemini/src/adapters/summarize.ts b/packages/typescript/ai-gemini/src/adapters/summarize.ts
new file mode 100644
index 00000000..0e4e2b21
--- /dev/null
+++ b/packages/typescript/ai-gemini/src/adapters/summarize.ts
@@ -0,0 +1,150 @@
+import {
+ createGeminiClient,
+ generateId,
+ getGeminiApiKeyFromEnv,
+} from '../utils'
+
+import type { GoogleGenAI } from '@google/genai'
+import type { SummarizeAdapter } from '@tanstack/ai/adapters'
+import type { SummarizationOptions, SummarizationResult } from '@tanstack/ai'
+
+/**
+ * Available Gemini models for summarization
+ */
+export const GeminiSummarizeModels = [
+ 'gemini-2.0-flash',
+ 'gemini-1.5-flash',
+ 'gemini-1.5-pro',
+ 'gemini-2.0-flash-lite',
+] as const
+
+export type GeminiSummarizeModel = (typeof GeminiSummarizeModels)[number]
+
+/**
+ * Provider-specific options for Gemini summarization
+ */
+export interface GeminiSummarizeProviderOptions {
+ /** Generation configuration */
+ generationConfig?: {
+ temperature?: number
+ topP?: number
+ topK?: number
+ maxOutputTokens?: number
+ stopSequences?: Array
+ }
+ /** Safety settings */
+ safetySettings?: Array<{
+ category: string
+ threshold: string
+ }>
+}
+
+export interface GeminiSummarizeAdapterOptions {
+ model?: GeminiSummarizeModel
+}
+
+/**
+ * Gemini Summarize Adapter
+ * A tree-shakeable summarization adapter for Google Gemini
+ */
+export class GeminiSummarizeAdapter implements SummarizeAdapter<
+ typeof GeminiSummarizeModels,
+ GeminiSummarizeProviderOptions
+> {
+ readonly kind = 'summarize' as const
+ readonly name = 'gemini' as const
+ readonly models = GeminiSummarizeModels
+
+ /** Type-only property for provider options inference */
+ declare _providerOptions?: GeminiSummarizeProviderOptions
+
+ private client: GoogleGenAI
+ private defaultModel: GeminiSummarizeModel
+
+ constructor(
+ apiKeyOrClient: string | GoogleGenAI,
+ options: GeminiSummarizeAdapterOptions = {},
+ ) {
+ this.client =
+ typeof apiKeyOrClient === 'string'
+ ? createGeminiClient({ apiKey: apiKeyOrClient })
+ : apiKeyOrClient
+ this.defaultModel = options.model ?? 'gemini-2.0-flash'
+ }
+
+ async summarize(options: SummarizationOptions): Promise {
+ const model = options.model || this.defaultModel
+
+ // Build the system prompt based on format
+ const formatInstructions = this.getFormatInstructions(options.style)
+ const lengthInstructions = options.maxLength
+ ? ` Keep the summary under ${options.maxLength} words.`
+ : ''
+
+ const systemPrompt = `You are a helpful assistant that summarizes text. ${formatInstructions}${lengthInstructions}`
+
+ const response = await this.client.models.generateContent({
+ model,
+ contents: [
+ {
+ role: 'user',
+ parts: [
+ { text: `Please summarize the following:\n\n${options.text}` },
+ ],
+ },
+ ],
+ config: {
+ systemInstruction: systemPrompt,
+ },
+ })
+
+ const summary = response.text ?? ''
+ const inputTokens = response.usageMetadata?.promptTokenCount ?? 0
+ const outputTokens = response.usageMetadata?.candidatesTokenCount ?? 0
+
+ return {
+ id: generateId('sum'),
+ model,
+ summary,
+ usage: {
+ promptTokens: inputTokens,
+ completionTokens: outputTokens,
+ totalTokens: inputTokens + outputTokens,
+ },
+ }
+ }
+
+ private getFormatInstructions(
+ style?: 'paragraph' | 'bullet-points' | 'concise',
+ ): string {
+ switch (style) {
+ case 'bullet-points':
+ return 'Provide the summary as bullet points.'
+ case 'concise':
+ return 'Provide a very brief one or two sentence summary.'
+ case 'paragraph':
+ default:
+ return 'Provide the summary in paragraph form.'
+ }
+ }
+}
+
+/**
+ * Creates a Gemini summarize adapter with explicit API key
+ */
+export function createGeminiSummarize(
+ apiKey: string,
+ options?: GeminiSummarizeAdapterOptions,
+): GeminiSummarizeAdapter {
+ return new GeminiSummarizeAdapter(apiKey, options)
+}
+
+/**
+ * Creates a Gemini summarize adapter with API key from environment
+ */
+export function geminiSummarize(
+ options?: GeminiSummarizeAdapterOptions,
+): GeminiSummarizeAdapter {
+ const apiKey = getGeminiApiKeyFromEnv()
+ return new GeminiSummarizeAdapter(apiKey, options)
+}
diff --git a/packages/typescript/ai-gemini/src/adapters/text.ts b/packages/typescript/ai-gemini/src/adapters/text.ts
new file mode 100644
index 00000000..3e82a179
--- /dev/null
+++ b/packages/typescript/ai-gemini/src/adapters/text.ts
@@ -0,0 +1,480 @@
+import { FinishReason } from '@google/genai'
+import { BaseTextAdapter } from '@tanstack/ai/adapters'
+import { GEMINI_MODELS } from '../model-meta'
+import { convertToolsToProviderFormat } from '../tools/tool-converter'
+import {
+ convertZodToGeminiSchema,
+ createGeminiClient,
+ generateId,
+ getGeminiApiKeyFromEnv,
+} from '../utils'
+import type {
+ StructuredOutputOptions,
+ StructuredOutputResult,
+} from '@tanstack/ai/adapters'
+import type {
+ GenerateContentParameters,
+ GenerateContentResponse,
+ GoogleGenAI,
+ Part,
+} from '@google/genai'
+import type {
+ ContentPart,
+ ModelMessage,
+ StreamChunk,
+ TextOptions,
+} from '@tanstack/ai'
+import type {
+ GeminiChatModelProviderOptionsByName,
+ GeminiModelInputModalitiesByName,
+} from '../model-meta'
+import type { ExternalTextProviderOptions } from '../text/text-provider-options'
+import type {
+ GeminiAudioMetadata,
+ GeminiDocumentMetadata,
+ GeminiImageMetadata,
+ GeminiMessageMetadataByModality,
+ GeminiVideoMetadata,
+} from '../message-types'
+import type { GeminiClientConfig } from '../utils'
+
+/**
+ * Configuration for Gemini text adapter
+ */
+export interface GeminiTextConfig extends GeminiClientConfig {}
+
+/**
+ * Gemini-specific provider options for text/chat
+ */
+export type GeminiTextProviderOptions = ExternalTextProviderOptions
+
+/**
+ * Gemini Text (Chat) Adapter
+ *
+ * Tree-shakeable adapter for Gemini chat/text completion functionality.
+ * Import only what you need for smaller bundle sizes.
+ */
+export class GeminiTextAdapter extends BaseTextAdapter<
+ typeof GEMINI_MODELS,
+ GeminiTextProviderOptions,
+ GeminiChatModelProviderOptionsByName,
+ GeminiModelInputModalitiesByName,
+ GeminiMessageMetadataByModality
+> {
+ readonly kind = 'text' as const
+ readonly name = 'gemini' as const
+ readonly models = GEMINI_MODELS
+
+ declare _modelProviderOptionsByName: GeminiChatModelProviderOptionsByName
+ declare _modelInputModalitiesByName: GeminiModelInputModalitiesByName
+ declare _messageMetadataByModality: GeminiMessageMetadataByModality
+
+ private client: GoogleGenAI
+
+ constructor(config: GeminiTextConfig) {
+ super({})
+ this.client = createGeminiClient(config)
+ }
+
+ async *chatStream(
+ options: TextOptions,
+ ): AsyncIterable {
+ const mappedOptions = this.mapCommonOptionsToGemini(options)
+
+ try {
+ const result =
+ await this.client.models.generateContentStream(mappedOptions)
+
+ yield* this.processStreamChunks(result, options.model)
+ } catch (error) {
+ const timestamp = Date.now()
+ yield {
+ type: 'error',
+ id: generateId(this.name),
+ model: options.model,
+ timestamp,
+ error: {
+ message:
+ error instanceof Error
+ ? error.message
+ : 'An unknown error occurred during the chat stream.',
+ },
+ }
+ }
+ }
+
+ /**
+ * Generate structured output using Gemini's native JSON response format.
+ * Uses responseMimeType: 'application/json' and responseSchema for structured output.
+ * Converts the Zod schema to JSON Schema format compatible with Gemini's API.
+ */
+ async structuredOutput(
+ options: StructuredOutputOptions,
+ ): Promise> {
+ const { chatOptions, outputSchema } = options
+
+ // Convert Zod schema to Gemini-compatible JSON Schema
+ const jsonSchema = convertZodToGeminiSchema(outputSchema)
+
+ const mappedOptions = this.mapCommonOptionsToGemini(chatOptions)
+
+ try {
+ // Add structured output configuration
+ const result = await this.client.models.generateContent({
+ ...mappedOptions,
+ config: {
+ ...mappedOptions.config,
+ responseMimeType: 'application/json',
+ responseSchema: jsonSchema,
+ },
+ })
+
+ // Extract text content from the response
+ const rawText = this.extractTextFromResponse(result)
+
+ // Parse the JSON response
+ let parsed: unknown
+ try {
+ parsed = JSON.parse(rawText)
+ } catch {
+ throw new Error(
+ `Failed to parse structured output as JSON. Content: ${rawText.slice(0, 200)}${rawText.length > 200 ? '...' : ''}`,
+ )
+ }
+
+ return {
+ data: parsed,
+ rawText,
+ }
+ } catch (error) {
+ throw new Error(
+ error instanceof Error
+ ? error.message
+ : 'An unknown error occurred during structured output generation.',
+ )
+ }
+ }
+
+ /**
+ * Extract text content from a non-streaming response
+ */
+ private extractTextFromResponse(response: GenerateContentResponse): string {
+ let textContent = ''
+
+ if (response.candidates?.[0]?.content?.parts) {
+ for (const part of response.candidates[0].content.parts) {
+ if (part.text) {
+ textContent += part.text
+ }
+ }
+ }
+
+ return textContent
+ }
+
+ private async *processStreamChunks(
+ result: AsyncGenerator,
+ model: string,
+ ): AsyncIterable {
+ const timestamp = Date.now()
+ let accumulatedContent = ''
+ const toolCallMap = new Map<
+ string,
+ { name: string; args: string; index: number }
+ >()
+ let nextToolIndex = 0
+
+ for await (const chunk of result) {
+ if (chunk.candidates?.[0]?.content?.parts) {
+ const parts = chunk.candidates[0].content.parts
+
+ for (const part of parts) {
+ if (part.text) {
+ accumulatedContent += part.text
+ yield {
+ type: 'content',
+ id: generateId(this.name),
+ model,
+ timestamp,
+ delta: part.text,
+ content: accumulatedContent,
+ role: 'assistant',
+ }
+ }
+
+ const functionCall = part.functionCall
+ if (functionCall) {
+ const toolCallId =
+ functionCall.name || `call_${Date.now()}_${nextToolIndex}`
+ const functionArgs = functionCall.args || {}
+
+ let toolCallData = toolCallMap.get(toolCallId)
+ if (!toolCallData) {
+ toolCallData = {
+ name: functionCall.name || '',
+ args:
+ typeof functionArgs === 'string'
+ ? functionArgs
+ : JSON.stringify(functionArgs),
+ index: nextToolIndex++,
+ }
+ toolCallMap.set(toolCallId, toolCallData)
+ } else {
+ try {
+ const existingArgs = JSON.parse(toolCallData.args)
+ const newArgs =
+ typeof functionArgs === 'string'
+ ? JSON.parse(functionArgs)
+ : functionArgs
+ const mergedArgs = { ...existingArgs, ...newArgs }
+ toolCallData.args = JSON.stringify(mergedArgs)
+ } catch {
+ toolCallData.args =
+ typeof functionArgs === 'string'
+ ? functionArgs
+ : JSON.stringify(functionArgs)
+ }
+ }
+
+ yield {
+ type: 'tool_call',
+ id: generateId(this.name),
+ model,
+ timestamp,
+ toolCall: {
+ id: toolCallId,
+ type: 'function',
+ function: {
+ name: toolCallData.name,
+ arguments: toolCallData.args,
+ },
+ },
+ index: toolCallData.index,
+ }
+ }
+ }
+ } else if (chunk.data) {
+ accumulatedContent += chunk.data
+ yield {
+ type: 'content',
+ id: generateId(this.name),
+ model,
+ timestamp,
+ delta: chunk.data,
+ content: accumulatedContent,
+ role: 'assistant',
+ }
+ }
+
+ if (chunk.candidates?.[0]?.finishReason) {
+ const finishReason = chunk.candidates[0].finishReason
+
+ if (finishReason === FinishReason.UNEXPECTED_TOOL_CALL) {
+ if (chunk.candidates[0].content?.parts) {
+ for (const part of chunk.candidates[0].content.parts) {
+ const functionCall = part.functionCall
+ if (functionCall) {
+ const toolCallId =
+ functionCall.name || `call_${Date.now()}_${nextToolIndex}`
+ const functionArgs = functionCall.args || {}
+
+ toolCallMap.set(toolCallId, {
+ name: functionCall.name || '',
+ args:
+ typeof functionArgs === 'string'
+ ? functionArgs
+ : JSON.stringify(functionArgs),
+ index: nextToolIndex++,
+ })
+
+ yield {
+ type: 'tool_call',
+ id: generateId(this.name),
+ model,
+ timestamp,
+ toolCall: {
+ id: toolCallId,
+ type: 'function',
+ function: {
+ name: functionCall.name || '',
+ arguments:
+ typeof functionArgs === 'string'
+ ? functionArgs
+ : JSON.stringify(functionArgs),
+ },
+ },
+ index: nextToolIndex - 1,
+ }
+ }
+ }
+ }
+ }
+ if (finishReason === FinishReason.MAX_TOKENS) {
+ yield {
+ type: 'error',
+ id: generateId(this.name),
+ model,
+ timestamp,
+ error: {
+ message:
+ 'The response was cut off because the maximum token limit was reached.',
+ },
+ }
+ }
+
+ yield {
+ type: 'done',
+ id: generateId(this.name),
+ model,
+ timestamp,
+ finishReason: toolCallMap.size > 0 ? 'tool_calls' : 'stop',
+ usage: chunk.usageMetadata
+ ? {
+ promptTokens: chunk.usageMetadata.promptTokenCount ?? 0,
+ completionTokens: chunk.usageMetadata.thoughtsTokenCount ?? 0,
+ totalTokens: chunk.usageMetadata.totalTokenCount ?? 0,
+ }
+ : undefined,
+ }
+ }
+ }
+ }
+
+ private convertContentPartToGemini(part: ContentPart): Part {
+ switch (part.type) {
+ case 'text':
+ return { text: part.content }
+ case 'image':
+ case 'audio':
+ case 'video':
+ case 'document': {
+ const metadata = part.metadata as
+ | GeminiDocumentMetadata
+ | GeminiImageMetadata
+ | GeminiVideoMetadata
+ | GeminiAudioMetadata
+ | undefined
+ if (part.source.type === 'data') {
+ return {
+ inlineData: {
+ data: part.source.value,
+ mimeType: metadata?.mimeType ?? 'image/jpeg',
+ },
+ }
+ } else {
+ return {
+ fileData: {
+ fileUri: part.source.value,
+ mimeType: metadata?.mimeType ?? 'image/jpeg',
+ },
+ }
+ }
+ }
+ default: {
+ const _exhaustiveCheck: never = part
+ throw new Error(
+ `Unsupported content part type: ${(_exhaustiveCheck as ContentPart).type}`,
+ )
+ }
+ }
+ }
+
+ private formatMessages(
+ messages: Array,
+ ): GenerateContentParameters['contents'] {
+ return messages.map((msg) => {
+ const role: 'user' | 'model' = msg.role === 'assistant' ? 'model' : 'user'
+ const parts: Array = []
+
+ if (Array.isArray(msg.content)) {
+ for (const contentPart of msg.content) {
+ parts.push(this.convertContentPartToGemini(contentPart))
+ }
+ } else if (msg.content) {
+ parts.push({ text: msg.content })
+ }
+
+ if (msg.role === 'assistant' && msg.toolCalls?.length) {
+ for (const toolCall of msg.toolCalls) {
+ let parsedArgs: Record = {}
+ try {
+ parsedArgs = toolCall.function.arguments
+ ? (JSON.parse(toolCall.function.arguments) as Record<
+ string,
+ unknown
+ >)
+ : {}
+ } catch {
+ parsedArgs = toolCall.function.arguments as unknown as Record<
+ string,
+ unknown
+ >
+ }
+
+ parts.push({
+ functionCall: {
+ name: toolCall.function.name,
+ args: parsedArgs,
+ },
+ })
+ }
+ }
+
+ if (msg.role === 'tool' && msg.toolCallId) {
+ parts.push({
+ functionResponse: {
+ name: msg.toolCallId,
+ response: {
+ content: msg.content || '',
+ },
+ },
+ })
+ }
+
+ return {
+ role,
+ parts: parts.length > 0 ? parts : [{ text: '' }],
+ }
+ })
+ }
+
+ private mapCommonOptionsToGemini(options: TextOptions) {
+ const providerOpts = options.providerOptions
+ const requestOptions: GenerateContentParameters = {
+ model: options.model,
+ contents: this.formatMessages(options.messages),
+ config: {
+ ...providerOpts,
+ temperature: options.options?.temperature,
+ topP: options.options?.topP,
+ maxOutputTokens: options.options?.maxTokens,
+ systemInstruction: options.systemPrompts?.join('\n'),
+ ...((providerOpts as Record | undefined)
+ ?.generationConfig as Record | undefined),
+ tools: convertToolsToProviderFormat(options.tools),
+ },
+ }
+
+ return requestOptions
+ }
+}
+
+/**
+ * Creates a Gemini text adapter with explicit API key
+ */
+export function createGeminiText(
+ apiKey: string,
+ config?: Omit,
+): GeminiTextAdapter {
+ return new GeminiTextAdapter({ apiKey, ...config })
+}
+
+/**
+ * Creates a Gemini text adapter with automatic API key detection
+ */
+export function geminiText(
+ config?: Omit,
+): GeminiTextAdapter {
+ const apiKey = getGeminiApiKeyFromEnv()
+ return createGeminiText(apiKey, config)
+}
diff --git a/packages/typescript/ai-gemini/src/adapters/tts.ts b/packages/typescript/ai-gemini/src/adapters/tts.ts
new file mode 100644
index 00000000..1d72f8a9
--- /dev/null
+++ b/packages/typescript/ai-gemini/src/adapters/tts.ts
@@ -0,0 +1,192 @@
+import { BaseTTSAdapter } from '@tanstack/ai/adapters'
+import { GEMINI_TTS_MODELS } from '../model-meta'
+import {
+ createGeminiClient,
+ generateId,
+ getGeminiApiKeyFromEnv,
+} from '../utils'
+import type { TTSOptions, TTSResult } from '@tanstack/ai'
+import type { GoogleGenAI } from '@google/genai'
+import type { GeminiClientConfig } from '../utils'
+
+/**
+ * Provider-specific options for Gemini TTS
+ *
+ * @experimental Gemini TTS is an experimental feature and uses the Live API.
+ */
+export interface GeminiTTSProviderOptions {
+ /**
+ * Voice configuration for TTS.
+ * Note: Gemini TTS uses the Live API which has limited configuration options.
+ */
+ voiceConfig?: {
+ prebuiltVoiceConfig?: {
+ voiceName?: string
+ }
+ }
+}
+
+/**
+ * Configuration for Gemini TTS adapter
+ *
+ * @experimental Gemini TTS is an experimental feature.
+ */
+export interface GeminiTTSConfig extends GeminiClientConfig {}
+
+/**
+ * Gemini Text-to-Speech Adapter
+ *
+ * Tree-shakeable adapter for Gemini TTS functionality.
+ *
+ * **IMPORTANT**: Gemini TTS uses the Live API (WebSocket-based) which requires
+ * different handling than traditional REST APIs. This adapter provides a
+ * simplified interface but may have limitations.
+ *
+ * @experimental Gemini TTS is an experimental feature and may change.
+ *
+ * Models:
+ * - gemini-2.5-flash-preview-tts
+ */
+export class GeminiTTSAdapter extends BaseTTSAdapter<
+ typeof GEMINI_TTS_MODELS,
+ GeminiTTSProviderOptions
+> {
+ readonly name = 'gemini' as const
+ readonly models = GEMINI_TTS_MODELS
+
+ private client: GoogleGenAI
+
+ constructor(config: GeminiTTSConfig) {
+ super(config)
+ this.client = createGeminiClient(config)
+ }
+
+ /**
+ * Generate speech from text using Gemini's TTS model.
+ *
+ * Note: Gemini's TTS functionality uses the Live API, which is WebSocket-based.
+ * This implementation uses the multimodal generation endpoint with audio output
+ * configuration, which may have different capabilities than the full Live API.
+ *
+ * @experimental This implementation is experimental and may change.
+ */
+ async generateSpeech(
+ options: TTSOptions,
+ ): Promise {
+ const { model, text, providerOptions } = options
+
+ // Use Gemini's multimodal content generation with audio output
+ // Note: This requires the model to support audio output
+ const voiceConfig = providerOptions?.voiceConfig || {
+ prebuiltVoiceConfig: {
+ voiceName: 'Kore', // Default Gemini voice
+ },
+ }
+
+ const response = await this.client.models.generateContent({
+ model,
+ contents: [
+ {
+ role: 'user',
+ parts: [{ text: `Please speak the following text: ${text}` }],
+ },
+ ],
+ config: {
+ // Configure for audio output
+ responseModalities: ['AUDIO'],
+ speechConfig: {
+ voiceConfig,
+ },
+ },
+ })
+
+ // Extract audio data from response
+ const candidate = response.candidates?.[0]
+ const parts = candidate?.content?.parts
+
+ if (!parts || parts.length === 0) {
+ throw new Error('No audio output received from Gemini TTS')
+ }
+
+ // Look for inline data (audio)
+ const audioPart = parts.find((part: any) =>
+ part.inlineData?.mimeType?.startsWith('audio/'),
+ )
+
+ if (!audioPart || !('inlineData' in audioPart)) {
+ throw new Error('No audio data in Gemini TTS response')
+ }
+
+ const inlineData = (audioPart as any).inlineData
+ const audioBase64 = inlineData.data
+ const mimeType = inlineData.mimeType || 'audio/wav'
+ const format = mimeType.split('/')[1] || 'wav'
+
+ return {
+ id: generateId(this.name),
+ model,
+ audio: audioBase64,
+ format,
+ contentType: mimeType,
+ }
+ }
+}
+
+/**
+ * Creates a Gemini TTS adapter with explicit API key
+ *
+ * @experimental Gemini TTS is an experimental feature and may change.
+ *
+ * @param apiKey - Your Google API key
+ * @param config - Optional additional configuration
+ * @returns Configured Gemini TTS adapter instance
+ *
+ * @example
+ * ```typescript
+ * const adapter = createGeminiTTS("your-api-key");
+ *
+ * const result = await ai({
+ * adapter,
+ * model: 'gemini-2.5-flash-preview-tts',
+ * text: 'Hello, world!'
+ * });
+ * ```
+ */
+export function createGeminiTTS(
+ apiKey: string,
+ config?: Omit,
+): GeminiTTSAdapter {
+ return new GeminiTTSAdapter({ apiKey, ...config })
+}
+
+/**
+ * Creates a Gemini TTS adapter with automatic API key detection from environment variables.
+ *
+ * @experimental Gemini TTS is an experimental feature and may change.
+ *
+ * Looks for `GOOGLE_API_KEY` or `GEMINI_API_KEY` in:
+ * - `process.env` (Node.js)
+ * - `window.env` (Browser with injected env)
+ *
+ * @param config - Optional configuration (excluding apiKey which is auto-detected)
+ * @returns Configured Gemini TTS adapter instance
+ * @throws Error if GOOGLE_API_KEY or GEMINI_API_KEY is not found in environment
+ *
+ * @example
+ * ```typescript
+ * // Automatically uses GOOGLE_API_KEY from environment
+ * const adapter = geminiTTS();
+ *
+ * const result = await ai({
+ * adapter,
+ * model: 'gemini-2.5-flash-preview-tts',
+ * text: 'Welcome to TanStack AI!'
+ * });
+ * ```
+ */
+export function geminiTTS(
+ config?: Omit,
+): GeminiTTSAdapter {
+ const apiKey = getGeminiApiKeyFromEnv()
+ return createGeminiTTS(apiKey, config)
+}
diff --git a/packages/typescript/ai-gemini/src/gemini-adapter.ts b/packages/typescript/ai-gemini/src/gemini-adapter.ts
index 8a711b10..1eaf3a33 100644
--- a/packages/typescript/ai-gemini/src/gemini-adapter.ts
+++ b/packages/typescript/ai-gemini/src/gemini-adapter.ts
@@ -4,7 +4,6 @@ import { GEMINI_EMBEDDING_MODELS, GEMINI_MODELS } from './model-meta'
import { convertToolsToProviderFormat } from './tools/tool-converter'
import type {
AIAdapterConfig,
- ChatOptions,
ContentPart,
EmbeddingOptions,
EmbeddingResult,
@@ -12,6 +11,7 @@ import type {
StreamChunk,
SummarizationOptions,
SummarizationResult,
+ TextOptions,
} from '@tanstack/ai'
import type {
GeminiChatModelProviderOptionsByName,
@@ -67,7 +67,7 @@ export class GeminiAdapter extends BaseAdapter<
}
async *chatStream(
- options: ChatOptions,
+ options: TextOptions,
): AsyncIterable {
// Map common options to Gemini format
const mappedOptions = this.mapCommonOptionsToGemini(options)
@@ -496,7 +496,7 @@ export class GeminiAdapter extends BaseAdapter<
* Maps common options to Gemini-specific format
* Handles translation of normalized options to Gemini's API format
*/
- private mapCommonOptionsToGemini(options: ChatOptions) {
+ private mapCommonOptionsToGemini(options: TextOptions) {
const providerOpts = options.providerOptions
const requestOptions: GenerateContentParameters = {
model: options.model,
diff --git a/packages/typescript/ai-gemini/src/image/image-provider-options.ts b/packages/typescript/ai-gemini/src/image/image-provider-options.ts
new file mode 100644
index 00000000..2db3f4d2
--- /dev/null
+++ b/packages/typescript/ai-gemini/src/image/image-provider-options.ts
@@ -0,0 +1,239 @@
+import type { GeminiImageModels } from '../model-meta'
+import type {
+ ImagePromptLanguage,
+ PersonGeneration,
+ SafetyFilterLevel,
+} from '@google/genai'
+
+// Re-export SDK types so users can use them directly
+export type { ImagePromptLanguage, PersonGeneration, SafetyFilterLevel }
+
+/**
+ * Gemini Imagen aspect ratio options
+ * Controls the aspect ratio of generated images
+ */
+export type GeminiAspectRatio =
+ | '1:1'
+ | '3:4'
+ | '4:3'
+ | '9:16'
+ | '16:9'
+ | '9:21'
+ | '21:9'
+
+/**
+ * Provider options for Gemini image generation
+ * These options match the @google/genai GenerateImagesConfig interface
+ * and can be spread directly into the API request.
+ */
+export interface GeminiImageProviderOptions {
+ /**
+ * The aspect ratio of generated images
+ * @default '1:1'
+ */
+ aspectRatio?: GeminiAspectRatio
+
+ /**
+ * Controls whether people can appear in generated images
+ * Use PersonGeneration enum values: DONT_ALLOW, ALLOW_ADULT, ALLOW_ALL
+ * @default 'ALLOW_ADULT'
+ */
+ personGeneration?: PersonGeneration
+
+ /**
+ * Safety filter level for content filtering
+ * Use SafetyFilterLevel enum values
+ */
+ safetyFilterLevel?: SafetyFilterLevel
+
+ /**
+ * Optional seed for reproducible image generation
+ * When the same seed is used with the same prompt and settings,
+ * you should get similar (though not identical) results
+ */
+ seed?: number
+
+ /**
+ * Whether to add a SynthID watermark to generated images
+ * SynthID helps identify AI-generated content
+ * @default true
+ */
+ addWatermark?: boolean
+
+ /**
+ * Language of the prompt
+ * Use ImagePromptLanguage enum values
+ */
+ language?: ImagePromptLanguage
+
+ /**
+ * Negative prompt - what to avoid in the generated image
+ * Not all models support negative prompts
+ */
+ negativePrompt?: string
+
+ /**
+ * Output MIME type for the generated image
+ * @default 'image/png'
+ */
+ outputMimeType?: 'image/png' | 'image/jpeg' | 'image/webp'
+
+ /**
+ * Compression quality for JPEG outputs (0-100)
+ * Higher values mean better quality but larger file sizes
+ * @default 75
+ */
+ outputCompressionQuality?: number
+
+ /**
+ * Controls how much the model adheres to the text prompt
+ * Large values increase output and prompt alignment,
+ * but may compromise image quality
+ */
+ guidanceScale?: number
+
+ /**
+ * Whether to use the prompt rewriting logic
+ */
+ enhancePrompt?: boolean
+
+ /**
+ * Whether to report the safety scores of each generated image
+ * and the positive prompt in the response
+ */
+ includeSafetyAttributes?: boolean
+
+ /**
+ * Whether to include the Responsible AI filter reason
+ * if the image is filtered out of the response
+ */
+ includeRaiReason?: boolean
+
+ /**
+ * Cloud Storage URI used to store the generated images
+ */
+ outputGcsUri?: string
+
+ /**
+ * User specified labels to track billing usage
+ */
+ labels?: Record
+}
+
+/**
+ * Model-specific provider options mapping
+ * Currently all Imagen models use the same options structure
+ */
+export type GeminiImageModelProviderOptionsByName = {
+ [K in GeminiImageModels]: GeminiImageProviderOptions
+}
+
+/**
+ * Supported size strings for Gemini Imagen models
+ * These map to aspect ratios internally
+ */
+export type GeminiImageSize =
+ | '1024x1024'
+ | '512x512'
+ | '1024x768'
+ | '1536x1024'
+ | '1792x1024'
+ | '1920x1080'
+ | '768x1024'
+ | '1024x1536'
+ | '1024x1792'
+ | '1080x1920'
+
+/**
+ * Model-specific size options mapping
+ * All Imagen models use the same size options
+ */
+export type GeminiImageModelSizeByName = {
+ [K in GeminiImageModels]: GeminiImageSize
+}
+
+/**
+ * Valid sizes for Gemini Imagen models
+ * Gemini uses aspect ratios, but we map common WIDTHxHEIGHT formats to aspect ratios
+ * These are approximate mappings based on common image dimensions
+ */
+export const GEMINI_SIZE_TO_ASPECT_RATIO: Record = {
+ // Square
+ '1024x1024': '1:1',
+ '512x512': '1:1',
+ // Landscape
+ '1024x768': '4:3',
+ '1536x1024': '3:4', // Actually this is portrait, but matching common dimensions
+ '1792x1024': '16:9',
+ '1920x1080': '16:9',
+ // Portrait
+ '768x1024': '3:4',
+ '1024x1536': '4:3', // Inverted
+ '1024x1792': '9:16',
+ '1080x1920': '9:16',
+}
+
+/**
+ * Maps a WIDTHxHEIGHT size string to a Gemini aspect ratio
+ * Returns undefined if the size cannot be mapped
+ */
+export function sizeToAspectRatio(
+ size: string | undefined,
+): GeminiAspectRatio | undefined {
+ if (!size) return undefined
+ return GEMINI_SIZE_TO_ASPECT_RATIO[size]
+}
+
+/**
+ * Validates that the provided size can be mapped to an aspect ratio
+ * Throws an error if the size is invalid
+ */
+export function validateImageSize(
+ model: string,
+ size: string | undefined,
+): void {
+ if (!size) return
+
+ const aspectRatio = sizeToAspectRatio(size)
+ if (!aspectRatio) {
+ const validSizes = Object.keys(GEMINI_SIZE_TO_ASPECT_RATIO)
+ throw new Error(
+ `Invalid size "${size}" for model "${model}". ` +
+ `Gemini Imagen uses aspect ratios. Valid sizes that map to aspect ratios: ${validSizes.join(', ')}. ` +
+ `Alternatively, use providerOptions.aspectRatio directly with values: 1:1, 3:4, 4:3, 9:16, 16:9, 9:21, 21:9`,
+ )
+ }
+}
+
+/**
+ * Validates the number of images requested
+ * Imagen models support 1-8 images per request (varies by model)
+ */
+export function validateNumberOfImages(
+ model: string,
+ numberOfImages: number | undefined,
+): void {
+ if (numberOfImages === undefined) return
+
+ // Most Imagen models support 1-4 images, some support up to 8
+ const maxImages = 4
+ if (numberOfImages < 1 || numberOfImages > maxImages) {
+ throw new Error(
+ `Invalid numberOfImages "${numberOfImages}" for model "${model}". ` +
+ `Must be between 1 and ${maxImages}.`,
+ )
+ }
+}
+
+/**
+ * Validates the prompt is not empty
+ */
+export function validatePrompt(options: {
+ prompt: string
+ model: string
+}): void {
+ const { prompt, model } = options
+ if (!prompt || prompt.trim().length === 0) {
+ throw new Error(`Prompt cannot be empty for model "${model}".`)
+ }
+}
diff --git a/packages/typescript/ai-gemini/src/index.ts b/packages/typescript/ai-gemini/src/index.ts
index 2cc667e6..3330af93 100644
--- a/packages/typescript/ai-gemini/src/index.ts
+++ b/packages/typescript/ai-gemini/src/index.ts
@@ -1,3 +1,84 @@
+// ===========================
+// New tree-shakeable adapters
+// ===========================
+
+// Text/Chat adapter
+export {
+ GeminiTextAdapter,
+ createGeminiText,
+ geminiText,
+ type GeminiTextConfig,
+ type GeminiTextProviderOptions,
+} from './adapters/text'
+
+// Embedding adapter
+export {
+ GeminiEmbedAdapter,
+ GeminiEmbeddingModels,
+ createGeminiEmbed,
+ geminiEmbed,
+ type GeminiEmbedAdapterOptions,
+ type GeminiEmbeddingModel,
+ type GeminiEmbedProviderOptions,
+} from './adapters/embed'
+
+// Summarize adapter
+export {
+ GeminiSummarizeAdapter,
+ GeminiSummarizeModels,
+ createGeminiSummarize,
+ geminiSummarize,
+ type GeminiSummarizeAdapterOptions,
+ type GeminiSummarizeModel,
+ type GeminiSummarizeProviderOptions,
+} from './adapters/summarize'
+
+// Image adapter
+export {
+ GeminiImageAdapter,
+ createGeminiImage,
+ geminiImage,
+ type GeminiImageConfig,
+} from './adapters/image'
+export type {
+ GeminiImageProviderOptions,
+ GeminiImageModelProviderOptionsByName,
+ GeminiAspectRatio,
+ // Re-export SDK types for convenience
+ PersonGeneration,
+ SafetyFilterLevel,
+ ImagePromptLanguage,
+} from './image/image-provider-options'
+
+// TTS adapter (experimental)
+/**
+ * @experimental Gemini TTS is an experimental feature and may change.
+ */
+export {
+ GeminiTTSAdapter,
+ createGeminiTTS,
+ geminiTTS,
+ type GeminiTTSConfig,
+ type GeminiTTSProviderOptions,
+} from './adapters/tts'
+
+// Re-export models from model-meta for convenience
+export { GEMINI_MODELS as GeminiTextModels } from './model-meta'
+export { GEMINI_IMAGE_MODELS as GeminiImageModels } from './model-meta'
+export { GEMINI_TTS_MODELS as GeminiTTSModels } from './model-meta'
+export type { GeminiModels as GeminiTextModel } from './model-meta'
+export type { GeminiImageModels as GeminiImageModel } from './model-meta'
+
+// ===========================
+// Legacy monolithic adapter (deprecated)
+// ===========================
+
+/**
+ * @deprecated Use the new tree-shakeable adapters instead:
+ * - `geminiText()` / `createGeminiText()` for chat/text generation
+ * - `geminiEmbed()` / `createGeminiEmbed()` for embeddings
+ * - `geminiSummarize()` / `createGeminiSummarize()` for summarization
+ */
export { GeminiAdapter, createGemini, gemini } from './gemini-adapter'
export type { GeminiAdapterConfig } from './gemini-adapter'
export type {
diff --git a/packages/typescript/ai-gemini/src/model-meta.ts b/packages/typescript/ai-gemini/src/model-meta.ts
index 5048f96f..bae5cb5f 100644
--- a/packages/typescript/ai-gemini/src/model-meta.ts
+++ b/packages/typescript/ai-gemini/src/model-meta.ts
@@ -220,7 +220,7 @@ const GEMINI_2_5_FLASH_PREVIEW = {
GeminiStructuredOutputOptions &
GeminiThinkingOptions
>
-/*
+
const GEMINI_2_5_FLASH_IMAGE = {
name: 'gemini-2.5-flash-image',
max_input_tokens: 1_048_576,
@@ -247,11 +247,11 @@ const GEMINI_2_5_FLASH_IMAGE = {
},
} as const satisfies ModelMeta<
GeminiToolConfigOptions &
- GeminiSafetyOptions &
- GeminiGenerationConfigOptions &
- GeminiCachedContentOptions
+ GeminiSafetyOptions &
+ GeminiGenerationConfigOptions &
+ GeminiCachedContentOptions
>
-
+/**
const GEMINI_2_5_FLASH_LIVE = {
name: 'gemini-2.5-flash-native-audio-preview-09-2025',
max_input_tokens: 141_072,
@@ -418,7 +418,7 @@ const GEMINI_2_FLASH = {
GeminiCachedContentOptions &
GeminiStructuredOutputOptions
>
-/*
+
const GEMINI_2_FLASH_IMAGE = {
name: 'gemini-2.0-flash-preview-image-generation',
max_input_tokens: 32_768,
@@ -444,10 +444,10 @@ const GEMINI_2_FLASH_IMAGE = {
},
} as const satisfies ModelMeta<
GeminiToolConfigOptions &
- GeminiSafetyOptions &
- GeminiGenerationConfigOptions &
- GeminiCachedContentOptions
-> */
+ GeminiSafetyOptions &
+ GeminiGenerationConfigOptions &
+ GeminiCachedContentOptions
+>
/*
const GEMINI_2_FLASH_LIVE = {
name: 'gemini-2.0-flash-live-001',
@@ -514,7 +514,7 @@ const GEMINI_2_FLASH_LITE = {
GeminiStructuredOutputOptions
>
-/* const IMAGEN_4_GENERATE = {
+const IMAGEN_4_GENERATE = {
name: 'imagen-4.0-generate-001',
max_input_tokens: 480,
max_output_tokens: 4,
@@ -532,9 +532,9 @@ const GEMINI_2_FLASH_LITE = {
},
} as const satisfies ModelMeta<
GeminiToolConfigOptions &
- GeminiSafetyOptions &
- GeminiGenerationConfigOptions &
- GeminiCachedContentOptions
+ GeminiSafetyOptions &
+ GeminiGenerationConfigOptions &
+ GeminiCachedContentOptions
>
const IMAGEN_4_GENERATE_ULTRA = {
@@ -555,9 +555,9 @@ const IMAGEN_4_GENERATE_ULTRA = {
},
} as const satisfies ModelMeta<
GeminiToolConfigOptions &
- GeminiSafetyOptions &
- GeminiGenerationConfigOptions &
- GeminiCachedContentOptions
+ GeminiSafetyOptions &
+ GeminiGenerationConfigOptions &
+ GeminiCachedContentOptions
>
const IMAGEN_4_GENERATE_FAST = {
@@ -578,9 +578,9 @@ const IMAGEN_4_GENERATE_FAST = {
},
} as const satisfies ModelMeta<
GeminiToolConfigOptions &
- GeminiSafetyOptions &
- GeminiGenerationConfigOptions &
- GeminiCachedContentOptions
+ GeminiSafetyOptions &
+ GeminiGenerationConfigOptions &
+ GeminiCachedContentOptions
>
const IMAGEN_3 = {
@@ -600,11 +600,11 @@ const IMAGEN_3 = {
},
} as const satisfies ModelMeta<
GeminiToolConfigOptions &
- GeminiSafetyOptions &
- GeminiGenerationConfigOptions &
- GeminiCachedContentOptions
+ GeminiSafetyOptions &
+ GeminiGenerationConfigOptions &
+ GeminiCachedContentOptions
>
-
+/**
const VEO_3_1_PREVIEW = {
name: 'veo-3.1-generate-preview',
max_input_tokens: 1024,
@@ -779,17 +779,27 @@ export const GEMINI_MODELS = [
GEMINI_2_FLASH_LITE.name,
] as const
-/* const GEMINI_IMAGE_MODELS = [
+export type GeminiModels = (typeof GEMINI_MODELS)[number]
+
+export type GeminiImageModels = (typeof GEMINI_IMAGE_MODELS)[number]
+
+export const GEMINI_IMAGE_MODELS = [
GEMINI_2_5_FLASH_IMAGE.name,
GEMINI_2_FLASH_IMAGE.name,
IMAGEN_3.name,
IMAGEN_4_GENERATE.name,
IMAGEN_4_GENERATE_FAST.name,
IMAGEN_4_GENERATE_ULTRA.name,
-] as const */
+] as const
export const GEMINI_EMBEDDING_MODELS = [GEMINI_EMBEDDING.name] as const
+/**
+ * Text-to-speech models
+ * @experimental Gemini TTS is an experimental feature and may change.
+ */
+export const GEMINI_TTS_MODELS = ['gemini-2.5-flash-preview-tts'] as const
+
/* const GEMINI_AUDIO_MODELS = [
GEMINI_2_5_PRO_TTS.name,
GEMINI_2_5_FLASH_TTS.name,
diff --git a/packages/typescript/ai-gemini/src/tools/tool-converter.ts b/packages/typescript/ai-gemini/src/tools/tool-converter.ts
index f0a239ca..ccdd5edd 100644
--- a/packages/typescript/ai-gemini/src/tools/tool-converter.ts
+++ b/packages/typescript/ai-gemini/src/tools/tool-converter.ts
@@ -1,4 +1,4 @@
-import { convertZodToJsonSchema } from '@tanstack/ai'
+import { convertZodToGeminiSchema } from '../utils/schema-converter'
import { convertCodeExecutionToolToAdapterFormat } from './code-execution-tool'
import { convertComputerUseToolToAdapterFormat } from './computer-use-tool'
import { convertFileSearchToolToAdapterFormat } from './file-search-tool'
@@ -76,8 +76,10 @@ export function convertToolsToProviderFormat(
)
}
- // Convert Zod schema to JSON Schema
- const jsonSchema = convertZodToJsonSchema(tool.inputSchema)
+ // Convert Zod schema to Gemini-compatible JSON Schema
+ const jsonSchema = tool.inputSchema
+ ? convertZodToGeminiSchema(tool.inputSchema)
+ : { type: 'object', properties: {}, required: [] }
functionDeclarations.push({
name: tool.name,
diff --git a/packages/typescript/ai-gemini/src/utils/client.ts b/packages/typescript/ai-gemini/src/utils/client.ts
new file mode 100644
index 00000000..21e0f2cd
--- /dev/null
+++ b/packages/typescript/ai-gemini/src/utils/client.ts
@@ -0,0 +1,43 @@
+import { GoogleGenAI } from '@google/genai'
+
+export interface GeminiClientConfig {
+ apiKey: string
+}
+
+/**
+ * Creates a Google Generative AI client instance
+ */
+export function createGeminiClient(config: GeminiClientConfig): GoogleGenAI {
+ return new GoogleGenAI({
+ apiKey: config.apiKey,
+ })
+}
+
+/**
+ * Gets Google API key from environment variables
+ * @throws Error if GOOGLE_API_KEY or GEMINI_API_KEY is not found
+ */
+export function getGeminiApiKeyFromEnv(): string {
+ const env =
+ typeof globalThis !== 'undefined' && (globalThis as any).window?.env
+ ? (globalThis as any).window.env
+ : typeof process !== 'undefined'
+ ? process.env
+ : undefined
+ const key = env?.GOOGLE_API_KEY || env?.GEMINI_API_KEY
+
+ if (!key) {
+ throw new Error(
+ 'GOOGLE_API_KEY or GEMINI_API_KEY is required. Please set it in your environment variables or use the factory function with an explicit API key.',
+ )
+ }
+
+ return key
+}
+
+/**
+ * Generates a unique ID with a prefix
+ */
+export function generateId(prefix: string): string {
+ return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(7)}`
+}
diff --git a/packages/typescript/ai-gemini/src/utils/index.ts b/packages/typescript/ai-gemini/src/utils/index.ts
new file mode 100644
index 00000000..a27fe1ef
--- /dev/null
+++ b/packages/typescript/ai-gemini/src/utils/index.ts
@@ -0,0 +1,7 @@
+export {
+ createGeminiClient,
+ generateId,
+ getGeminiApiKeyFromEnv,
+ type GeminiClientConfig,
+} from './client'
+export { convertZodToGeminiSchema } from './schema-converter'
diff --git a/packages/typescript/ai-gemini/src/utils/schema-converter.ts b/packages/typescript/ai-gemini/src/utils/schema-converter.ts
new file mode 100644
index 00000000..a234b0b7
--- /dev/null
+++ b/packages/typescript/ai-gemini/src/utils/schema-converter.ts
@@ -0,0 +1,87 @@
+import { toJSONSchema } from 'zod'
+import type { z } from 'zod'
+
+/**
+ * Check if a value is a Zod schema by looking for Zod-specific internals.
+ * Zod schemas have a `_zod` property that contains metadata.
+ */
+function isZodSchema(schema: unknown): schema is z.ZodType {
+ return (
+ typeof schema === 'object' &&
+ schema !== null &&
+ '_zod' in schema &&
+ typeof (schema as any)._zod === 'object'
+ )
+}
+
+/**
+ * Converts a Zod schema to JSON Schema format compatible with Gemini's API.
+ *
+ * Gemini accepts standard JSON Schema without special transformations.
+ *
+ * @param schema - Zod schema to convert
+ * @returns JSON Schema object compatible with Gemini's structured output API
+ *
+ * @example
+ * ```typescript
+ * import { z } from 'zod';
+ *
+ * const zodSchema = z.object({
+ * location: z.string().describe('City name'),
+ * unit: z.enum(['celsius', 'fahrenheit']).optional()
+ * });
+ *
+ * const jsonSchema = convertZodToGeminiSchema(zodSchema);
+ * // Returns standard JSON Schema
+ * ```
+ */
+export function convertZodToGeminiSchema(
+ schema: z.ZodType,
+): Record {
+ if (!isZodSchema(schema)) {
+ throw new Error('Expected a Zod schema')
+ }
+
+ // Use Zod's built-in toJSONSchema
+ const jsonSchema = toJSONSchema(schema, {
+ target: 'openapi-3.0',
+ reused: 'ref',
+ })
+
+ // Remove $schema property as it's not needed for LLM providers
+ let result = jsonSchema
+ if (typeof result === 'object' && '$schema' in result) {
+ const { $schema, ...rest } = result
+ result = rest
+ }
+
+ // Ensure object schemas always have type: "object"
+ if (typeof result === 'object') {
+ const isZodObject =
+ typeof schema === 'object' &&
+ 'def' in schema &&
+ schema.def.type === 'object'
+
+ if (isZodObject && !result.type) {
+ result.type = 'object'
+ }
+
+ if (Object.keys(result).length === 0) {
+ result.type = 'object'
+ }
+
+ if ('properties' in result && !result.type) {
+ result.type = 'object'
+ }
+
+ if (result.type === 'object' && !('properties' in result)) {
+ result.properties = {}
+ }
+
+ if (result.type === 'object' && !('required' in result)) {
+ result.required = []
+ }
+ }
+
+ return result
+}
diff --git a/packages/typescript/ai-gemini/tests/gemini-adapter.test.ts b/packages/typescript/ai-gemini/tests/gemini-adapter.test.ts
index 6c7a08aa..2304a4f9 100644
--- a/packages/typescript/ai-gemini/tests/gemini-adapter.test.ts
+++ b/packages/typescript/ai-gemini/tests/gemini-adapter.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
-import { chat, summarize, embedding } from '@tanstack/ai'
+import { ai } from '@tanstack/ai'
import type { Tool, StreamChunk } from '@tanstack/ai'
import {
Type,
@@ -7,10 +7,10 @@ import {
type HarmCategory,
type SafetySetting,
} from '@google/genai'
-import {
- GeminiAdapter,
- type GeminiProviderOptions,
-} from '../src/gemini-adapter'
+import { GeminiTextAdapter } from '../src/adapters/text'
+import { GeminiSummarizeAdapter } from '../src/adapters/summarize'
+import { GeminiEmbedAdapter } from '../src/adapters/embed'
+import type { GeminiProviderOptions } from '../src/gemini-adapter'
import type { Schema } from '@google/genai'
const mocks = vi.hoisted(() => {
@@ -54,7 +54,9 @@ vi.mock('@google/genai', async () => {
}
})
-const createAdapter = () => new GeminiAdapter({ apiKey: 'test-key' })
+const createTextAdapter = () => new GeminiTextAdapter({ apiKey: 'test-key' })
+const createSummarizeAdapter = () => new GeminiSummarizeAdapter('test-key')
+const createEmbedAdapter = () => new GeminiEmbedAdapter('test-key')
const weatherTool: Tool = {
name: 'lookup_weather',
@@ -95,10 +97,10 @@ describe('GeminiAdapter through AI', () => {
mocks.generateContentStreamSpy.mockResolvedValue(createStream(streamChunks))
- const adapter = createAdapter()
+ const adapter = createTextAdapter()
// Consume the stream to trigger the API call
- for await (const _ of chat({
+ for await (const _ of ai({
adapter,
model: 'gemini-2.5-pro',
messages: [{ role: 'user', content: 'How is the weather in Madrid?' }],
@@ -207,10 +209,10 @@ describe('GeminiAdapter through AI', () => {
cachedContent: 'cachedContents/weather-context',
} as const
- const adapter = createAdapter()
+ const adapter = createTextAdapter()
// Consume the stream to trigger the API call
- for await (const _ of chat({
+ for await (const _ of ai({
adapter,
model: 'gemini-2.5-pro',
messages: [{ role: 'user', content: 'Provide structured response' }],
@@ -309,9 +311,9 @@ describe('GeminiAdapter through AI', () => {
mocks.generateContentStreamSpy.mockResolvedValue(createStream(streamChunks))
- const adapter = createAdapter()
+ const adapter = createTextAdapter()
const received: StreamChunk[] = []
- for await (const chunk of chat({
+ for await (const chunk of ai({
adapter,
model: 'gemini-2.5-pro',
messages: [{ role: 'user', content: 'Tell me a joke' }],
@@ -350,19 +352,17 @@ describe('GeminiAdapter through AI', () => {
it('uses summarize function with models API', async () => {
const summaryText = 'Short and sweet.'
mocks.generateContentSpy.mockResolvedValueOnce({
- candidates: [
- {
- content: {
- parts: [{ text: summaryText }],
- },
- },
- ],
+ text: summaryText,
+ usageMetadata: {
+ promptTokenCount: 10,
+ candidatesTokenCount: 5,
+ },
})
- const adapter = createAdapter()
- const result = await summarize({
+ const adapter = createSummarizeAdapter()
+ const result = await ai({
adapter,
- model: 'gemini-2.5-flash',
+ model: 'gemini-2.0-flash',
text: 'A very long passage that needs to be shortened',
maxLength: 123,
style: 'paragraph',
@@ -370,30 +370,30 @@ describe('GeminiAdapter through AI', () => {
expect(mocks.generateContentSpy).toHaveBeenCalledTimes(1)
const [payload] = mocks.generateContentSpy.mock.calls[0]
- expect(payload.model).toBe('gemini-2.5-flash')
- expect(payload.config).toMatchObject({
- temperature: 0.3,
- maxOutputTokens: 123,
- })
+ expect(payload.model).toBe('gemini-2.0-flash')
+ expect(payload.config.systemInstruction).toContain('summarizes text')
+ expect(payload.config.systemInstruction).toContain('123 words')
expect(result.summary).toBe(summaryText)
})
it('creates embeddings via embedding function', async () => {
- mocks.embedContentSpy.mockResolvedValueOnce({
- embeddings: [{ values: [0.1, 0.2] }, { values: [0.3, 0.4] }],
- })
-
- const adapter = createAdapter()
- const result = await embedding({
+ // The embed adapter calls embedContent once per input
+ mocks.embedContentSpy
+ .mockResolvedValueOnce({
+ embeddings: [{ values: [0.1, 0.2] }],
+ })
+ .mockResolvedValueOnce({
+ embeddings: [{ values: [0.3, 0.4] }],
+ })
+
+ const adapter = createEmbedAdapter()
+ const result = await ai({
adapter,
- model: 'gemini-embedding-001' as 'gemini-2.5-pro', // type workaround for embedding model
+ model: 'text-embedding-004',
input: ['doc one', 'doc two'],
})
- expect(mocks.embedContentSpy).toHaveBeenCalledTimes(1)
- const [payload] = mocks.embedContentSpy.mock.calls[0]
- expect(payload.model).toBe('gemini-embedding-001')
- expect(payload.contents).toEqual(['doc one', 'doc two'])
+ expect(mocks.embedContentSpy).toHaveBeenCalledTimes(2)
expect(result.embeddings).toEqual([
[0.1, 0.2],
[0.3, 0.4],
diff --git a/packages/typescript/ai-gemini/tests/image-adapter.test.ts b/packages/typescript/ai-gemini/tests/image-adapter.test.ts
new file mode 100644
index 00000000..7ba2f456
--- /dev/null
+++ b/packages/typescript/ai-gemini/tests/image-adapter.test.ts
@@ -0,0 +1,195 @@
+import { describe, it, expect, vi } from 'vitest'
+import { GeminiImageAdapter, createGeminiImage } from '../src/adapters/image'
+import {
+ sizeToAspectRatio,
+ validateImageSize,
+ validateNumberOfImages,
+ validatePrompt,
+} from '../src/image/image-provider-options'
+
+describe('Gemini Image Adapter', () => {
+ describe('createGeminiImage', () => {
+ it('creates an adapter with the provided API key', () => {
+ const adapter = createGeminiImage('test-api-key')
+ expect(adapter).toBeInstanceOf(GeminiImageAdapter)
+ expect(adapter.kind).toBe('image')
+ expect(adapter.name).toBe('gemini')
+ })
+
+ it('has the correct models', () => {
+ const adapter = createGeminiImage('test-api-key')
+ expect(adapter.models).toContain('imagen-3.0-generate-002')
+ expect(adapter.models).toContain('imagen-4.0-generate-001')
+ expect(adapter.models).toContain('imagen-4.0-fast-generate-001')
+ expect(adapter.models).toContain('imagen-4.0-ultra-generate-001')
+ })
+ })
+
+ describe('sizeToAspectRatio', () => {
+ it('maps common sizes to aspect ratios', () => {
+ expect(sizeToAspectRatio('1024x1024')).toBe('1:1')
+ expect(sizeToAspectRatio('512x512')).toBe('1:1')
+ expect(sizeToAspectRatio('1920x1080')).toBe('16:9')
+ expect(sizeToAspectRatio('1080x1920')).toBe('9:16')
+ })
+
+ it('returns undefined for unknown sizes', () => {
+ expect(sizeToAspectRatio('999x999')).toBeUndefined()
+ expect(sizeToAspectRatio('invalid')).toBeUndefined()
+ })
+
+ it('returns undefined for undefined input', () => {
+ expect(sizeToAspectRatio(undefined)).toBeUndefined()
+ })
+ })
+
+ describe('validateImageSize', () => {
+ it('accepts valid sizes that map to aspect ratios', () => {
+ expect(() =>
+ validateImageSize('imagen-3.0-generate-002', '1024x1024'),
+ ).not.toThrow()
+ expect(() =>
+ validateImageSize('imagen-4.0-generate-001', '1920x1080'),
+ ).not.toThrow()
+ })
+
+ it('rejects invalid sizes', () => {
+ expect(() =>
+ validateImageSize('imagen-3.0-generate-002', '999x999'),
+ ).toThrow()
+ })
+
+ it('accepts undefined size', () => {
+ expect(() =>
+ validateImageSize('imagen-3.0-generate-002', undefined),
+ ).not.toThrow()
+ })
+ })
+
+ describe('validateNumberOfImages', () => {
+ it('accepts 1-4 images', () => {
+ expect(() =>
+ validateNumberOfImages('imagen-3.0-generate-002', 1),
+ ).not.toThrow()
+ expect(() =>
+ validateNumberOfImages('imagen-3.0-generate-002', 4),
+ ).not.toThrow()
+ })
+
+ it('rejects more than 4 images', () => {
+ expect(() =>
+ validateNumberOfImages('imagen-3.0-generate-002', 5),
+ ).toThrow()
+ })
+
+ it('rejects 0 images', () => {
+ expect(() =>
+ validateNumberOfImages('imagen-3.0-generate-002', 0),
+ ).toThrow()
+ })
+
+ it('accepts undefined', () => {
+ expect(() =>
+ validateNumberOfImages('imagen-3.0-generate-002', undefined),
+ ).not.toThrow()
+ })
+ })
+
+ describe('validatePrompt', () => {
+ it('rejects empty prompts', () => {
+ expect(() =>
+ validatePrompt({ prompt: '', model: 'imagen-3.0-generate-002' }),
+ ).toThrow()
+ expect(() =>
+ validatePrompt({ prompt: ' ', model: 'imagen-3.0-generate-002' }),
+ ).toThrow()
+ })
+
+ it('accepts non-empty prompts', () => {
+ expect(() =>
+ validatePrompt({ prompt: 'A cat', model: 'imagen-3.0-generate-002' }),
+ ).not.toThrow()
+ })
+ })
+
+ describe('generateImages', () => {
+ it('calls the Gemini models.generateImages API', async () => {
+ const mockResponse = {
+ generatedImages: [
+ {
+ image: {
+ imageBytes: 'base64encodedimage',
+ },
+ },
+ ],
+ }
+
+ const mockGenerateImages = vi.fn().mockResolvedValueOnce(mockResponse)
+
+ const adapter = createGeminiImage('test-api-key')
+ // Replace the internal Gemini SDK client with our mock
+ ;(
+ adapter as unknown as {
+ client: { models: { generateImages: unknown } }
+ }
+ ).client = {
+ models: {
+ generateImages: mockGenerateImages,
+ },
+ }
+
+ const result = await adapter.generateImages({
+ model: 'imagen-3.0-generate-002',
+ prompt: 'A cat wearing a hat',
+ numberOfImages: 1,
+ size: '1024x1024',
+ })
+
+ expect(mockGenerateImages).toHaveBeenCalledWith({
+ model: 'imagen-3.0-generate-002',
+ prompt: 'A cat wearing a hat',
+ config: {
+ numberOfImages: 1,
+ aspectRatio: '1:1',
+ },
+ })
+
+ expect(result.model).toBe('imagen-3.0-generate-002')
+ expect(result.images).toHaveLength(1)
+ expect(result.images[0].b64Json).toBe('base64encodedimage')
+ })
+
+ it('generates a unique ID for each response', async () => {
+ const mockResponse = {
+ generatedImages: [{ image: { imageBytes: 'base64' } }],
+ }
+
+ const mockGenerateImages = vi.fn().mockResolvedValue(mockResponse)
+
+ const adapter = createGeminiImage('test-api-key')
+ ;(
+ adapter as unknown as {
+ client: { models: { generateImages: unknown } }
+ }
+ ).client = {
+ models: {
+ generateImages: mockGenerateImages,
+ },
+ }
+
+ const result1 = await adapter.generateImages({
+ model: 'imagen-3.0-generate-002',
+ prompt: 'Test prompt',
+ })
+
+ const result2 = await adapter.generateImages({
+ model: 'imagen-3.0-generate-002',
+ prompt: 'Test prompt',
+ })
+
+ expect(result1.id).not.toBe(result2.id)
+ expect(result1.id).toMatch(/^gemini-/)
+ expect(result2.id).toMatch(/^gemini-/)
+ })
+ })
+})
diff --git a/packages/typescript/ai-ollama/package.json b/packages/typescript/ai-ollama/package.json
index 3d26a035..0baae378 100644
--- a/packages/typescript/ai-ollama/package.json
+++ b/packages/typescript/ai-ollama/package.json
@@ -42,14 +42,14 @@
],
"dependencies": {
"@tanstack/ai": "workspace:*",
- "ollama": "^0.6.3",
- "zod": "^4.1.13"
+ "ollama": "^0.6.3"
},
"devDependencies": {
"@vitest/coverage-v8": "4.0.14",
- "vite": "^7.2.4"
+ "vite": "^7.2.7"
},
"peerDependencies": {
- "@tanstack/ai": "workspace:*"
+ "@tanstack/ai": "workspace:*",
+ "zod": "^4.0.0"
}
}
diff --git a/packages/typescript/ai-ollama/src/adapters/embed.ts b/packages/typescript/ai-ollama/src/adapters/embed.ts
new file mode 100644
index 00000000..4d9c1003
--- /dev/null
+++ b/packages/typescript/ai-ollama/src/adapters/embed.ts
@@ -0,0 +1,129 @@
+import {
+ createOllamaClient,
+ estimateTokens,
+ getOllamaHostFromEnv,
+} from '../utils'
+
+import type { Ollama } from 'ollama'
+import type { EmbeddingAdapter } from '@tanstack/ai/adapters'
+import type { EmbeddingOptions, EmbeddingResult } from '@tanstack/ai'
+
+/**
+ * Ollama embedding models
+ * Note: Ollama models are dynamically loaded, this is a common subset
+ */
+export const OllamaEmbeddingModels = [
+ 'nomic-embed-text',
+ 'mxbai-embed-large',
+ 'all-minilm',
+ 'snowflake-arctic-embed',
+] as const
+
+export type OllamaEmbeddingModel =
+ | (typeof OllamaEmbeddingModels)[number]
+ | (string & {})
+
+/**
+ * Ollama-specific provider options for embeddings
+ */
+export interface OllamaEmbedProviderOptions {
+ /** Number of GPU layers to use */
+ num_gpu?: number
+ /** Number of threads to use */
+ num_thread?: number
+ /** Use memory-mapped model */
+ use_mmap?: boolean
+ /** Use memory-locked model */
+ use_mlock?: boolean
+}
+
+export interface OllamaEmbedAdapterOptions {
+ model?: OllamaEmbeddingModel
+ host?: string
+}
+
+/**
+ * Ollama Embedding Adapter
+ * A tree-shakeable embedding adapter for Ollama
+ */
+export class OllamaEmbedAdapter implements EmbeddingAdapter<
+ typeof OllamaEmbeddingModels,
+ OllamaEmbedProviderOptions
+> {
+ readonly kind = 'embedding' as const
+ readonly name = 'ollama' as const
+ readonly models = OllamaEmbeddingModels
+
+ /** Type-only property for provider options inference */
+ declare _providerOptions?: OllamaEmbedProviderOptions
+
+ private client: Ollama
+ private defaultModel: OllamaEmbeddingModel
+
+ constructor(
+ hostOrClient?: string | Ollama,
+ options: OllamaEmbedAdapterOptions = {},
+ ) {
+ if (typeof hostOrClient === 'string' || hostOrClient === undefined) {
+ this.client = createOllamaClient({ host: hostOrClient })
+ } else {
+ this.client = hostOrClient
+ }
+ this.defaultModel = options.model ?? 'nomic-embed-text'
+ }
+
+ async createEmbeddings(options: EmbeddingOptions): Promise {
+ const model = options.model || this.defaultModel
+
+ // Ensure input is an array
+ const inputs = Array.isArray(options.input)
+ ? options.input
+ : [options.input]
+
+ const embeddings: Array> = []
+
+ for (const input of inputs) {
+ const response = await this.client.embeddings({
+ model,
+ prompt: input,
+ })
+
+ embeddings.push(response.embedding)
+ }
+
+ const promptTokens = inputs.reduce(
+ (sum: number, input: string) => sum + estimateTokens(input),
+ 0,
+ )
+
+ return {
+ id: `embed-${Date.now()}`,
+ model,
+ embeddings,
+ usage: {
+ promptTokens,
+ totalTokens: promptTokens,
+ },
+ }
+ }
+}
+
+/**
+ * Creates an Ollama embedding adapter with explicit host
+ */
+export function createOllamaEmbed(
+ host?: string,
+ options?: OllamaEmbedAdapterOptions,
+): OllamaEmbedAdapter {
+ return new OllamaEmbedAdapter(host, options)
+}
+
+/**
+ * Creates an Ollama embedding adapter with host from environment
+ */
+export function ollamaEmbed(
+ options?: OllamaEmbedAdapterOptions,
+): OllamaEmbedAdapter {
+ const host = getOllamaHostFromEnv()
+ return new OllamaEmbedAdapter(host, options)
+}
diff --git a/packages/typescript/ai-ollama/src/adapters/summarize.ts b/packages/typescript/ai-ollama/src/adapters/summarize.ts
new file mode 100644
index 00000000..1291242c
--- /dev/null
+++ b/packages/typescript/ai-ollama/src/adapters/summarize.ts
@@ -0,0 +1,167 @@
+import {
+ createOllamaClient,
+ estimateTokens,
+ generateId,
+ getOllamaHostFromEnv,
+} from '../utils'
+
+import type { Ollama } from 'ollama'
+import type { SummarizeAdapter } from '@tanstack/ai/adapters'
+import type { SummarizationOptions, SummarizationResult } from '@tanstack/ai'
+
+/**
+ * Ollama models suitable for summarization
+ * Note: Ollama models are dynamically loaded, this is a common subset
+ */
+export const OllamaSummarizeModels = [
+ 'llama2',
+ 'llama3',
+ 'llama3.1',
+ 'llama3.2',
+ 'mistral',
+ 'mixtral',
+ 'phi',
+ 'phi3',
+ 'qwen2',
+ 'qwen2.5',
+] as const
+
+export type OllamaSummarizeModel =
+ | (typeof OllamaSummarizeModels)[number]
+ | (string & {})
+
+/**
+ * Ollama-specific provider options for summarization
+ */
+export interface OllamaSummarizeProviderOptions {
+ /** Number of GPU layers to use */
+ num_gpu?: number
+ /** Number of threads to use */
+ num_thread?: number
+ /** Context window size */
+ num_ctx?: number
+ /** Number of tokens to predict */
+ num_predict?: number
+ /** Temperature for sampling */
+ temperature?: number
+ /** Top-p sampling */
+ top_p?: number
+ /** Top-k sampling */
+ top_k?: number
+ /** Repeat penalty */
+ repeat_penalty?: number
+}
+
+export interface OllamaSummarizeAdapterOptions {
+ model?: OllamaSummarizeModel
+ host?: string
+}
+
+/**
+ * Ollama Summarize Adapter
+ * A tree-shakeable summarization adapter for Ollama
+ */
+export class OllamaSummarizeAdapter implements SummarizeAdapter<
+ typeof OllamaSummarizeModels,
+ OllamaSummarizeProviderOptions
+> {
+ readonly kind = 'summarize' as const
+ readonly name = 'ollama' as const
+ readonly models = OllamaSummarizeModels
+
+ /** Type-only property for provider options inference */
+ declare _providerOptions?: OllamaSummarizeProviderOptions
+
+ private client: Ollama
+ private defaultModel: OllamaSummarizeModel
+
+ constructor(
+ hostOrClient?: string | Ollama,
+ options: OllamaSummarizeAdapterOptions = {},
+ ) {
+ if (typeof hostOrClient === 'string' || hostOrClient === undefined) {
+ this.client = createOllamaClient({ host: hostOrClient })
+ } else {
+ this.client = hostOrClient
+ }
+ this.defaultModel = options.model ?? 'llama3'
+ }
+
+ async summarize(options: SummarizationOptions): Promise {
+ const model = options.model || this.defaultModel
+
+ const prompt = this.buildSummarizationPrompt(options)
+
+ const response = await this.client.generate({
+ model,
+ prompt,
+ options: {
+ temperature: 0.3,
+ num_predict: options.maxLength ?? 500,
+ },
+ stream: false,
+ })
+
+ const promptTokens = estimateTokens(prompt)
+ const completionTokens = estimateTokens(response.response)
+
+ return {
+ id: generateId('sum'),
+ model: response.model,
+ summary: response.response,
+ usage: {
+ promptTokens,
+ completionTokens,
+ totalTokens: promptTokens + completionTokens,
+ },
+ }
+ }
+
+ private buildSummarizationPrompt(options: SummarizationOptions): string {
+ let prompt = 'You are a professional summarizer. '
+
+ switch (options.style) {
+ case 'bullet-points':
+ prompt += 'Provide a summary in bullet point format. '
+ break
+ case 'concise':
+ prompt += 'Provide a very brief one or two sentence summary. '
+ break
+ case 'paragraph':
+ default:
+ prompt += 'Provide a clear and concise summary in paragraph format. '
+ }
+
+ if (options.maxLength) {
+ prompt += `Keep the summary under ${options.maxLength} words. `
+ }
+
+ if (options.focus && options.focus.length > 0) {
+ prompt += `Focus on: ${options.focus.join(', ')}. `
+ }
+
+ prompt += `\n\nText to summarize:\n${options.text}\n\nSummary:`
+
+ return prompt
+ }
+}
+
+/**
+ * Creates an Ollama summarize adapter with explicit host
+ */
+export function createOllamaSummarize(
+ host?: string,
+ options?: OllamaSummarizeAdapterOptions,
+): OllamaSummarizeAdapter {
+ return new OllamaSummarizeAdapter(host, options)
+}
+
+/**
+ * Creates an Ollama summarize adapter with host from environment
+ */
+export function ollamaSummarize(
+ options?: OllamaSummarizeAdapterOptions,
+): OllamaSummarizeAdapter {
+ const host = getOllamaHostFromEnv()
+ return new OllamaSummarizeAdapter(host, options)
+}
diff --git a/packages/typescript/ai-ollama/src/adapters/text.ts b/packages/typescript/ai-ollama/src/adapters/text.ts
new file mode 100644
index 00000000..b5b9b48d
--- /dev/null
+++ b/packages/typescript/ai-ollama/src/adapters/text.ts
@@ -0,0 +1,406 @@
+import { BaseTextAdapter } from '@tanstack/ai/adapters'
+
+import {
+ convertZodToOllamaSchema,
+ createOllamaClient,
+ generateId,
+ getOllamaHostFromEnv,
+} from '../utils'
+
+import type {
+ StructuredOutputOptions,
+ StructuredOutputResult,
+} from '@tanstack/ai/adapters'
+import type {
+ AbortableAsyncIterator,
+ ChatRequest,
+ ChatResponse,
+ Message,
+ Ollama,
+ Tool as OllamaTool,
+ ToolCall,
+} from 'ollama'
+import type { StreamChunk, TextOptions, Tool } from '@tanstack/ai'
+
+/**
+ * Ollama text models
+ * Note: Ollama models are dynamically loaded, this is a common subset
+ */
+export const OllamaTextModels = [
+ 'llama2',
+ 'llama3',
+ 'llama3.1',
+ 'llama3.2',
+ 'codellama',
+ 'mistral',
+ 'mixtral',
+ 'phi',
+ 'phi3',
+ 'neural-chat',
+ 'starling-lm',
+ 'orca-mini',
+ 'vicuna',
+ 'nous-hermes',
+ 'qwen2',
+ 'qwen2.5',
+ 'gemma',
+ 'gemma2',
+ 'deepseek-coder',
+ 'command-r',
+] as const
+
+export type OllamaTextModel = (typeof OllamaTextModels)[number] | (string & {})
+
+/**
+ * Ollama-specific provider options
+ */
+export interface OllamaTextProviderOptions {
+ /** Number of tokens to keep from the prompt */
+ num_keep?: number
+ /** Number of tokens from context to consider for next token prediction */
+ top_k?: number
+ /** Minimum probability for nucleus sampling */
+ min_p?: number
+ /** Tail-free sampling parameter */
+ tfs_z?: number
+ /** Typical probability sampling parameter */
+ typical_p?: number
+ /** Number of previous tokens to consider for repetition penalty */
+ repeat_last_n?: number
+ /** Penalty for repeating tokens */
+ repeat_penalty?: number
+ /** Enable Mirostat sampling (0=disabled, 1=Mirostat, 2=Mirostat 2.0) */
+ mirostat?: number
+ /** Target entropy for Mirostat */
+ mirostat_tau?: number
+ /** Learning rate for Mirostat */
+ mirostat_eta?: number
+ /** Enable penalize_newline */
+ penalize_newline?: boolean
+ /** Enable NUMA support */
+ numa?: boolean
+ /** Context window size */
+ num_ctx?: number
+ /** Batch size for prompt processing */
+ num_batch?: number
+ /** Number of GQA groups (for some models) */
+ num_gqa?: number
+ /** Number of GPU layers to use */
+ num_gpu?: number
+ /** GPU to use for inference */
+ main_gpu?: number
+ /** Use memory-mapped model */
+ use_mmap?: boolean
+ /** Use memory-locked model */
+ use_mlock?: boolean
+ /** Number of threads to use */
+ num_thread?: number
+}
+
+export interface OllamaTextAdapterOptions {
+ model?: OllamaTextModel
+ host?: string
+}
+
+/**
+ * Ollama Text/Chat Adapter
+ * A tree-shakeable chat adapter for Ollama
+ */
+export class OllamaTextAdapter extends BaseTextAdapter<
+ typeof OllamaTextModels,
+ OllamaTextProviderOptions
+> {
+ readonly kind = 'text' as const
+ readonly name = 'ollama' as const
+ readonly models = OllamaTextModels
+
+ private client: Ollama
+ private defaultModel: OllamaTextModel
+
+ constructor(
+ hostOrClient?: string | Ollama,
+ options: OllamaTextAdapterOptions = {},
+ ) {
+ super({})
+ if (typeof hostOrClient === 'string' || hostOrClient === undefined) {
+ this.client = createOllamaClient({ host: hostOrClient })
+ } else {
+ this.client = hostOrClient
+ }
+ this.defaultModel = options.model ?? 'llama3'
+ }
+
+ async *chatStream(options: TextOptions): AsyncIterable {
+ const mappedOptions = this.mapCommonOptionsToOllama(options)
+ const response = await this.client.chat({
+ ...mappedOptions,
+ stream: true,
+ })
+ yield* this.processOllamaStreamChunks(response)
+ }
+
+ /**
+ * Generate structured output using Ollama's JSON format option.
+ * Uses format: 'json' with the schema to ensure structured output.
+ * Converts the Zod schema to JSON Schema format compatible with Ollama's API.
+ */
+ async structuredOutput(
+ options: StructuredOutputOptions,
+ ): Promise> {
+ const { chatOptions, outputSchema } = options
+
+ // Convert Zod schema to Ollama-compatible JSON Schema
+ const jsonSchema = convertZodToOllamaSchema(outputSchema)
+
+ const mappedOptions = this.mapCommonOptionsToOllama(chatOptions)
+
+ try {
+ // Make non-streaming request with JSON format
+ const response = await this.client.chat({
+ ...mappedOptions,
+ stream: false,
+ format: jsonSchema,
+ })
+
+ const rawText = response.message.content
+
+ // Parse the JSON response
+ let parsed: unknown
+ try {
+ parsed = JSON.parse(rawText)
+ } catch {
+ throw new Error(
+ `Failed to parse structured output as JSON. Content: ${rawText.slice(0, 200)}${rawText.length > 200 ? '...' : ''}`,
+ )
+ }
+
+ return {
+ data: parsed,
+ rawText,
+ }
+ } catch (error: unknown) {
+ const err = error as Error
+ throw new Error(
+ `Structured output generation failed: ${err.message || 'Unknown error occurred'}`,
+ )
+ }
+ }
+
+ private async *processOllamaStreamChunks(
+ stream: AbortableAsyncIterator,
+ ): AsyncIterable {
+ let accumulatedContent = ''
+ const timestamp = Date.now()
+ const responseId = generateId('msg')
+ let accumulatedReasoning = ''
+ let hasEmittedToolCalls = false
+
+ for await (const chunk of stream) {
+ const handleToolCall = (toolCall: ToolCall): StreamChunk => {
+ const actualToolCall = toolCall as ToolCall & {
+ id: string
+ function: { index: number }
+ }
+ return {
+ type: 'tool_call',
+ id: responseId,
+ model: chunk.model,
+ timestamp,
+ toolCall: {
+ type: 'function',
+ id: actualToolCall.id,
+ function: {
+ name: actualToolCall.function.name || '',
+ arguments:
+ typeof actualToolCall.function.arguments === 'string'
+ ? actualToolCall.function.arguments
+ : JSON.stringify(actualToolCall.function.arguments),
+ },
+ },
+ index: actualToolCall.function.index,
+ }
+ }
+
+ if (chunk.done) {
+ if (chunk.message.tool_calls && chunk.message.tool_calls.length > 0) {
+ for (const toolCall of chunk.message.tool_calls) {
+ yield handleToolCall(toolCall)
+ hasEmittedToolCalls = true
+ }
+ yield {
+ type: 'done',
+ id: responseId,
+ model: chunk.model,
+ timestamp,
+ finishReason: 'tool_calls',
+ }
+ continue
+ }
+ yield {
+ type: 'done',
+ id: responseId,
+ model: chunk.model,
+ timestamp,
+ finishReason: hasEmittedToolCalls ? 'tool_calls' : 'stop',
+ }
+ continue
+ }
+
+ if (chunk.message.content) {
+ accumulatedContent += chunk.message.content
+ yield {
+ type: 'content',
+ id: responseId,
+ model: chunk.model,
+ timestamp,
+ delta: chunk.message.content,
+ content: accumulatedContent,
+ role: 'assistant',
+ }
+ }
+
+ if (chunk.message.tool_calls && chunk.message.tool_calls.length > 0) {
+ for (const toolCall of chunk.message.tool_calls) {
+ yield handleToolCall(toolCall)
+ hasEmittedToolCalls = true
+ }
+ }
+
+ if (chunk.message.thinking) {
+ accumulatedReasoning += chunk.message.thinking
+ yield {
+ type: 'thinking',
+ id: responseId,
+ model: chunk.model,
+ timestamp,
+ content: accumulatedReasoning,
+ delta: chunk.message.thinking,
+ }
+ }
+ }
+ }
+
+ private convertToolsToOllamaFormat(
+ tools?: Array,
+ ): Array | undefined {
+ if (!tools || tools.length === 0) {
+ return undefined
+ }
+
+ return tools.map((tool) => ({
+ type: 'function',
+ function: {
+ name: tool.name,
+ description: tool.description,
+ parameters: tool.inputSchema
+ ? convertZodToOllamaSchema(tool.inputSchema)
+ : { type: 'object', properties: {}, required: [] },
+ },
+ }))
+ }
+
+ private formatMessages(messages: TextOptions['messages']): Array {
+ return messages.map((msg) => {
+ let textContent = ''
+ const images: Array = []
+
+ if (Array.isArray(msg.content)) {
+ for (const part of msg.content) {
+ if (part.type === 'text') {
+ textContent += part.content
+ } else if (part.type === 'image') {
+ if (part.source.type === 'data') {
+ images.push(part.source.value)
+ } else {
+ images.push(part.source.value)
+ }
+ }
+ }
+ } else {
+ textContent = msg.content || ''
+ }
+
+ const hasToolCallId = msg.role === 'tool' && msg.toolCallId
+ return {
+ role: hasToolCallId ? 'tool' : msg.role,
+ content: hasToolCallId
+ ? typeof msg.content === 'string'
+ ? msg.content
+ : JSON.stringify(msg.content)
+ : textContent,
+ ...(images.length > 0 ? { images } : {}),
+ ...(msg.role === 'assistant' &&
+ msg.toolCalls &&
+ msg.toolCalls.length > 0
+ ? {
+ tool_calls: msg.toolCalls.map((toolCall) => {
+ let parsedArguments: Record = {}
+ if (typeof toolCall.function.arguments === 'string') {
+ try {
+ parsedArguments = JSON.parse(
+ toolCall.function.arguments,
+ ) as Record
+ } catch {
+ parsedArguments = {}
+ }
+ } else {
+ parsedArguments = toolCall.function
+ .arguments as unknown as Record
+ }
+
+ return {
+ id: toolCall.id,
+ type: toolCall.type,
+ function: {
+ name: toolCall.function.name,
+ arguments: parsedArguments,
+ },
+ }
+ }),
+ }
+ : {}),
+ }
+ })
+ }
+
+ private mapCommonOptionsToOllama(options: TextOptions): ChatRequest {
+ const model = options.model || this.defaultModel
+ const providerOptions = options.providerOptions as
+ | OllamaTextProviderOptions
+ | undefined
+
+ const ollamaOptions = {
+ temperature: options.options?.temperature,
+ top_p: options.options?.topP,
+ num_predict: options.options?.maxTokens,
+ ...providerOptions,
+ }
+
+ return {
+ model,
+ options: ollamaOptions,
+ messages: this.formatMessages(options.messages),
+ tools: this.convertToolsToOllamaFormat(options.tools),
+ }
+ }
+}
+
+/**
+ * Creates an Ollama text adapter with explicit host
+ */
+export function createOllamaText(
+ host?: string,
+ options?: OllamaTextAdapterOptions,
+): OllamaTextAdapter {
+ return new OllamaTextAdapter(host, options)
+}
+
+/**
+ * Creates an Ollama text adapter with host from environment
+ */
+export function ollamaText(
+ options?: OllamaTextAdapterOptions,
+): OllamaTextAdapter {
+ const host = getOllamaHostFromEnv()
+ return new OllamaTextAdapter(host, options)
+}
diff --git a/packages/typescript/ai-ollama/src/index.ts b/packages/typescript/ai-ollama/src/index.ts
index e3f1428f..e4f0e779 100644
--- a/packages/typescript/ai-ollama/src/index.ts
+++ b/packages/typescript/ai-ollama/src/index.ts
@@ -1,3 +1,50 @@
+// ===========================
+// New tree-shakeable adapters
+// ===========================
+
+// Text/Chat adapter
+export {
+ OllamaTextAdapter,
+ OllamaTextModels,
+ createOllamaText,
+ ollamaText,
+ type OllamaTextAdapterOptions,
+ type OllamaTextModel,
+ type OllamaTextProviderOptions,
+} from './adapters/text'
+
+// Embedding adapter
+export {
+ OllamaEmbedAdapter,
+ OllamaEmbeddingModels,
+ createOllamaEmbed,
+ ollamaEmbed,
+ type OllamaEmbedAdapterOptions,
+ type OllamaEmbeddingModel,
+ type OllamaEmbedProviderOptions,
+} from './adapters/embed'
+
+// Summarize adapter
+export {
+ OllamaSummarizeAdapter,
+ OllamaSummarizeModels,
+ createOllamaSummarize,
+ ollamaSummarize,
+ type OllamaSummarizeAdapterOptions,
+ type OllamaSummarizeModel,
+ type OllamaSummarizeProviderOptions,
+} from './adapters/summarize'
+
+// ===========================
+// Legacy monolithic adapter (deprecated)
+// ===========================
+
+/**
+ * @deprecated Use the new tree-shakeable adapters instead:
+ * - `ollamaText()` / `createOllamaText()` for chat/text generation
+ * - `ollamaEmbed()` / `createOllamaEmbed()` for embeddings
+ * - `ollamaSummarize()` / `createOllamaSummarize()` for summarization
+ */
export {
Ollama,
createOllama,
diff --git a/packages/typescript/ai-ollama/src/ollama-adapter.ts b/packages/typescript/ai-ollama/src/ollama-adapter.ts
index fc6080c0..3a650106 100644
--- a/packages/typescript/ai-ollama/src/ollama-adapter.ts
+++ b/packages/typescript/ai-ollama/src/ollama-adapter.ts
@@ -1,5 +1,6 @@
import { Ollama as OllamaSDK } from 'ollama'
-import { BaseAdapter, convertZodToJsonSchema } from '@tanstack/ai'
+import { BaseAdapter } from '@tanstack/ai'
+import { convertZodToOllamaSchema } from './utils/schema-converter'
import type {
AbortableAsyncIterator,
ChatRequest,
@@ -9,13 +10,13 @@ import type {
ToolCall,
} from 'ollama'
import type {
- ChatOptions,
DefaultMessageMetadataByModality,
EmbeddingOptions,
EmbeddingResult,
StreamChunk,
SummarizationOptions,
SummarizationResult,
+ TextOptions,
Tool,
} from '@tanstack/ai'
@@ -163,7 +164,7 @@ export class Ollama extends BaseAdapter<
})
}
- async *chatStream(options: ChatOptions): AsyncIterable {
+ async *chatStream(options: TextOptions): AsyncIterable {
// Use stream converter for now
// Map common options to Ollama format
const mappedOptions = this.mapCommonOptionsToOllama(options)
@@ -373,7 +374,9 @@ export class Ollama extends BaseAdapter<
function: {
name: tool.name,
description: tool.description,
- parameters: convertZodToJsonSchema(tool.inputSchema),
+ parameters: tool.inputSchema
+ ? convertZodToOllamaSchema(tool.inputSchema)
+ : { type: 'object', properties: {}, required: [] },
},
}))
}
@@ -381,7 +384,7 @@ export class Ollama extends BaseAdapter<
/**
* Formats messages for Ollama, handling tool calls, tool results, and multimodal content
*/
- private formatMessages(messages: ChatOptions['messages']): Array {
+ private formatMessages(messages: TextOptions['messages']): Array {
return messages.map((msg) => {
let textContent = ''
const images: Array = []
@@ -453,7 +456,7 @@ export class Ollama extends BaseAdapter<
* Maps common options to Ollama-specific format
* Handles translation of normalized options to Ollama's API format
*/
- private mapCommonOptionsToOllama(options: ChatOptions): ChatRequest {
+ private mapCommonOptionsToOllama(options: TextOptions): ChatRequest {
const providerOptions = options.providerOptions as
| OllamaProviderOptions
| undefined
diff --git a/packages/typescript/ai-ollama/src/utils/client.ts b/packages/typescript/ai-ollama/src/utils/client.ts
new file mode 100644
index 00000000..dc5cd927
--- /dev/null
+++ b/packages/typescript/ai-ollama/src/utils/client.ts
@@ -0,0 +1,49 @@
+import { Ollama } from 'ollama'
+
+export interface OllamaClientConfig {
+ host?: string
+}
+
+/**
+ * Creates an Ollama client instance
+ */
+export function createOllamaClient(config: OllamaClientConfig = {}): Ollama {
+ return new Ollama({
+ host: config.host || 'http://localhost:11434',
+ })
+}
+
+/**
+ * Gets Ollama host from environment variables
+ * Falls back to default localhost
+ */
+export function getOllamaHostFromEnv(): string {
+ const env =
+ typeof globalThis !== 'undefined' &&
+ (globalThis as Record).window
+ ? ((
+ (globalThis as Record).window as Record<
+ string,
+ unknown
+ >
+ ).env as Record | undefined)
+ : typeof process !== 'undefined'
+ ? process.env
+ : undefined
+ return env?.OLLAMA_HOST || 'http://localhost:11434'
+}
+
+/**
+ * Generates a unique ID with a prefix
+ */
+export function generateId(prefix: string = 'msg'): string {
+ return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(7)}`
+}
+
+/**
+ * Estimates token count for text (rough approximation)
+ */
+export function estimateTokens(text: string): number {
+ // Rough approximation: 1 token ≈ 4 characters
+ return Math.ceil(text.length / 4)
+}
diff --git a/packages/typescript/ai-ollama/src/utils/index.ts b/packages/typescript/ai-ollama/src/utils/index.ts
new file mode 100644
index 00000000..3ba50049
--- /dev/null
+++ b/packages/typescript/ai-ollama/src/utils/index.ts
@@ -0,0 +1,8 @@
+export {
+ createOllamaClient,
+ estimateTokens,
+ generateId,
+ getOllamaHostFromEnv,
+ type OllamaClientConfig,
+} from './client'
+export { convertZodToOllamaSchema } from './schema-converter'
diff --git a/packages/typescript/ai-ollama/src/utils/schema-converter.ts b/packages/typescript/ai-ollama/src/utils/schema-converter.ts
new file mode 100644
index 00000000..bb47f9df
--- /dev/null
+++ b/packages/typescript/ai-ollama/src/utils/schema-converter.ts
@@ -0,0 +1,87 @@
+import { toJSONSchema } from 'zod'
+import type { z } from 'zod'
+
+/**
+ * Check if a value is a Zod schema by looking for Zod-specific internals.
+ * Zod schemas have a `_zod` property that contains metadata.
+ */
+function isZodSchema(schema: unknown): schema is z.ZodType {
+ return (
+ typeof schema === 'object' &&
+ schema !== null &&
+ '_zod' in schema &&
+ typeof (schema as any)._zod === 'object'
+ )
+}
+
+/**
+ * Converts a Zod schema to JSON Schema format compatible with Ollama's API.
+ *
+ * Ollama accepts standard JSON Schema without special transformations.
+ *
+ * @param schema - Zod schema to convert
+ * @returns JSON Schema object compatible with Ollama's structured output API
+ *
+ * @example
+ * ```typescript
+ * import { z } from 'zod';
+ *
+ * const zodSchema = z.object({
+ * location: z.string().describe('City name'),
+ * unit: z.enum(['celsius', 'fahrenheit']).optional()
+ * });
+ *
+ * const jsonSchema = convertZodToOllamaSchema(zodSchema);
+ * // Returns standard JSON Schema
+ * ```
+ */
+export function convertZodToOllamaSchema(
+ schema: z.ZodType,
+): Record {
+ if (!isZodSchema(schema)) {
+ throw new Error('Expected a Zod schema')
+ }
+
+ // Use Zod's built-in toJSONSchema
+ const jsonSchema = toJSONSchema(schema, {
+ target: 'openapi-3.0',
+ reused: 'ref',
+ })
+
+ // Remove $schema property as it's not needed for LLM providers
+ let result = jsonSchema
+ if (typeof result === 'object' && '$schema' in result) {
+ const { $schema, ...rest } = result
+ result = rest
+ }
+
+ // Ensure object schemas always have type: "object"
+ if (typeof result === 'object') {
+ const isZodObject =
+ typeof schema === 'object' &&
+ 'def' in schema &&
+ schema.def.type === 'object'
+
+ if (isZodObject && !result.type) {
+ result.type = 'object'
+ }
+
+ if (Object.keys(result).length === 0) {
+ result.type = 'object'
+ }
+
+ if ('properties' in result && !result.type) {
+ result.type = 'object'
+ }
+
+ if (result.type === 'object' && !('properties' in result)) {
+ result.properties = {}
+ }
+
+ if (result.type === 'object' && !('required' in result)) {
+ result.required = []
+ }
+ }
+
+ return result
+}
diff --git a/packages/typescript/ai-openai/package.json b/packages/typescript/ai-openai/package.json
index 48a0d02d..f7a596eb 100644
--- a/packages/typescript/ai-openai/package.json
+++ b/packages/typescript/ai-openai/package.json
@@ -45,9 +45,10 @@
},
"devDependencies": {
"@vitest/coverage-v8": "4.0.14",
- "vite": "^7.2.4"
+ "vite": "^7.2.7"
},
"peerDependencies": {
- "@tanstack/ai": "workspace:*"
+ "@tanstack/ai": "workspace:*",
+ "zod": "^4.0.0"
}
}
diff --git a/packages/typescript/ai-openai/src/adapters/embed.ts b/packages/typescript/ai-openai/src/adapters/embed.ts
new file mode 100644
index 00000000..822de00b
--- /dev/null
+++ b/packages/typescript/ai-openai/src/adapters/embed.ts
@@ -0,0 +1,116 @@
+import { BaseEmbeddingAdapter } from '@tanstack/ai/adapters'
+import { OPENAI_EMBEDDING_MODELS } from '../model-meta'
+import {
+ createOpenAIClient,
+ generateId,
+ getOpenAIApiKeyFromEnv,
+} from '../utils'
+import type { EmbeddingOptions, EmbeddingResult } from '@tanstack/ai'
+import type OpenAI_SDK from 'openai'
+import type { OpenAIClientConfig } from '../utils'
+
+/**
+ * Configuration for OpenAI embedding adapter
+ */
+export interface OpenAIEmbedConfig extends OpenAIClientConfig {}
+
+/**
+ * OpenAI-specific provider options for embeddings
+ * Based on OpenAI Embeddings API documentation
+ * @see https://platform.openai.com/docs/api-reference/embeddings/create
+ */
+export interface OpenAIEmbedProviderOptions {
+ /** Encoding format for embeddings: 'float' | 'base64' */
+ encodingFormat?: 'float' | 'base64'
+ /** Unique identifier for end-user (for abuse monitoring) */
+ user?: string
+}
+
+/**
+ * OpenAI Embedding Adapter
+ *
+ * Tree-shakeable adapter for OpenAI embedding functionality.
+ * Import only what you need for smaller bundle sizes.
+ */
+export class OpenAIEmbedAdapter extends BaseEmbeddingAdapter<
+ typeof OPENAI_EMBEDDING_MODELS,
+ OpenAIEmbedProviderOptions
+> {
+ readonly kind = 'embedding' as const
+ readonly name = 'openai' as const
+ readonly models = OPENAI_EMBEDDING_MODELS
+
+ private client: OpenAI_SDK
+
+ constructor(config: OpenAIEmbedConfig) {
+ super({})
+ this.client = createOpenAIClient(config)
+ }
+
+ async createEmbeddings(options: EmbeddingOptions): Promise {
+ const response = await this.client.embeddings.create({
+ model: options.model || 'text-embedding-ada-002',
+ input: options.input,
+ dimensions: options.dimensions,
+ })
+
+ return {
+ id: generateId(this.name),
+ model: response.model,
+ embeddings: response.data.map((d) => d.embedding),
+ usage: {
+ promptTokens: response.usage.prompt_tokens,
+ totalTokens: response.usage.total_tokens,
+ },
+ }
+ }
+}
+
+/**
+ * Creates an OpenAI embedding adapter with explicit API key
+ *
+ * @param apiKey - Your OpenAI API key
+ * @param config - Optional additional configuration
+ * @returns Configured OpenAI embedding adapter instance
+ *
+ * @example
+ * ```typescript
+ * const adapter = createOpenaiEmbed("sk-...");
+ * ```
+ */
+export function createOpenaiEmbed(
+ apiKey: string,
+ config?: Omit,
+): OpenAIEmbedAdapter {
+ return new OpenAIEmbedAdapter({ apiKey, ...config })
+}
+
+/**
+ * Creates an OpenAI embedding adapter with automatic API key detection from environment variables.
+ *
+ * Looks for `OPENAI_API_KEY` in:
+ * - `process.env` (Node.js)
+ * - `window.env` (Browser with injected env)
+ *
+ * @param config - Optional configuration (excluding apiKey which is auto-detected)
+ * @returns Configured OpenAI embedding adapter instance
+ * @throws Error if OPENAI_API_KEY is not found in environment
+ *
+ * @example
+ * ```typescript
+ * // Automatically uses OPENAI_API_KEY from environment
+ * const adapter = openaiEmbed();
+ *
+ * await generate({
+ * adapter,
+ * model: "text-embedding-3-small",
+ * input: "Hello, world!"
+ * });
+ * ```
+ */
+export function openaiEmbed(
+ config?: Omit,
+): OpenAIEmbedAdapter {
+ const apiKey = getOpenAIApiKeyFromEnv()
+ return createOpenaiEmbed(apiKey, config)
+}
diff --git a/packages/typescript/ai-openai/src/adapters/image.ts b/packages/typescript/ai-openai/src/adapters/image.ts
new file mode 100644
index 00000000..38039145
--- /dev/null
+++ b/packages/typescript/ai-openai/src/adapters/image.ts
@@ -0,0 +1,172 @@
+import { BaseImageAdapter } from '@tanstack/ai/adapters'
+import { OPENAI_IMAGE_MODELS } from '../model-meta'
+import {
+ createOpenAIClient,
+ generateId,
+ getOpenAIApiKeyFromEnv,
+} from '../utils'
+import {
+ validateImageSize,
+ validateNumberOfImages,
+ validatePrompt,
+} from '../image/image-provider-options'
+import type {
+ OpenAIImageModelProviderOptionsByName,
+ OpenAIImageModelSizeByName,
+ OpenAIImageProviderOptions,
+} from '../image/image-provider-options'
+import type {
+ GeneratedImage,
+ ImageGenerationOptions,
+ ImageGenerationResult,
+} from '@tanstack/ai'
+import type OpenAI_SDK from 'openai'
+import type { OpenAIClientConfig } from '../utils'
+
+/**
+ * Configuration for OpenAI image adapter
+ */
+export interface OpenAIImageConfig extends OpenAIClientConfig {}
+
+/**
+ * OpenAI Image Generation Adapter
+ *
+ * Tree-shakeable adapter for OpenAI image generation functionality.
+ * Supports gpt-image-1, gpt-image-1-mini, dall-e-3, and dall-e-2 models.
+ *
+ * Features:
+ * - Model-specific type-safe provider options
+ * - Size validation per model
+ * - Number of images validation
+ */
+export class OpenAIImageAdapter extends BaseImageAdapter<
+ typeof OPENAI_IMAGE_MODELS,
+ OpenAIImageProviderOptions,
+ OpenAIImageModelProviderOptionsByName,
+ OpenAIImageModelSizeByName
+> {
+ readonly kind = 'image' as const
+ readonly name = 'openai' as const
+ readonly models = OPENAI_IMAGE_MODELS
+
+ private client: OpenAI_SDK
+
+ constructor(config: OpenAIImageConfig) {
+ super({})
+ this.client = createOpenAIClient(config)
+ }
+
+ async generateImages(
+ options: ImageGenerationOptions,
+ ): Promise {
+ const { model, prompt, numberOfImages, size } = options
+
+ // Validate inputs
+ validatePrompt({ prompt, model })
+ validateImageSize(model, size)
+ validateNumberOfImages(model, numberOfImages)
+
+ // Build request based on model type
+ const request = this.buildRequest(options)
+
+ const response = await this.client.images.generate({
+ ...request,
+ stream: false,
+ })
+
+ return this.transformResponse(model, response)
+ }
+
+ private buildRequest(
+ options: ImageGenerationOptions,
+ ): OpenAI_SDK.Images.ImageGenerateParams {
+ const { model, prompt, numberOfImages, size, providerOptions } = options
+
+ return {
+ model,
+ prompt,
+ n: numberOfImages ?? 1,
+ size: size as OpenAI_SDK.Images.ImageGenerateParams['size'],
+ ...providerOptions,
+ }
+ }
+
+ private transformResponse(
+ model: string,
+ response: OpenAI_SDK.Images.ImagesResponse,
+ ): ImageGenerationResult {
+ const images: Array = (response.data ?? []).map((item) => ({
+ b64Json: item.b64_json,
+ url: item.url,
+ revisedPrompt: item.revised_prompt,
+ }))
+
+ return {
+ id: generateId(this.name),
+ model,
+ images,
+ usage: response.usage
+ ? {
+ inputTokens: response.usage.input_tokens,
+ outputTokens: response.usage.output_tokens,
+ totalTokens: response.usage.total_tokens,
+ }
+ : undefined,
+ }
+ }
+}
+
+/**
+ * Creates an OpenAI image adapter with explicit API key
+ *
+ * @param apiKey - Your OpenAI API key
+ * @param config - Optional additional configuration
+ * @returns Configured OpenAI image adapter instance
+ *
+ * @example
+ * ```typescript
+ * const adapter = createOpenaiImage("sk-...");
+ *
+ * const result = await ai({
+ * adapter,
+ * model: 'gpt-image-1',
+ * prompt: 'A cute baby sea otter'
+ * });
+ * ```
+ */
+export function createOpenaiImage(
+ apiKey: string,
+ config?: Omit,
+): OpenAIImageAdapter {
+ return new OpenAIImageAdapter({ apiKey, ...config })
+}
+
+/**
+ * Creates an OpenAI image adapter with automatic API key detection from environment variables.
+ *
+ * Looks for `OPENAI_API_KEY` in:
+ * - `process.env` (Node.js)
+ * - `window.env` (Browser with injected env)
+ *
+ * @param config - Optional configuration (excluding apiKey which is auto-detected)
+ * @returns Configured OpenAI image adapter instance
+ * @throws Error if OPENAI_API_KEY is not found in environment
+ *
+ * @example
+ * ```typescript
+ * // Automatically uses OPENAI_API_KEY from environment
+ * const adapter = openaiImage();
+ *
+ * const result = await ai({
+ * adapter,
+ * model: 'dall-e-3',
+ * prompt: 'A beautiful sunset over mountains'
+ * });
+ * ```
+ */
+export function openaiImage(
+ config?: Omit,
+): OpenAIImageAdapter {
+ const apiKey = getOpenAIApiKeyFromEnv()
+ return createOpenaiImage(apiKey, config)
+}
diff --git a/packages/typescript/ai-openai/src/adapters/summarize.ts b/packages/typescript/ai-openai/src/adapters/summarize.ts
new file mode 100644
index 00000000..34b4a9b0
--- /dev/null
+++ b/packages/typescript/ai-openai/src/adapters/summarize.ts
@@ -0,0 +1,145 @@
+import { BaseSummarizeAdapter } from '@tanstack/ai/adapters'
+import { OPENAI_CHAT_MODELS } from '../model-meta'
+import { createOpenAIClient, getOpenAIApiKeyFromEnv } from '../utils'
+import type { SummarizationOptions, SummarizationResult } from '@tanstack/ai'
+import type { OpenAIClientConfig } from '../utils'
+
+/**
+ * Configuration for OpenAI summarize adapter
+ */
+export interface OpenAISummarizeConfig extends OpenAIClientConfig {}
+
+/**
+ * OpenAI-specific provider options for summarization
+ */
+export interface OpenAISummarizeProviderOptions {
+ /** Temperature for response generation (0-2) */
+ temperature?: number
+ /** Maximum tokens in the response */
+ maxTokens?: number
+}
+
+/**
+ * OpenAI Summarize Adapter
+ *
+ * Tree-shakeable adapter for OpenAI summarization functionality.
+ * Import only what you need for smaller bundle sizes.
+ */
+export class OpenAISummarizeAdapter extends BaseSummarizeAdapter<
+ typeof OPENAI_CHAT_MODELS,
+ OpenAISummarizeProviderOptions
+> {
+ readonly kind = 'summarize' as const
+ readonly name = 'openai' as const
+ readonly models = OPENAI_CHAT_MODELS
+
+ private client: ReturnType
+
+ constructor(config: OpenAISummarizeConfig) {
+ super({})
+ this.client = createOpenAIClient(config)
+ }
+
+ async summarize(options: SummarizationOptions): Promise {
+ const systemPrompt = this.buildSummarizationPrompt(options)
+
+ const response = await this.client.chat.completions.create({
+ model: options.model || 'gpt-3.5-turbo',
+ messages: [
+ { role: 'system', content: systemPrompt },
+ { role: 'user', content: options.text },
+ ],
+ max_tokens: options.maxLength,
+ temperature: 0.3,
+ stream: false,
+ })
+
+ return {
+ id: response.id,
+ model: response.model,
+ summary: response.choices[0]?.message.content || '',
+ usage: {
+ promptTokens: response.usage?.prompt_tokens || 0,
+ completionTokens: response.usage?.completion_tokens || 0,
+ totalTokens: response.usage?.total_tokens || 0,
+ },
+ }
+ }
+
+ private buildSummarizationPrompt(options: SummarizationOptions): string {
+ let prompt = 'You are a professional summarizer. '
+
+ switch (options.style) {
+ case 'bullet-points':
+ prompt += 'Provide a summary in bullet point format. '
+ break
+ case 'paragraph':
+ prompt += 'Provide a summary in paragraph format. '
+ break
+ case 'concise':
+ prompt += 'Provide a very concise summary in 1-2 sentences. '
+ break
+ default:
+ prompt += 'Provide a clear and concise summary. '
+ }
+
+ if (options.focus && options.focus.length > 0) {
+ prompt += `Focus on the following aspects: ${options.focus.join(', ')}. `
+ }
+
+ if (options.maxLength) {
+ prompt += `Keep the summary under ${options.maxLength} tokens. `
+ }
+
+ return prompt
+ }
+}
+
+/**
+ * Creates an OpenAI summarize adapter with explicit API key
+ *
+ * @param apiKey - Your OpenAI API key
+ * @param config - Optional additional configuration
+ * @returns Configured OpenAI summarize adapter instance
+ *
+ * @example
+ * ```typescript
+ * const adapter = createOpenaiSummarize("sk-...");
+ * ```
+ */
+export function createOpenaiSummarize(
+ apiKey: string,
+ config?: Omit,
+): OpenAISummarizeAdapter {
+ return new OpenAISummarizeAdapter({ apiKey, ...config })
+}
+
+/**
+ * Creates an OpenAI summarize adapter with automatic API key detection from environment variables.
+ *
+ * Looks for `OPENAI_API_KEY` in:
+ * - `process.env` (Node.js)
+ * - `window.env` (Browser with injected env)
+ *
+ * @param config - Optional configuration (excluding apiKey which is auto-detected)
+ * @returns Configured OpenAI summarize adapter instance
+ * @throws Error if OPENAI_API_KEY is not found in environment
+ *
+ * @example
+ * ```typescript
+ * // Automatically uses OPENAI_API_KEY from environment
+ * const adapter = openaiSummarize();
+ *
+ * await generate({
+ * adapter,
+ * model: "gpt-4",
+ * text: "Long article text..."
+ * });
+ * ```
+ */
+export function openaiSummarize(
+ config?: Omit,
+): OpenAISummarizeAdapter {
+ const apiKey = getOpenAIApiKeyFromEnv()
+ return createOpenaiSummarize(apiKey, config)
+}
diff --git a/packages/typescript/ai-openai/src/adapters/text.ts b/packages/typescript/ai-openai/src/adapters/text.ts
new file mode 100644
index 00000000..d1eed227
--- /dev/null
+++ b/packages/typescript/ai-openai/src/adapters/text.ts
@@ -0,0 +1,773 @@
+import { BaseTextAdapter } from '@tanstack/ai/adapters'
+import { OPENAI_CHAT_MODELS } from '../model-meta'
+import { validateTextProviderOptions } from '../text/text-provider-options'
+import { convertToolsToProviderFormat } from '../tools'
+import {
+ convertZodToOpenAISchema,
+ createOpenAIClient,
+ generateId,
+ getOpenAIApiKeyFromEnv,
+ transformNullsToUndefined,
+} from '../utils'
+import type {
+ StructuredOutputOptions,
+ StructuredOutputResult,
+} from '@tanstack/ai/adapters'
+import type OpenAI_SDK from 'openai'
+import type { Responses } from 'openai/resources'
+import type {
+ ContentPart,
+ ModelMessage,
+ StreamChunk,
+ TextOptions,
+} from '@tanstack/ai'
+import type {
+ OpenAIChatModelProviderOptionsByName,
+ OpenAIModelInputModalitiesByName,
+} from '../model-meta'
+import type {
+ ExternalTextProviderOptions,
+ InternalTextProviderOptions,
+} from '../text/text-provider-options'
+import type {
+ OpenAIAudioMetadata,
+ OpenAIImageMetadata,
+ OpenAIMessageMetadataByModality,
+} from '../message-types'
+import type { OpenAIClientConfig } from '../utils'
+
+/**
+ * Configuration for OpenAI text adapter
+ */
+export interface OpenAITextConfig extends OpenAIClientConfig {}
+
+/**
+ * Alias for TextProviderOptions
+ */
+export type OpenAITextProviderOptions = ExternalTextProviderOptions
+
+/**
+ * OpenAI Text (Chat) Adapter
+ *
+ * Tree-shakeable adapter for OpenAI chat/text completion functionality.
+ * Import only what you need for smaller bundle sizes.
+ */
+export class OpenAITextAdapter extends BaseTextAdapter<
+ typeof OPENAI_CHAT_MODELS,
+ OpenAITextProviderOptions,
+ OpenAIChatModelProviderOptionsByName,
+ OpenAIModelInputModalitiesByName,
+ OpenAIMessageMetadataByModality
+> {
+ readonly kind = 'text' as const
+ readonly name = 'openai' as const
+ readonly models = OPENAI_CHAT_MODELS
+
+ // Type-only properties for type inference
+ declare _modelProviderOptionsByName: OpenAIChatModelProviderOptionsByName
+ declare _modelInputModalitiesByName: OpenAIModelInputModalitiesByName
+ declare _messageMetadataByModality: OpenAIMessageMetadataByModality
+
+ private client: OpenAI_SDK
+
+ constructor(config: OpenAITextConfig) {
+ super({})
+ this.client = createOpenAIClient(config)
+ }
+
+ async *chatStream(
+ options: TextOptions,
+ ): AsyncIterable {
+ // Track tool call metadata by unique ID
+ // OpenAI streams tool calls with deltas - first chunk has ID/name, subsequent chunks only have args
+ // We assign our own indices as we encounter unique tool call IDs
+ const toolCallMetadata = new Map()
+ const requestArguments = this.mapTextOptionsToOpenAI(options)
+
+ try {
+ const response = await this.client.responses.create(
+ {
+ ...requestArguments,
+ stream: true,
+ },
+ {
+ headers: options.request?.headers,
+ signal: options.request?.signal,
+ },
+ )
+
+ // Chat Completions API uses SSE format - iterate directly
+ yield* this.processOpenAIStreamChunks(
+ response,
+ toolCallMetadata,
+ options,
+ () => generateId(this.name),
+ )
+ } catch (error: unknown) {
+ const err = error as Error
+ console.error('>>> chatStream: Fatal error during response creation <<<')
+ console.error('>>> Error message:', err.message)
+ console.error('>>> Error stack:', err.stack)
+ console.error('>>> Full error:', err)
+ throw error
+ }
+ }
+
+ /**
+ * Generate structured output using OpenAI's native JSON Schema response format.
+ * Uses stream: false to get the complete response in one call.
+ *
+ * OpenAI has strict requirements for structured output:
+ * - All properties must be in the `required` array
+ * - Optional fields should have null added to their type union
+ * - additionalProperties must be false for all objects
+ *
+ * The schema conversion is handled by convertZodToOpenAISchema.
+ */
+ async structuredOutput(
+ options: StructuredOutputOptions,
+ ): Promise> {
+ const { chatOptions, outputSchema } = options
+ const requestArguments = this.mapTextOptionsToOpenAI(chatOptions)
+
+ // Convert Zod schema to OpenAI-compatible JSON Schema
+ const jsonSchema = convertZodToOpenAISchema(outputSchema)
+
+ try {
+ const response = await this.client.responses.create(
+ {
+ ...requestArguments,
+ stream: false,
+ // Configure structured output via text.format
+ text: {
+ format: {
+ type: 'json_schema',
+ name: 'structured_output',
+ schema: jsonSchema,
+ strict: true,
+ },
+ },
+ },
+ {
+ headers: chatOptions.request?.headers,
+ signal: chatOptions.request?.signal,
+ },
+ )
+
+ // Extract text content from the response
+ const rawText = this.extractTextFromResponse(response)
+
+ // Parse the JSON response
+ let parsed: unknown
+ try {
+ parsed = JSON.parse(rawText)
+ } catch {
+ throw new Error(
+ `Failed to parse structured output as JSON. Content: ${rawText.slice(0, 200)}${rawText.length > 200 ? '...' : ''}`,
+ )
+ }
+
+ // Transform null values to undefined to match original Zod schema expectations
+ // OpenAI returns null for optional fields we made nullable in the schema
+ const transformed = transformNullsToUndefined(parsed)
+
+ return {
+ data: transformed,
+ rawText,
+ }
+ } catch (error: unknown) {
+ const err = error as Error
+ console.error('>>> structuredOutput: Error during response creation <<<')
+ console.error('>>> Error message:', err.message)
+ throw error
+ }
+ }
+
+ /**
+ * Extract text content from a non-streaming response
+ */
+ private extractTextFromResponse(
+ response: OpenAI_SDK.Responses.Response,
+ ): string {
+ let textContent = ''
+
+ for (const item of response.output) {
+ if (item.type === 'message') {
+ for (const part of item.content) {
+ if (part.type === 'output_text') {
+ textContent += part.text
+ }
+ }
+ }
+ }
+
+ return textContent
+ }
+
+ private async *processOpenAIStreamChunks(
+ stream: AsyncIterable,
+ toolCallMetadata: Map,
+ options: TextOptions,
+ genId: () => string,
+ ): AsyncIterable {
+ let accumulatedContent = ''
+ let accumulatedReasoning = ''
+ const timestamp = Date.now()
+ let chunkCount = 0
+
+ // Track if we've been streaming deltas to avoid duplicating content from done events
+ let hasStreamedContentDeltas = false
+ let hasStreamedReasoningDeltas = false
+
+ // Preserve response metadata across events
+ let responseId: string | null = null
+ let model: string = options.model
+
+ const eventTypeCounts = new Map()
+
+ try {
+ for await (const chunk of stream) {
+ chunkCount++
+ const handleContentPart = (
+ contentPart:
+ | OpenAI_SDK.Responses.ResponseOutputText
+ | OpenAI_SDK.Responses.ResponseOutputRefusal
+ | OpenAI_SDK.Responses.ResponseContentPartAddedEvent.ReasoningText,
+ ): StreamChunk => {
+ if (contentPart.type === 'output_text') {
+ accumulatedContent += contentPart.text
+ return {
+ type: 'content',
+ id: responseId || genId(),
+ model: model || options.model,
+ timestamp,
+ delta: contentPart.text,
+ content: accumulatedContent,
+ role: 'assistant',
+ }
+ }
+
+ if (contentPart.type === 'reasoning_text') {
+ accumulatedReasoning += contentPart.text
+ return {
+ type: 'thinking',
+ id: responseId || genId(),
+ model: model || options.model,
+ timestamp,
+ delta: contentPart.text,
+ content: accumulatedReasoning,
+ }
+ }
+ return {
+ type: 'error',
+ id: responseId || genId(),
+ model: model || options.model,
+ timestamp,
+ error: {
+ message: contentPart.refusal,
+ },
+ }
+ }
+ // handle general response events
+ if (
+ chunk.type === 'response.created' ||
+ chunk.type === 'response.incomplete' ||
+ chunk.type === 'response.failed'
+ ) {
+ responseId = chunk.response.id
+ model = chunk.response.model
+ // Reset streaming flags for new response
+ hasStreamedContentDeltas = false
+ hasStreamedReasoningDeltas = false
+ accumulatedContent = ''
+ accumulatedReasoning = ''
+ if (chunk.response.error) {
+ yield {
+ type: 'error',
+ id: chunk.response.id,
+ model: chunk.response.model,
+ timestamp,
+ error: chunk.response.error,
+ }
+ }
+ if (chunk.response.incomplete_details) {
+ yield {
+ type: 'error',
+ id: chunk.response.id,
+ model: chunk.response.model,
+ timestamp,
+ error: {
+ message: chunk.response.incomplete_details.reason ?? '',
+ },
+ }
+ }
+ }
+ // Handle output text deltas (token-by-token streaming)
+ // response.output_text.delta provides incremental text updates
+ if (chunk.type === 'response.output_text.delta' && chunk.delta) {
+ // Delta can be an array of strings or a single string
+ const textDelta = Array.isArray(chunk.delta)
+ ? chunk.delta.join('')
+ : typeof chunk.delta === 'string'
+ ? chunk.delta
+ : ''
+
+ if (textDelta) {
+ accumulatedContent += textDelta
+ hasStreamedContentDeltas = true
+ yield {
+ type: 'content',
+ id: responseId || genId(),
+ model: model || options.model,
+ timestamp,
+ delta: textDelta,
+ content: accumulatedContent,
+ role: 'assistant',
+ }
+ }
+ }
+
+ // Handle reasoning deltas (token-by-token thinking/reasoning streaming)
+ // response.reasoning_text.delta provides incremental reasoning updates
+ if (chunk.type === 'response.reasoning_text.delta' && chunk.delta) {
+ // Delta can be an array of strings or a single string
+ const reasoningDelta = Array.isArray(chunk.delta)
+ ? chunk.delta.join('')
+ : typeof chunk.delta === 'string'
+ ? chunk.delta
+ : ''
+
+ if (reasoningDelta) {
+ accumulatedReasoning += reasoningDelta
+ hasStreamedReasoningDeltas = true
+ yield {
+ type: 'thinking',
+ id: responseId || genId(),
+ model: model || options.model,
+ timestamp,
+ delta: reasoningDelta,
+ content: accumulatedReasoning,
+ }
+ }
+ }
+
+ // Handle reasoning summary deltas (when using reasoning.summary option)
+ // response.reasoning_summary_text.delta provides incremental summary updates
+ if (
+ chunk.type === 'response.reasoning_summary_text.delta' &&
+ chunk.delta
+ ) {
+ const summaryDelta =
+ typeof chunk.delta === 'string' ? chunk.delta : ''
+
+ if (summaryDelta) {
+ accumulatedReasoning += summaryDelta
+ hasStreamedReasoningDeltas = true
+ yield {
+ type: 'thinking',
+ id: responseId || genId(),
+ model: model || options.model,
+ timestamp,
+ delta: summaryDelta,
+ content: accumulatedReasoning,
+ }
+ }
+ }
+
+ // handle content_part added events for text, reasoning and refusals
+ if (chunk.type === 'response.content_part.added') {
+ const contentPart = chunk.part
+ yield handleContentPart(contentPart)
+ }
+
+ if (chunk.type === 'response.content_part.done') {
+ const contentPart = chunk.part
+
+ // Skip emitting chunks for content parts that we've already streamed via deltas
+ // The done event is just a completion marker, not new content
+ if (contentPart.type === 'output_text' && hasStreamedContentDeltas) {
+ // Content already accumulated from deltas, skip
+ continue
+ }
+ if (
+ contentPart.type === 'reasoning_text' &&
+ hasStreamedReasoningDeltas
+ ) {
+ // Reasoning already accumulated from deltas, skip
+ continue
+ }
+
+ // Only emit if we haven't been streaming deltas (e.g., for non-streaming responses)
+ yield handleContentPart(contentPart)
+ }
+
+ // handle output_item.added to capture function call metadata (name)
+ if (chunk.type === 'response.output_item.added') {
+ const item = chunk.item
+ if (item.type === 'function_call' && item.id) {
+ // Store the function name for later use
+ if (!toolCallMetadata.has(item.id)) {
+ toolCallMetadata.set(item.id, {
+ index: chunk.output_index,
+ name: item.name || '',
+ })
+ }
+ }
+ }
+
+ if (chunk.type === 'response.function_call_arguments.done') {
+ const { item_id, output_index } = chunk
+
+ // Get the function name from metadata (captured in output_item.added)
+ const metadata = toolCallMetadata.get(item_id)
+ const name = metadata?.name || ''
+
+ yield {
+ type: 'tool_call',
+ id: responseId || genId(),
+ model: model || options.model,
+ timestamp,
+ index: output_index,
+ toolCall: {
+ id: item_id,
+ type: 'function',
+ function: {
+ name,
+ arguments: chunk.arguments,
+ },
+ },
+ }
+ }
+
+ if (chunk.type === 'response.completed') {
+ // Determine finish reason based on output
+ // If there are function_call items in the output, it's a tool_calls finish
+ const hasFunctionCalls = chunk.response.output.some(
+ (item: unknown) =>
+ (item as { type: string }).type === 'function_call',
+ )
+
+ yield {
+ type: 'done',
+ id: responseId || genId(),
+ model: model || options.model,
+ timestamp,
+ usage: {
+ promptTokens: chunk.response.usage?.input_tokens || 0,
+ completionTokens: chunk.response.usage?.output_tokens || 0,
+ totalTokens: chunk.response.usage?.total_tokens || 0,
+ },
+ finishReason: hasFunctionCalls ? 'tool_calls' : 'stop',
+ }
+ }
+
+ if (chunk.type === 'error') {
+ yield {
+ type: 'error',
+ id: responseId || genId(),
+ model: model || options.model,
+ timestamp,
+ error: {
+ message: chunk.message,
+ code: chunk.code ?? undefined,
+ },
+ }
+ }
+ }
+ } catch (error: unknown) {
+ const err = error as Error & { code?: string }
+ console.log(
+ '[OpenAI Adapter] Stream ended with error. Event type summary:',
+ {
+ totalChunks: chunkCount,
+ eventTypes: Object.fromEntries(eventTypeCounts),
+ error: err.message,
+ },
+ )
+ yield {
+ type: 'error',
+ id: genId(),
+ model: options.model,
+ timestamp,
+ error: {
+ message: err.message || 'Unknown error occurred',
+ code: err.code,
+ },
+ }
+ }
+ }
+
+ /**
+ * Maps common options to OpenAI-specific format
+ * Handles translation of normalized options to OpenAI's API format
+ */
+ private mapTextOptionsToOpenAI(options: TextOptions) {
+ const providerOptions = options.providerOptions as
+ | Omit<
+ InternalTextProviderOptions,
+ | 'max_output_tokens'
+ | 'tools'
+ | 'metadata'
+ | 'temperature'
+ | 'input'
+ | 'top_p'
+ >
+ | undefined
+ const input = this.convertMessagesToInput(options.messages)
+ if (providerOptions) {
+ validateTextProviderOptions({
+ ...providerOptions,
+ input,
+ model: options.model,
+ })
+ }
+
+ const tools = options.tools
+ ? convertToolsToProviderFormat(options.tools)
+ : undefined
+
+ const requestParams: Omit<
+ OpenAI_SDK.Responses.ResponseCreateParams,
+ 'stream'
+ > = {
+ model: options.model,
+ temperature: options.options?.temperature,
+ max_output_tokens: options.options?.maxTokens,
+ top_p: options.options?.topP,
+ metadata: options.options?.metadata,
+ instructions: options.systemPrompts?.join('\n'),
+ ...providerOptions,
+ input,
+ tools,
+ }
+
+ return requestParams
+ }
+
+ private convertMessagesToInput(
+ messages: Array,
+ ): Responses.ResponseInput {
+ const result: Responses.ResponseInput = []
+
+ for (const message of messages) {
+ // Handle tool messages - convert to FunctionToolCallOutput
+ if (message.role === 'tool') {
+ result.push({
+ type: 'function_call_output',
+ call_id: message.toolCallId || '',
+ output:
+ typeof message.content === 'string'
+ ? message.content
+ : JSON.stringify(message.content),
+ })
+ continue
+ }
+
+ // Handle assistant messages
+ if (message.role === 'assistant') {
+ // If the assistant message has tool calls, add them as FunctionToolCall objects
+ // OpenAI Responses API expects arguments as a string (JSON string)
+ if (message.toolCalls && message.toolCalls.length > 0) {
+ for (const toolCall of message.toolCalls) {
+ // Keep arguments as string for Responses API
+ // Our internal format stores arguments as a JSON string, which is what API expects
+ const argumentsString =
+ typeof toolCall.function.arguments === 'string'
+ ? toolCall.function.arguments
+ : JSON.stringify(toolCall.function.arguments)
+
+ result.push({
+ type: 'function_call',
+ call_id: toolCall.id,
+ name: toolCall.function.name,
+ arguments: argumentsString,
+ })
+ }
+ }
+
+ // Add the assistant's text message if there is content
+ if (message.content) {
+ // Assistant messages are typically text-only
+ const contentStr = this.extractTextContent(message.content)
+ if (contentStr) {
+ result.push({
+ type: 'message',
+ role: 'assistant',
+ content: contentStr,
+ })
+ }
+ }
+
+ continue
+ }
+
+ // Handle user messages (default case) - support multimodal content
+ const contentParts = this.normalizeContent(message.content)
+ const openAIContent: Array = []
+
+ for (const part of contentParts) {
+ openAIContent.push(
+ this.convertContentPartToOpenAI(
+ part as ContentPart<
+ OpenAIImageMetadata,
+ OpenAIAudioMetadata,
+ unknown,
+ unknown
+ >,
+ ),
+ )
+ }
+
+ // If no content parts, add empty text
+ if (openAIContent.length === 0) {
+ openAIContent.push({ type: 'input_text', text: '' })
+ }
+
+ result.push({
+ type: 'message',
+ role: 'user',
+ content: openAIContent,
+ })
+ }
+
+ return result
+ }
+
+ /**
+ * Converts a ContentPart to OpenAI input content item.
+ * Handles text, image, and audio content parts.
+ */
+ private convertContentPartToOpenAI(
+ part: ContentPart<
+ OpenAIImageMetadata,
+ OpenAIAudioMetadata,
+ unknown,
+ unknown
+ >,
+ ): Responses.ResponseInputContent {
+ switch (part.type) {
+ case 'text':
+ return {
+ type: 'input_text',
+ text: part.content,
+ }
+ case 'image': {
+ const imageMetadata = part.metadata
+ if (part.source.type === 'url') {
+ return {
+ type: 'input_image',
+ image_url: part.source.value,
+ detail: imageMetadata?.detail || 'auto',
+ }
+ }
+ // For base64 data, construct a data URI
+ return {
+ type: 'input_image',
+ image_url: part.source.value,
+ detail: imageMetadata?.detail || 'auto',
+ }
+ }
+ case 'audio': {
+ if (part.source.type === 'url') {
+ // OpenAI may support audio URLs in the future
+ // For now, treat as data URI
+ return {
+ type: 'input_file',
+ file_url: part.source.value,
+ }
+ }
+ return {
+ type: 'input_file',
+ file_data: part.source.value,
+ }
+ }
+
+ default:
+ throw new Error(`Unsupported content part type: ${part.type}`)
+ }
+ }
+
+ /**
+ * Normalizes message content to an array of ContentPart.
+ * Handles backward compatibility with string content.
+ */
+ private normalizeContent(
+ content: string | null | Array,
+ ): Array {
+ if (content === null) {
+ return []
+ }
+ if (typeof content === 'string') {
+ return [{ type: 'text', content: content }]
+ }
+ return content
+ }
+
+ /**
+ * Extracts text content from a content value that may be string, null, or ContentPart array.
+ */
+ private extractTextContent(
+ content: string | null | Array,
+ ): string {
+ if (content === null) {
+ return ''
+ }
+ if (typeof content === 'string') {
+ return content
+ }
+ // It's an array of ContentPart
+ return content
+ .filter((p) => p.type === 'text')
+ .map((p) => p.content)
+ .join('')
+ }
+}
+
+/**
+ * Creates an OpenAI text adapter with explicit API key
+ *
+ * @param apiKey - Your OpenAI API key
+ * @param config - Optional additional configuration
+ * @returns Configured OpenAI text adapter instance
+ *
+ * @example
+ * ```typescript
+ * const adapter = createOpenaiText("sk-...");
+ * ```
+ */
+export function createOpenaiText(
+ apiKey: string,
+ config?: Omit,
+): OpenAITextAdapter {
+ return new OpenAITextAdapter({ apiKey, ...config })
+}
+
+/**
+ * Creates an OpenAI text adapter with automatic API key detection from environment variables.
+ *
+ * Looks for `OPENAI_API_KEY` in:
+ * - `process.env` (Node.js)
+ * - `window.env` (Browser with injected env)
+ *
+ * @param config - Optional configuration (excluding apiKey which is auto-detected)
+ * @returns Configured OpenAI text adapter instance
+ * @throws Error if OPENAI_API_KEY is not found in environment
+ *
+ * @example
+ * ```typescript
+ * // Automatically uses OPENAI_API_KEY from environment
+ * const adapter = openaiText();
+ *
+ * await generate({
+ * adapter,
+ * model: "gpt-4",
+ * messages: [{ role: "user", content: "Hello!" }]
+ * });
+ * ```
+ */
+export function openaiText(
+ config?: Omit,
+): OpenAITextAdapter {
+ const apiKey = getOpenAIApiKeyFromEnv()
+ return createOpenaiText(apiKey, config)
+}
diff --git a/packages/typescript/ai-openai/src/adapters/transcription.ts b/packages/typescript/ai-openai/src/adapters/transcription.ts
new file mode 100644
index 00000000..7bb754e6
--- /dev/null
+++ b/packages/typescript/ai-openai/src/adapters/transcription.ts
@@ -0,0 +1,239 @@
+import { BaseTranscriptionAdapter } from '@tanstack/ai/adapters'
+import { OPENAI_TRANSCRIPTION_MODELS } from '../model-meta'
+import {
+ createOpenAIClient,
+ generateId,
+ getOpenAIApiKeyFromEnv,
+} from '../utils'
+import type { OpenAITranscriptionProviderOptions } from '../audio/transcription-provider-options'
+import type {
+ TranscriptionOptions,
+ TranscriptionResult,
+ TranscriptionSegment,
+} from '@tanstack/ai'
+import type OpenAI_SDK from 'openai'
+import type { OpenAIClientConfig } from '../utils'
+
+/**
+ * Configuration for OpenAI Transcription adapter
+ */
+export interface OpenAITranscriptionConfig extends OpenAIClientConfig {}
+
+/**
+ * OpenAI Transcription (Speech-to-Text) Adapter
+ *
+ * Tree-shakeable adapter for OpenAI audio transcription functionality.
+ * Supports whisper-1, gpt-4o-transcribe, gpt-4o-mini-transcribe, and gpt-4o-transcribe-diarize models.
+ *
+ * Features:
+ * - Multiple transcription models with different capabilities
+ * - Language detection or specification
+ * - Multiple output formats: json, text, srt, verbose_json, vtt
+ * - Word and segment-level timestamps (with verbose_json)
+ * - Speaker diarization (with gpt-4o-transcribe-diarize)
+ */
+export class OpenAITranscriptionAdapter extends BaseTranscriptionAdapter<
+ typeof OPENAI_TRANSCRIPTION_MODELS,
+ OpenAITranscriptionProviderOptions
+> {
+ readonly name = 'openai' as const
+ readonly models = OPENAI_TRANSCRIPTION_MODELS
+
+ private client: OpenAI_SDK
+
+ constructor(config: OpenAITranscriptionConfig) {
+ super(config)
+ this.client = createOpenAIClient(config)
+ }
+
+ async transcribe(
+ options: TranscriptionOptions,
+ ): Promise {
+ const { model, audio, language, prompt, responseFormat, providerOptions } =
+ options
+
+ // Convert audio input to File object
+ const file = this.prepareAudioFile(audio)
+
+ // Build request
+ const request: OpenAI_SDK.Audio.TranscriptionCreateParams = {
+ model,
+ file,
+ language,
+ prompt,
+ response_format: this.mapResponseFormat(responseFormat),
+ ...providerOptions,
+ }
+
+ // Call OpenAI API - use verbose_json to get timestamps when available
+ const useVerbose =
+ responseFormat === 'verbose_json' ||
+ (!responseFormat && model !== 'whisper-1')
+
+ if (useVerbose) {
+ request.response_format = 'verbose_json'
+ const response = (await this.client.audio.transcriptions.create(
+ request,
+ )) as OpenAI_SDK.Audio.Transcription & {
+ segments?: Array<{
+ id: number
+ start: number
+ end: number
+ text: string
+ avg_logprob?: number
+ }>
+ words?: Array<{
+ word: string
+ start: number
+ end: number
+ }>
+ duration?: number
+ language?: string
+ }
+
+ return {
+ id: generateId(this.name),
+ model,
+ text: response.text,
+ language: response.language,
+ duration: response.duration,
+ segments: response.segments?.map(
+ (seg): TranscriptionSegment => ({
+ id: seg.id,
+ start: seg.start,
+ end: seg.end,
+ text: seg.text,
+ confidence: seg.avg_logprob ? Math.exp(seg.avg_logprob) : undefined,
+ }),
+ ),
+ words: response.words?.map((w) => ({
+ word: w.word,
+ start: w.start,
+ end: w.end,
+ })),
+ }
+ } else {
+ const response = await this.client.audio.transcriptions.create(request)
+
+ return {
+ id: generateId(this.name),
+ model,
+ text: typeof response === 'string' ? response : response.text,
+ language,
+ }
+ }
+ }
+
+ private prepareAudioFile(audio: string | File | Blob | ArrayBuffer): File {
+ // If already a File, return it
+ if (audio instanceof File) {
+ return audio
+ }
+
+ // If Blob, convert to File
+ if (audio instanceof Blob) {
+ return new File([audio], 'audio.mp3', {
+ type: audio.type || 'audio/mpeg',
+ })
+ }
+
+ // If ArrayBuffer, convert to File
+ if (audio instanceof ArrayBuffer) {
+ return new File([audio], 'audio.mp3', { type: 'audio/mpeg' })
+ }
+
+ // If base64 string, decode and convert to File
+ if (typeof audio === 'string') {
+ // Check if it's a data URL
+ if (audio.startsWith('data:')) {
+ const parts = audio.split(',')
+ const header = parts[0]
+ const base64Data = parts[1] || ''
+ const mimeMatch = header?.match(/data:([^;]+)/)
+ const mimeType = mimeMatch?.[1] || 'audio/mpeg'
+ const binaryStr = atob(base64Data)
+ const bytes = new Uint8Array(binaryStr.length)
+ for (let i = 0; i < binaryStr.length; i++) {
+ bytes[i] = binaryStr.charCodeAt(i)
+ }
+ const extension = mimeType.split('/')[1] || 'mp3'
+ return new File([bytes], `audio.${extension}`, { type: mimeType })
+ }
+
+ // Assume raw base64
+ const binaryStr = atob(audio)
+ const bytes = new Uint8Array(binaryStr.length)
+ for (let i = 0; i < binaryStr.length; i++) {
+ bytes[i] = binaryStr.charCodeAt(i)
+ }
+ return new File([bytes], 'audio.mp3', { type: 'audio/mpeg' })
+ }
+
+ throw new Error('Invalid audio input type')
+ }
+
+ private mapResponseFormat(
+ format?: 'json' | 'text' | 'srt' | 'verbose_json' | 'vtt',
+ ): OpenAI_SDK.Audio.TranscriptionCreateParams['response_format'] {
+ if (!format) return 'json'
+ return format as OpenAI_SDK.Audio.TranscriptionCreateParams['response_format']
+ }
+}
+
+/**
+ * Creates an OpenAI Transcription adapter with explicit API key
+ *
+ * @param apiKey - Your OpenAI API key
+ * @param config - Optional additional configuration
+ * @returns Configured OpenAI Transcription adapter instance
+ *
+ * @example
+ * ```typescript
+ * const adapter = createOpenaiTranscription("sk-...");
+ *
+ * const result = await ai({
+ * adapter,
+ * model: 'whisper-1',
+ * audio: audioFile,
+ * language: 'en'
+ * });
+ * ```
+ */
+export function createOpenaiTranscription(
+ apiKey: string,
+ config?: Omit,
+): OpenAITranscriptionAdapter {
+ return new OpenAITranscriptionAdapter({ apiKey, ...config })
+}
+
+/**
+ * Creates an OpenAI Transcription adapter with automatic API key detection from environment variables.
+ *
+ * Looks for `OPENAI_API_KEY` in:
+ * - `process.env` (Node.js)
+ * - `window.env` (Browser with injected env)
+ *
+ * @param config - Optional configuration (excluding apiKey which is auto-detected)
+ * @returns Configured OpenAI Transcription adapter instance
+ * @throws Error if OPENAI_API_KEY is not found in environment
+ *
+ * @example
+ * ```typescript
+ * // Automatically uses OPENAI_API_KEY from environment
+ * const adapter = openaiTranscription();
+ *
+ * const result = await ai({
+ * adapter,
+ * model: 'whisper-1',
+ * audio: audioFile
+ * });
+ *
+ * console.log(result.text)
+ * ```
+ */
+export function openaiTranscription(
+ config?: Omit,
+): OpenAITranscriptionAdapter {
+ const apiKey = getOpenAIApiKeyFromEnv()
+ return createOpenaiTranscription(apiKey, config)
+}
diff --git a/packages/typescript/ai-openai/src/adapters/tts.ts b/packages/typescript/ai-openai/src/adapters/tts.ts
new file mode 100644
index 00000000..1e2a0df4
--- /dev/null
+++ b/packages/typescript/ai-openai/src/adapters/tts.ts
@@ -0,0 +1,169 @@
+import { BaseTTSAdapter } from '@tanstack/ai/adapters'
+import { OPENAI_TTS_MODELS } from '../model-meta'
+import {
+ createOpenAIClient,
+ generateId,
+ getOpenAIApiKeyFromEnv,
+} from '../utils'
+import {
+ validateAudioInput,
+ validateInstructions,
+ validateSpeed,
+} from '../audio/audio-provider-options'
+import type {
+ OpenAITTSFormat,
+ OpenAITTSProviderOptions,
+ OpenAITTSVoice,
+} from '../audio/tts-provider-options'
+import type { TTSOptions, TTSResult } from '@tanstack/ai'
+import type OpenAI_SDK from 'openai'
+import type { OpenAIClientConfig } from '../utils'
+
+/**
+ * Configuration for OpenAI TTS adapter
+ */
+export interface OpenAITTSConfig extends OpenAIClientConfig {}
+
+/**
+ * OpenAI Text-to-Speech Adapter
+ *
+ * Tree-shakeable adapter for OpenAI TTS functionality.
+ * Supports tts-1, tts-1-hd, and gpt-4o-audio-preview models.
+ *
+ * Features:
+ * - Multiple voice options: alloy, ash, ballad, coral, echo, fable, onyx, nova, sage, shimmer, verse
+ * - Multiple output formats: mp3, opus, aac, flac, wav, pcm
+ * - Speed control (0.25 to 4.0)
+ */
+export class OpenAITTSAdapter extends BaseTTSAdapter<
+ typeof OPENAI_TTS_MODELS,
+ OpenAITTSProviderOptions
+> {
+ readonly name = 'openai' as const
+ readonly models = OPENAI_TTS_MODELS
+
+ private client: OpenAI_SDK
+
+ constructor(config: OpenAITTSConfig) {
+ super(config)
+ this.client = createOpenAIClient(config)
+ }
+
+ async generateSpeech(
+ options: TTSOptions,
+ ): Promise {
+ const { model, text, voice, format, speed, providerOptions } = options
+
+ // Validate inputs using existing validators
+ const audioOptions = {
+ input: text,
+ model,
+ voice: voice as OpenAITTSVoice,
+ speed,
+ response_format: format as OpenAITTSFormat,
+ ...providerOptions,
+ }
+
+ validateAudioInput(audioOptions)
+ validateSpeed(audioOptions)
+ validateInstructions(audioOptions)
+
+ // Build request
+ const request: OpenAI_SDK.Audio.SpeechCreateParams = {
+ model,
+ input: text,
+ voice: voice || 'alloy',
+ response_format: format,
+ speed,
+ ...providerOptions,
+ }
+
+ // Call OpenAI API
+ const response = await this.client.audio.speech.create(request)
+
+ // Convert response to base64
+ const arrayBuffer = await response.arrayBuffer()
+ const base64 = Buffer.from(arrayBuffer).toString('base64')
+
+ const outputFormat = format || 'mp3'
+ const contentType = this.getContentType(outputFormat)
+
+ return {
+ id: generateId(this.name),
+ model,
+ audio: base64,
+ format: outputFormat,
+ contentType,
+ }
+ }
+
+ private getContentType(format: string): string {
+ const contentTypes: Record = {
+ mp3: 'audio/mpeg',
+ opus: 'audio/opus',
+ aac: 'audio/aac',
+ flac: 'audio/flac',
+ wav: 'audio/wav',
+ pcm: 'audio/pcm',
+ }
+ return contentTypes[format] || 'audio/mpeg'
+ }
+}
+
+/**
+ * Creates an OpenAI TTS adapter with explicit API key
+ *
+ * @param apiKey - Your OpenAI API key
+ * @param config - Optional additional configuration
+ * @returns Configured OpenAI TTS adapter instance
+ *
+ * @example
+ * ```typescript
+ * const adapter = createOpenaiTTS("sk-...");
+ *
+ * const result = await ai({
+ * adapter,
+ * model: 'tts-1-hd',
+ * text: 'Hello, world!',
+ * voice: 'nova'
+ * });
+ * ```
+ */
+export function createOpenaiTTS(
+ apiKey: string,
+ config?: Omit,
+): OpenAITTSAdapter {
+ return new OpenAITTSAdapter({ apiKey, ...config })
+}
+
+/**
+ * Creates an OpenAI TTS adapter with automatic API key detection from environment variables.
+ *
+ * Looks for `OPENAI_API_KEY` in:
+ * - `process.env` (Node.js)
+ * - `window.env` (Browser with injected env)
+ *
+ * @param config - Optional configuration (excluding apiKey which is auto-detected)
+ * @returns Configured OpenAI TTS adapter instance
+ * @throws Error if OPENAI_API_KEY is not found in environment
+ *
+ * @example
+ * ```typescript
+ * // Automatically uses OPENAI_API_KEY from environment
+ * const adapter = openaiTTS();
+ *
+ * const result = await ai({
+ * adapter,
+ * model: 'tts-1',
+ * text: 'Welcome to TanStack AI!',
+ * voice: 'alloy',
+ * format: 'mp3'
+ * });
+ * ```
+ */
+export function openaiTTS(
+ config?: Omit,
+): OpenAITTSAdapter {
+ const apiKey = getOpenAIApiKeyFromEnv()
+ return createOpenaiTTS(apiKey, config)
+}
diff --git a/packages/typescript/ai-openai/src/adapters/video.ts b/packages/typescript/ai-openai/src/adapters/video.ts
new file mode 100644
index 00000000..661e96d0
--- /dev/null
+++ b/packages/typescript/ai-openai/src/adapters/video.ts
@@ -0,0 +1,400 @@
+import { BaseVideoAdapter } from '@tanstack/ai/adapters'
+import { OPENAI_VIDEO_MODELS } from '../model-meta'
+import { createOpenAIClient, getOpenAIApiKeyFromEnv } from '../utils'
+import {
+ toApiSeconds,
+ validateVideoSeconds,
+ validateVideoSize,
+} from '../video/video-provider-options'
+import type {
+ OpenAIVideoModelProviderOptionsByName,
+ OpenAIVideoProviderOptions,
+} from '../video/video-provider-options'
+import type {
+ VideoGenerationOptions,
+ VideoJobResult,
+ VideoStatusResult,
+ VideoUrlResult,
+} from '@tanstack/ai'
+import type OpenAI_SDK from 'openai'
+import type { OpenAIClientConfig } from '../utils'
+
+/**
+ * Configuration for OpenAI video adapter.
+ *
+ * @experimental Video generation is an experimental feature and may change.
+ */
+export interface OpenAIVideoConfig extends OpenAIClientConfig {}
+
+/**
+ * OpenAI Video Generation Adapter
+ *
+ * Tree-shakeable adapter for OpenAI video generation functionality using Sora-2.
+ * Uses a jobs/polling architecture for async video generation.
+ *
+ * @experimental Video generation is an experimental feature and may change.
+ *
+ * Features:
+ * - Async job-based video generation
+ * - Status polling for job progress
+ * - URL retrieval for completed videos
+ * - Model-specific type-safe provider options
+ */
+export class OpenAIVideoAdapter extends BaseVideoAdapter<
+ typeof OPENAI_VIDEO_MODELS,
+ OpenAIVideoProviderOptions
+> {
+ readonly name = 'openai' as const
+ readonly models = OPENAI_VIDEO_MODELS
+
+ // Type-only properties for type inference
+ declare _modelProviderOptionsByName?: OpenAIVideoModelProviderOptionsByName
+
+ private client: OpenAI_SDK
+
+ constructor(config: OpenAIVideoConfig) {
+ super(config)
+ this.client = createOpenAIClient(config)
+ }
+
+ /**
+ * Create a new video generation job.
+ *
+ * API: POST /v1/videos
+ * Docs: https://platform.openai.com/docs/api-reference/videos/create
+ *
+ * @experimental Video generation is an experimental feature and may change.
+ *
+ * @example
+ * ```ts
+ * const { jobId } = await adapter.createVideoJob({
+ * model: 'sora-2',
+ * prompt: 'A cat chasing a dog in a sunny park',
+ * size: '1280x720',
+ * duration: 8 // seconds: 4, 8, or 12
+ * })
+ * ```
+ */
+ async createVideoJob(
+ options: VideoGenerationOptions,
+ ): Promise {
+ const { model, size, duration, providerOptions } = options
+
+ // Validate inputs
+ validateVideoSize(model, size)
+ // Duration maps to 'seconds' in the API
+ const seconds = duration ?? providerOptions?.seconds
+ validateVideoSeconds(model, seconds)
+
+ // Build request
+ const request = this.buildRequest(options)
+
+ try {
+ // POST /v1/videos
+ // Cast to any because the videos API may not be in SDK types yet
+ const client = this.client as any
+ const response = await client.videos.create(request)
+
+ return {
+ jobId: response.id,
+ model,
+ }
+ } catch (error: any) {
+ // Fallback for when the videos API is not available
+ if (error.message?.includes('videos') || error.code === 'invalid_api') {
+ throw new Error(
+ `Video generation API is not available. The Sora API may require special access. ` +
+ `Original error: ${error.message}`,
+ )
+ }
+ throw error
+ }
+ }
+
+ /**
+ * Get the current status of a video generation job.
+ *
+ * API: GET /v1/videos/{video_id}
+ * Docs: https://platform.openai.com/docs/api-reference/videos/get
+ *
+ * @experimental Video generation is an experimental feature and may change.
+ *
+ * @example
+ * ```ts
+ * const status = await adapter.getVideoStatus(jobId)
+ * if (status.status === 'completed') {
+ * console.log('Video is ready!')
+ * } else if (status.status === 'processing') {
+ * console.log(`Progress: ${status.progress}%`)
+ * }
+ * ```
+ */
+ async getVideoStatus(jobId: string): Promise {
+ try {
+ // GET /v1/videos/{video_id}
+ const client = this.client as any
+ const response = await client.videos.retrieve(jobId)
+
+ return {
+ jobId,
+ status: this.mapStatus(response.status),
+ progress: response.progress,
+ error: response.error?.message,
+ }
+ } catch (error: any) {
+ if (error.status === 404) {
+ return {
+ jobId,
+ status: 'failed',
+ error: 'Job not found',
+ }
+ }
+ throw error
+ }
+ }
+
+ /**
+ * Get the URL to download/view the generated video.
+ *
+ * API: GET /v1/videos/{video_id}/content
+ * Docs: https://platform.openai.com/docs/api-reference/videos/content
+ *
+ * @experimental Video generation is an experimental feature and may change.
+ *
+ * @example
+ * ```ts
+ * const { url, expiresAt } = await adapter.getVideoUrl(jobId)
+ * console.log('Video URL:', url)
+ * console.log('Expires at:', expiresAt)
+ * ```
+ */
+ async getVideoUrl(jobId: string): Promise {
+ try {
+ // GET /v1/videos/{video_id}/content
+ // The SDK may not have a .content() method, so we try multiple approaches
+ const client = this.client as any
+
+ let response: any
+
+ // Try different possible method names
+ if (typeof client.videos?.content === 'function') {
+ response = await client.videos.content(jobId)
+ } else if (typeof client.videos?.getContent === 'function') {
+ response = await client.videos.getContent(jobId)
+ } else if (typeof client.videos?.download === 'function') {
+ response = await client.videos.download(jobId)
+ } else {
+ // Fallback: check if retrieve returns the URL directly
+ const videoInfo = await client.videos.retrieve(jobId)
+ if (videoInfo.url) {
+ return {
+ jobId,
+ url: videoInfo.url,
+ expiresAt: videoInfo.expires_at
+ ? new Date(videoInfo.expires_at)
+ : undefined,
+ }
+ }
+
+ // Last resort: The /content endpoint returns raw binary video data, not JSON.
+ // We need to construct a URL that the client can use to fetch the video.
+ // The URL needs to include auth, so we'll create a signed URL or return
+ // a proxy endpoint.
+
+ // For now, return a URL that goes through our API to proxy the request
+ // since the raw endpoint requires auth headers that browsers can't send.
+ // The video element can't add Authorization headers, so we need a workaround.
+
+ // Option 1: Return the direct URL (only works if OpenAI supports query param auth)
+ // Option 2: Return a blob URL after fetching (memory intensive)
+ // Option 3: Return a proxy URL through our server
+
+ // Let's try fetching and returning a data URL for now
+ const baseUrl = this.config.baseUrl || 'https://api.openai.com/v1'
+ const apiKey = this.config.apiKey
+
+ const contentResponse = await fetch(
+ `${baseUrl}/videos/${jobId}/content`,
+ {
+ method: 'GET',
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ },
+ },
+ )
+
+ if (!contentResponse.ok) {
+ // Try to parse error as JSON, but it might be binary
+ const contentType = contentResponse.headers.get('content-type')
+ if (contentType?.includes('application/json')) {
+ const errorData = await contentResponse.json().catch(() => ({}))
+ throw new Error(
+ errorData.error?.message ||
+ `Failed to get video content: ${contentResponse.status}`,
+ )
+ }
+ throw new Error(
+ `Failed to get video content: ${contentResponse.status}`,
+ )
+ }
+
+ // The response is the raw video file - convert to base64 data URL
+ const videoBlob = await contentResponse.blob()
+ const buffer = await videoBlob.arrayBuffer()
+ const base64 = Buffer.from(buffer).toString('base64')
+ const mimeType =
+ contentResponse.headers.get('content-type') || 'video/mp4'
+
+ return {
+ jobId,
+ url: `data:${mimeType};base64,${base64}`,
+ expiresAt: undefined, // Data URLs don't expire
+ }
+ }
+
+ return {
+ jobId,
+ url: response.url,
+ expiresAt: response.expires_at
+ ? new Date(response.expires_at)
+ : undefined,
+ }
+ } catch (error: any) {
+ if (error.status === 404) {
+ throw new Error(`Video job not found: ${jobId}`)
+ }
+ if (error.status === 400) {
+ throw new Error(
+ `Video is not ready for download. Check status first. Job ID: ${jobId}`,
+ )
+ }
+ throw error
+ }
+ }
+
+ private buildRequest(
+ options: VideoGenerationOptions,
+ ): Record {
+ const { model, prompt, size, duration, providerOptions } = options
+
+ const request: Record = {
+ model,
+ prompt,
+ }
+
+ // Add size/resolution
+ // Supported: '1280x720', '720x1280', '1792x1024', '1024x1792'
+ if (size) {
+ request.size = size
+ } else if (providerOptions?.size) {
+ request.size = providerOptions.size
+ }
+
+ // Add seconds (duration)
+ // Supported: '4', '8', or '12' - yes, the API wants strings
+ const seconds = duration ?? providerOptions?.seconds
+ if (seconds !== undefined) {
+ request.seconds = toApiSeconds(seconds)
+ }
+
+ return request
+ }
+
+ private mapStatus(
+ apiStatus: string,
+ ): 'pending' | 'processing' | 'completed' | 'failed' {
+ switch (apiStatus) {
+ case 'queued':
+ case 'pending':
+ return 'pending'
+ case 'processing':
+ case 'in_progress':
+ return 'processing'
+ case 'completed':
+ case 'succeeded':
+ return 'completed'
+ case 'failed':
+ case 'error':
+ case 'cancelled':
+ return 'failed'
+ default:
+ return 'processing'
+ }
+ }
+}
+
+/**
+ * Creates an OpenAI video adapter with an explicit API key.
+ *
+ * @experimental Video generation is an experimental feature and may change.
+ *
+ * @param apiKey - Your OpenAI API key
+ * @param config - Optional additional configuration
+ * @returns Configured OpenAI video adapter instance
+ *
+ * @example
+ * ```typescript
+ * const adapter = createOpenaiVideo('your-api-key');
+ *
+ * const { jobId } = await ai({
+ * adapter,
+ * model: 'sora-2',
+ * prompt: 'A beautiful sunset over the ocean'
+ * });
+ * ```
+ */
+export function createOpenaiVideo(
+ apiKey: string,
+ config?: Omit,
+): OpenAIVideoAdapter {
+ return new OpenAIVideoAdapter({ apiKey, ...config })
+}
+
+/**
+ * Creates an OpenAI video adapter with automatic API key detection from environment variables.
+ *
+ * Looks for `OPENAI_API_KEY` in:
+ * - `process.env` (Node.js)
+ * - `window.env` (Browser with injected env)
+ *
+ * @experimental Video generation is an experimental feature and may change.
+ *
+ * @param config - Optional configuration (excluding apiKey which is auto-detected)
+ * @returns Configured OpenAI video adapter instance
+ * @throws Error if OPENAI_API_KEY is not found in environment
+ *
+ * @example
+ * ```typescript
+ * // Automatically uses OPENAI_API_KEY from environment
+ * const adapter = openaiVideo();
+ *
+ * // Create a video generation job
+ * const { jobId } = await ai({
+ * adapter,
+ * model: 'sora-2',
+ * prompt: 'A cat playing piano'
+ * });
+ *
+ * // Poll for status
+ * const status = await ai({
+ * adapter,
+ * model: 'sora-2',
+ * jobId,
+ * request: 'status'
+ * });
+ *
+ * // Get video URL when complete
+ * const { url } = await ai({
+ * adapter,
+ * model: 'sora-2',
+ * jobId,
+ * request: 'url'
+ * });
+ * ```
+ */
+export function openaiVideo(
+ config?: Omit,
+): OpenAIVideoAdapter {
+ const apiKey = getOpenAIApiKeyFromEnv()
+ return createOpenaiVideo(apiKey, config)
+}
diff --git a/packages/typescript/ai-openai/src/audio/transcription-provider-options.ts b/packages/typescript/ai-openai/src/audio/transcription-provider-options.ts
new file mode 100644
index 00000000..4dfe5ffb
--- /dev/null
+++ b/packages/typescript/ai-openai/src/audio/transcription-provider-options.ts
@@ -0,0 +1,18 @@
+/**
+ * Provider-specific options for OpenAI Transcription
+ */
+export interface OpenAITranscriptionProviderOptions {
+ /**
+ * The sampling temperature, between 0 and 1.
+ * Higher values like 0.8 will make the output more random,
+ * while lower values like 0.2 will make it more focused and deterministic.
+ */
+ temperature?: number
+
+ /**
+ * The timestamp granularities to populate for this transcription.
+ * response_format must be set to verbose_json to use timestamp granularities.
+ * Either or both of these options are supported: word, or segment.
+ */
+ timestamp_granularities?: Array<'word' | 'segment'>
+}
diff --git a/packages/typescript/ai-openai/src/audio/tts-provider-options.ts b/packages/typescript/ai-openai/src/audio/tts-provider-options.ts
new file mode 100644
index 00000000..4368caed
--- /dev/null
+++ b/packages/typescript/ai-openai/src/audio/tts-provider-options.ts
@@ -0,0 +1,31 @@
+/**
+ * OpenAI TTS voice options
+ */
+export type OpenAITTSVoice =
+ | 'alloy'
+ | 'ash'
+ | 'ballad'
+ | 'coral'
+ | 'echo'
+ | 'fable'
+ | 'onyx'
+ | 'nova'
+ | 'sage'
+ | 'shimmer'
+ | 'verse'
+
+/**
+ * OpenAI TTS output format options
+ */
+export type OpenAITTSFormat = 'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm'
+
+/**
+ * Provider-specific options for OpenAI TTS
+ */
+export interface OpenAITTSProviderOptions {
+ /**
+ * Control the voice of your generated audio with additional instructions.
+ * Does not work with tts-1 or tts-1-hd.
+ */
+ instructions?: string
+}
diff --git a/packages/typescript/ai-openai/src/image/image-provider-options.ts b/packages/typescript/ai-openai/src/image/image-provider-options.ts
index 49e9fcc1..a16281cf 100644
--- a/packages/typescript/ai-openai/src/image/image-provider-options.ts
+++ b/packages/typescript/ai-openai/src/image/image-provider-options.ts
@@ -1,23 +1,269 @@
-interface ImageProviderOptions {
+/**
+ * OpenAI Image Generation Provider Options
+ *
+ * These are provider-specific options for OpenAI image generation.
+ * Common options like prompt, numberOfImages, and size are handled
+ * in the base ImageGenerationOptions.
+ */
+
+/**
+ * Quality options for gpt-image-1 and gpt-image-1-mini models
+ */
+export type GptImageQuality = 'high' | 'medium' | 'low' | 'auto'
+
+/**
+ * Quality options for dall-e-3 model
+ */
+export type DallE3Quality = 'hd' | 'standard'
+
+/**
+ * Quality options for dall-e-2 model (only standard is supported)
+ */
+export type DallE2Quality = 'standard'
+
+/**
+ * Style options for dall-e-3 model
+ */
+export type DallE3Style = 'vivid' | 'natural'
+
+/**
+ * Output format options for gpt-image-1 models
+ */
+export type GptImageOutputFormat = 'png' | 'jpeg' | 'webp'
+
+/**
+ * Response format options for dall-e models
+ */
+export type DallEResponseFormat = 'url' | 'b64_json'
+
+/**
+ * Background options for gpt-image-1 models
+ */
+export type GptImageBackground = 'transparent' | 'opaque' | 'auto'
+
+/**
+ * Moderation level for gpt-image-1 models
+ */
+export type GptImageModeration = 'low' | 'auto'
+
+/**
+ * Supported sizes for gpt-image-1 models
+ */
+export type GptImageSize = '1024x1024' | '1536x1024' | '1024x1536' | 'auto'
+
+/**
+ * Supported sizes for dall-e-3 model
+ */
+export type DallE3Size = '1024x1024' | '1792x1024' | '1024x1792'
+
+/**
+ * Supported sizes for dall-e-2 model
+ */
+export type DallE2Size = '256x256' | '512x512' | '1024x1024'
+
+/**
+ * Base provider options shared across all OpenAI image models
+ */
+export interface OpenAIImageBaseProviderOptions {
/**
- * A text prompt describing the desired image. The maximum length is 32000 characters for gpt-image-1, 1000 characters for dall-e-2 and 4000 characters for dall-e-3.
+ * A unique identifier representing your end-user.
+ * Can help OpenAI to monitor and detect abuse.
*/
- prompt: string
+ user?: string
+}
+
+/**
+ * Provider options for gpt-image-1 model
+ * Field names match the OpenAI API for direct spreading
+ */
+export interface GptImage1ProviderOptions extends OpenAIImageBaseProviderOptions {
/**
- * Allows to set transparency for the background of the generated image(s). This parameter is only supported for gpt-image-1. Must be one of transparent, opaque or auto (default value). When auto is used, the model will automatically determine the best background for the image.
+ * The quality of the image.
+ * @default 'auto'
+ */
+ quality?: GptImageQuality
-If transparent, the output format needs to support transparency, so it should be set to either png (default value) or webp.
+ /**
+ * Background transparency setting.
+ * When 'transparent', output format must be 'png' or 'webp'.
+ * @default 'auto'
*/
- background?: 'transparent' | 'opaque' | 'auto' | null
+ background?: GptImageBackground
+
/**
- * The image model to use for generation.
+ * Output image format.
+ * @default 'png'
*/
+ output_format?: GptImageOutputFormat
+
+ /**
+ * Compression level (0-100%) for webp/jpeg formats.
+ * @default 100
+ */
+ output_compression?: number
+
+ /**
+ * Content moderation level.
+ * @default 'auto'
+ */
+ moderation?: GptImageModeration
+
+ /**
+ * Number of partial images to generate during streaming (0-3).
+ * Only used when stream: true.
+ * @default 0
+ */
+ partial_images?: number
+}
+
+/**
+ * Provider options for gpt-image-1-mini model
+ * Same as gpt-image-1
+ */
+export type GptImage1MiniProviderOptions = GptImage1ProviderOptions
+
+/**
+ * Provider options for dall-e-3 model
+ * Field names match the OpenAI API for direct spreading
+ */
+export interface DallE3ProviderOptions extends OpenAIImageBaseProviderOptions {
+ /**
+ * The quality of the image.
+ * @default 'standard'
+ */
+ quality?: DallE3Quality
+
+ /**
+ * The style of the generated images.
+ * 'vivid' causes the model to lean towards generating hyper-real and dramatic images.
+ * 'natural' causes the model to produce more natural, less hyper-real looking images.
+ * @default 'vivid'
+ */
+ style?: DallE3Style
+
+ /**
+ * The format in which generated images are returned.
+ * URLs are only valid for 60 minutes after generation.
+ * @default 'url'
+ */
+ response_format?: DallEResponseFormat
+}
+
+/**
+ * Provider options for dall-e-2 model
+ * Field names match the OpenAI API for direct spreading
+ */
+export interface DallE2ProviderOptions extends OpenAIImageBaseProviderOptions {
+ /**
+ * The quality of the image (only 'standard' is supported).
+ */
+ quality?: DallE2Quality
+
+ /**
+ * The format in which generated images are returned.
+ * URLs are only valid for 60 minutes after generation.
+ * @default 'url'
+ */
+ response_format?: DallEResponseFormat
+}
+
+/**
+ * Union of all OpenAI image provider options
+ */
+export type OpenAIImageProviderOptions =
+ | GptImage1ProviderOptions
+ | GptImage1MiniProviderOptions
+ | DallE3ProviderOptions
+ | DallE2ProviderOptions
+
+/**
+ * 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.
+ */
+export type OpenAIImageModelProviderOptionsByName = {
+ 'gpt-image-1': GptImage1ProviderOptions
+ 'gpt-image-1-mini': GptImage1MiniProviderOptions
+ 'dall-e-3': DallE3ProviderOptions
+ 'dall-e-2': DallE2ProviderOptions
+}
+
+/**
+ * Type-only map from model name to its supported sizes.
+ */
+export type OpenAIImageModelSizeByName = {
+ 'gpt-image-1': GptImageSize
+ 'gpt-image-1-mini': GptImageSize
+ 'dall-e-3': DallE3Size
+ 'dall-e-2': DallE2Size
+}
+
+/**
+ * Internal options interface for validation
+ */
+interface ImageValidationOptions {
+ prompt: string
model: string
+ background?: 'transparent' | 'opaque' | 'auto' | null
+}
+
+/**
+ * Validates that the provided size is supported by the model.
+ * Throws a descriptive error if the size is not supported.
+ */
+export function validateImageSize(
+ model: string,
+ size: string | undefined,
+): void {
+ if (!size || size === 'auto') return
+
+ const validSizes: Record> = {
+ 'gpt-image-1': ['1024x1024', '1536x1024', '1024x1536', 'auto'],
+ 'gpt-image-1-mini': ['1024x1024', '1536x1024', '1024x1536', 'auto'],
+ 'dall-e-3': ['1024x1024', '1792x1024', '1024x1792'],
+ 'dall-e-2': ['256x256', '512x512', '1024x1024'],
+ }
+
+ const modelSizes = validSizes[model]
+ if (!modelSizes) {
+ throw new Error(`Unknown image model: ${model}`)
+ }
+
+ if (!modelSizes.includes(size)) {
+ throw new Error(
+ `Size "${size}" is not supported by model "${model}". ` +
+ `Supported sizes: ${modelSizes.join(', ')}`,
+ )
+ }
+}
+
+/**
+ * Validates that the number of images is within bounds for the model.
+ */
+export function validateNumberOfImages(
+ model: string,
+ numberOfImages: number | undefined,
+): void {
+ if (numberOfImages === undefined) return
+
+ // dall-e-3 only supports n=1
+ if (model === 'dall-e-3' && numberOfImages !== 1) {
+ throw new Error(
+ `Model "dall-e-3" only supports generating 1 image at a time. ` +
+ `Requested: ${numberOfImages}`,
+ )
+ }
+
+ // Other models support 1-10
+ if (numberOfImages < 1 || numberOfImages > 10) {
+ throw new Error(
+ `Number of images must be between 1 and 10. Requested: ${numberOfImages}`,
+ )
+ }
}
-export const validateBackground = (options: ImageProviderOptions) => {
+export const validateBackground = (options: ImageValidationOptions) => {
if (options.background) {
- const supportedModels = ['gpt-image-1']
+ const supportedModels = ['gpt-image-1', 'gpt-image-1-mini']
if (!supportedModels.includes(options.model)) {
throw new Error(
`The model ${options.model} does not support background option.`,
@@ -26,13 +272,16 @@ export const validateBackground = (options: ImageProviderOptions) => {
}
}
-export const validatePrompt = (options: ImageProviderOptions) => {
+export const validatePrompt = (options: ImageValidationOptions) => {
if (options.prompt.length === 0) {
throw new Error('Prompt cannot be empty.')
}
- if (options.model === 'gpt-image-1' && options.prompt.length > 32000) {
+ if (
+ (options.model === 'gpt-image-1' || options.model === 'gpt-image-1-mini') &&
+ options.prompt.length > 32000
+ ) {
throw new Error(
- 'For gpt-image-1, prompt length must be less than or equal to 32000 characters.',
+ 'For gpt-image-1/gpt-image-1-mini, prompt length must be less than or equal to 32000 characters.',
)
}
if (options.model === 'dall-e-2' && options.prompt.length > 1000) {
diff --git a/packages/typescript/ai-openai/src/index.ts b/packages/typescript/ai-openai/src/index.ts
index df504301..2e0cf5d3 100644
--- a/packages/typescript/ai-openai/src/index.ts
+++ b/packages/typescript/ai-openai/src/index.ts
@@ -1,13 +1,114 @@
+// ============================================================================
+// New Tree-Shakeable Adapters (Recommended)
+// ============================================================================
+
+// Text (Chat) adapter - for chat/text completion
+export {
+ OpenAITextAdapter,
+ createOpenaiText,
+ openaiText,
+ type OpenAITextConfig,
+ type OpenAITextProviderOptions,
+} from './adapters/text'
+
+// Embedding adapter - for text embeddings
+export {
+ OpenAIEmbedAdapter,
+ createOpenaiEmbed,
+ openaiEmbed,
+ type OpenAIEmbedConfig,
+ type OpenAIEmbedProviderOptions,
+} from './adapters/embed'
+
+// Summarize adapter - for text summarization
+export {
+ OpenAISummarizeAdapter,
+ createOpenaiSummarize,
+ openaiSummarize,
+ type OpenAISummarizeConfig,
+ type OpenAISummarizeProviderOptions,
+} from './adapters/summarize'
+
+// Image adapter - for image generation
+export {
+ OpenAIImageAdapter,
+ createOpenaiImage,
+ openaiImage,
+ type OpenAIImageConfig,
+} from './adapters/image'
+export type {
+ OpenAIImageProviderOptions,
+ OpenAIImageModelProviderOptionsByName,
+} from './image/image-provider-options'
+
+// Video adapter - for video generation (experimental)
+/**
+ * @experimental Video generation is an experimental feature and may change.
+ */
+export {
+ OpenAIVideoAdapter,
+ createOpenaiVideo,
+ openaiVideo,
+ type OpenAIVideoConfig,
+} from './adapters/video'
+export type {
+ OpenAIVideoProviderOptions,
+ OpenAIVideoModelProviderOptionsByName,
+ OpenAIVideoSize,
+ OpenAIVideoDuration,
+} from './video/video-provider-options'
+
+// TTS adapter - for text-to-speech
+export {
+ OpenAITTSAdapter,
+ createOpenaiTTS,
+ openaiTTS,
+ type OpenAITTSConfig,
+} from './adapters/tts'
+export type {
+ OpenAITTSProviderOptions,
+ OpenAITTSVoice,
+ OpenAITTSFormat,
+} from './audio/tts-provider-options'
+
+// Transcription adapter - for speech-to-text
+export {
+ OpenAITranscriptionAdapter,
+ createOpenaiTranscription,
+ openaiTranscription,
+ type OpenAITranscriptionConfig,
+} from './adapters/transcription'
+export type { OpenAITranscriptionProviderOptions } from './audio/transcription-provider-options'
+
+// ============================================================================
+// Legacy Exports (Deprecated - will be removed in future versions)
+// ============================================================================
+
+/**
+ * @deprecated Use `openaiText()`, `openaiEmbed()`, or `openaiSummarize()` instead.
+ * This monolithic adapter will be removed in a future version.
+ */
export {
OpenAI,
createOpenAI,
openai,
type OpenAIConfig,
} from './openai-adapter'
+
+// ============================================================================
+// Type Exports
+// ============================================================================
+
export type {
OpenAIChatModelProviderOptionsByName,
OpenAIModelInputModalitiesByName,
} from './model-meta'
+export {
+ OPENAI_IMAGE_MODELS,
+ OPENAI_TTS_MODELS,
+ OPENAI_TRANSCRIPTION_MODELS,
+ OPENAI_VIDEO_MODELS,
+} from './model-meta'
export type {
OpenAITextMetadata,
OpenAIImageMetadata,
diff --git a/packages/typescript/ai-openai/src/model-meta.ts b/packages/typescript/ai-openai/src/model-meta.ts
index 024a6c1a..69ae9cba 100644
--- a/packages/typescript/ai-openai/src/model-meta.ts
+++ b/packages/typescript/ai-openai/src/model-meta.ts
@@ -300,7 +300,11 @@ const GPT5_CODEX = {
OpenAIMetadataOptions
>
-/* const SORA2 = {
+/**
+ * Sora-2 video generation model.
+ * @experimental Video generation is an experimental feature and may change.
+ */
+const SORA2 = {
name: 'sora-2',
pricing: {
input: {
@@ -321,6 +325,10 @@ const GPT5_CODEX = {
OpenAIBaseOptions & OpenAIStreamingOptions & OpenAIMetadataOptions
>
+/**
+ * Sora-2-Pro video generation model (higher quality).
+ * @experimental Video generation is an experimental feature and may change.
+ */
const SORA2_PRO = {
name: 'sora-2-pro',
pricing: {
@@ -386,7 +394,7 @@ const GPT_IMAGE_1_MINI = {
},
} as const satisfies ModelMeta<
OpenAIBaseOptions & OpenAIStreamingOptions & OpenAIMetadataOptions
-> */
+>
const O3_DEEP_RESEARCH = {
name: 'o3-deep-research',
@@ -1246,7 +1254,7 @@ const CODEX_MINI_LATEST = {
OpenAIStreamingOptions &
OpenAIMetadataOptions
>
-/*
+
const DALL_E_2 = {
name: 'dall-e-2',
pricing: {
@@ -1287,7 +1295,7 @@ const DALL_E_3 = {
},
} as const satisfies ModelMeta<
OpenAIBaseOptions & OpenAIStreamingOptions & OpenAIMetadataOptions
-> */
+>
const GPT_3_5_TURBO = {
name: 'gpt-3.5-turbo',
@@ -1653,12 +1661,12 @@ export const OPENAI_CHAT_MODELS = [
] as const
// Image generation models (based on endpoints: "image-generation" or "image-edit")
-/* const OPENAI_IMAGE_MODELS = [
+export const OPENAI_IMAGE_MODELS = [
GPT_IMAGE_1.name,
GPT_IMAGE_1_MINI.name,
DALL_E_3.name,
DALL_E_2.name,
-] as const */
+] as const
// Embedding models (based on endpoints: "embedding")
export const OPENAI_EMBEDDING_MODELS = [
@@ -1691,9 +1699,30 @@ export const OPENAI_EMBEDDING_MODELS = [
GPT_4O_MINI_TRANSCRIBE.name,
] as const
-// Video generation models (based on endpoints: "video")
-const OPENAI_VIDEO_MODELS = [SORA2.name, SORA2_PRO.name] as const
+/**
+ * Video generation models (based on endpoints: "video")
+ * @experimental Video generation is an experimental feature and may change.
+ */
+export const OPENAI_VIDEO_MODELS = [SORA2.name, SORA2_PRO.name] as const
+
+/**
+ * Text-to-speech models (based on endpoints: "speech_generation")
+ */
+export const OPENAI_TTS_MODELS = [
+ 'tts-1',
+ 'tts-1-hd',
+ 'gpt-4o-audio-preview',
+] as const
+
+/**
+ * Transcription models (based on endpoints: "transcription")
*/
+export const OPENAI_TRANSCRIPTION_MODELS = [
+ 'whisper-1',
+ 'gpt-4o-transcribe',
+ 'gpt-4o-mini-transcribe',
+ 'gpt-4o-transcribe-diarize',
+] as const
// const OPENAI_MODERATION_MODELS = [OMNI_MODERATION.name] as const
// export type OpenAIChatModel = (typeof OPENAI_CHAT_MODELS)[number]
diff --git a/packages/typescript/ai-openai/src/openai-adapter.ts b/packages/typescript/ai-openai/src/openai-adapter.ts
index 676d5257..c281c012 100644
--- a/packages/typescript/ai-openai/src/openai-adapter.ts
+++ b/packages/typescript/ai-openai/src/openai-adapter.ts
@@ -5,7 +5,6 @@ import { validateTextProviderOptions } from './text/text-provider-options'
import { convertToolsToProviderFormat } from './tools'
import type { Responses } from 'openai/resources'
import type {
- ChatOptions,
ContentPart,
EmbeddingOptions,
EmbeddingResult,
@@ -13,6 +12,7 @@ import type {
StreamChunk,
SummarizationOptions,
SummarizationResult,
+ TextOptions,
} from '@tanstack/ai'
import type {
OpenAIChatModelProviderOptionsByName,
@@ -88,13 +88,13 @@ export class OpenAI extends BaseAdapter<
}
async *chatStream(
- options: ChatOptions,
+ options: TextOptions,
): AsyncIterable {
// Track tool call metadata by unique ID
// OpenAI streams tool calls with deltas - first chunk has ID/name, subsequent chunks only have args
// We assign our own indices as we encounter unique tool call IDs
const toolCallMetadata = new Map()
- const requestArguments = this.mapChatOptionsToOpenAI(options)
+ const requestArguments = this.mapTextOptionsToOpenAI(options)
try {
const response = await this.client.responses.create(
@@ -199,7 +199,7 @@ export class OpenAI extends BaseAdapter<
private async *processOpenAIStreamChunks(
stream: AsyncIterable,
toolCallMetadata: Map,
- options: ChatOptions,
+ options: TextOptions,
generateId: () => string,
): AsyncIterable {
let accumulatedContent = ''
@@ -491,7 +491,7 @@ export class OpenAI extends BaseAdapter<
* Maps common options to OpenAI-specific format
* Handles translation of normalized options to OpenAI's API format
*/
- private mapChatOptionsToOpenAI(options: ChatOptions) {
+ private mapTextOptionsToOpenAI(options: TextOptions) {
const providerOptions = options.providerOptions as
| Omit<
InternalTextProviderOptions,
@@ -505,7 +505,11 @@ export class OpenAI extends BaseAdapter<
| undefined
const input = this.convertMessagesToInput(options.messages)
if (providerOptions) {
- validateTextProviderOptions({ ...providerOptions, input })
+ validateTextProviderOptions({
+ ...providerOptions,
+ input,
+ model: options.model,
+ })
}
const tools = options.tools
diff --git a/packages/typescript/ai-openai/src/tools/function-tool.ts b/packages/typescript/ai-openai/src/tools/function-tool.ts
index 60737b4c..efebdb87 100644
--- a/packages/typescript/ai-openai/src/tools/function-tool.ts
+++ b/packages/typescript/ai-openai/src/tools/function-tool.ts
@@ -1,38 +1,35 @@
-import { convertZodToJsonSchema } from '@tanstack/ai'
+import { convertZodToOpenAISchema } from '../utils/schema-converter'
import type { Tool } from '@tanstack/ai'
import type OpenAI from 'openai'
export type FunctionTool = OpenAI.Responses.FunctionTool
/**
- * Converts a standard Tool to OpenAI FunctionTool format
+ * Converts a standard Tool to OpenAI FunctionTool format.
+ *
+ * Uses the OpenAI-specific schema converter which applies strict mode transformations:
+ * - All properties in required array
+ * - Optional fields made nullable
+ * - additionalProperties: false
+ *
+ * This enables strict mode for all tools automatically.
*/
export function convertFunctionToolToAdapterFormat(tool: Tool): FunctionTool {
- // Convert Zod schema to JSON Schema
+ // Convert Zod schema to OpenAI-compatible JSON Schema (with strict mode transformations)
const jsonSchema = tool.inputSchema
- ? convertZodToJsonSchema(tool.inputSchema)
- : undefined
-
- // Determine if we can use strict mode
- // Strict mode requires all properties to be in the required array
- const properties = jsonSchema?.properties || {}
- const required = jsonSchema?.required || []
- const propertyNames = Object.keys(properties)
-
- // Only enable strict mode if all properties are required
- // This ensures compatibility with tools that have optional parameters
- const canUseStrict =
- propertyNames.length > 0 &&
- propertyNames.every((prop: string) => required.includes(prop))
+ ? convertZodToOpenAISchema(tool.inputSchema)
+ : {
+ type: 'object',
+ properties: {},
+ required: [],
+ additionalProperties: false,
+ }
return {
type: 'function',
name: tool.name,
description: tool.description,
- parameters: {
- ...jsonSchema,
- additionalProperties: false,
- },
- strict: canUseStrict,
+ parameters: jsonSchema,
+ strict: true, // Always use strict mode since our schema converter handles the requirements
} satisfies FunctionTool
}
diff --git a/packages/typescript/ai-openai/src/utils/client.ts b/packages/typescript/ai-openai/src/utils/client.ts
new file mode 100644
index 00000000..828541e2
--- /dev/null
+++ b/packages/typescript/ai-openai/src/utils/client.ts
@@ -0,0 +1,47 @@
+import OpenAI_SDK from 'openai'
+
+export interface OpenAIClientConfig {
+ apiKey: string
+ organization?: string
+ baseURL?: string
+}
+
+/**
+ * Creates an OpenAI SDK client instance
+ */
+export function createOpenAIClient(config: OpenAIClientConfig): OpenAI_SDK {
+ return new OpenAI_SDK({
+ apiKey: config.apiKey,
+ organization: config.organization,
+ baseURL: config.baseURL,
+ })
+}
+
+/**
+ * Gets OpenAI API key from environment variables
+ * @throws Error if OPENAI_API_KEY is not found
+ */
+export function getOpenAIApiKeyFromEnv(): string {
+ const env =
+ typeof globalThis !== 'undefined' && (globalThis as any).window?.env
+ ? (globalThis as any).window.env
+ : typeof process !== 'undefined'
+ ? process.env
+ : undefined
+ const key = env?.OPENAI_API_KEY
+
+ if (!key) {
+ throw new Error(
+ 'OPENAI_API_KEY is required. Please set it in your environment variables or use the factory function with an explicit API key.',
+ )
+ }
+
+ return key
+}
+
+/**
+ * Generates a unique ID with a prefix
+ */
+export function generateId(prefix: string): string {
+ return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(7)}`
+}
diff --git a/packages/typescript/ai-openai/src/utils/index.ts b/packages/typescript/ai-openai/src/utils/index.ts
new file mode 100644
index 00000000..20475201
--- /dev/null
+++ b/packages/typescript/ai-openai/src/utils/index.ts
@@ -0,0 +1,10 @@
+export {
+ createOpenAIClient,
+ getOpenAIApiKeyFromEnv,
+ generateId,
+ type OpenAIClientConfig,
+} from './client'
+export {
+ convertZodToOpenAISchema,
+ transformNullsToUndefined,
+} from './schema-converter'
diff --git a/packages/typescript/ai-openai/src/utils/schema-converter.ts b/packages/typescript/ai-openai/src/utils/schema-converter.ts
new file mode 100644
index 00000000..c207e3db
--- /dev/null
+++ b/packages/typescript/ai-openai/src/utils/schema-converter.ts
@@ -0,0 +1,213 @@
+import { toJSONSchema } from 'zod'
+import type { z } from 'zod'
+
+/**
+ * Check if a value is a Zod schema by looking for Zod-specific internals.
+ * Zod schemas have a `_zod` property that contains metadata.
+ */
+function isZodSchema(schema: unknown): schema is z.ZodType {
+ return (
+ typeof schema === 'object' &&
+ schema !== null &&
+ '_zod' in schema &&
+ typeof (schema as any)._zod === 'object'
+ )
+}
+
+/**
+ * Recursively transform null values to undefined in an object.
+ *
+ * This is needed because OpenAI's structured output requires all fields to be
+ * in the `required` array, with optional fields made nullable (type: ["string", "null"]).
+ * When OpenAI returns null for optional fields, we need to convert them back to
+ * undefined to match the original Zod schema expectations.
+ *
+ * @param obj - Object to transform
+ * @returns Object with nulls converted to undefined
+ */
+export function transformNullsToUndefined(obj: T): T {
+ if (obj === null) {
+ return undefined as unknown as T
+ }
+
+ if (Array.isArray(obj)) {
+ return obj.map((item) => transformNullsToUndefined(item)) as unknown as T
+ }
+
+ if (typeof obj === 'object') {
+ const result: Record = {}
+ for (const [key, value] of Object.entries(obj as Record)) {
+ const transformed = transformNullsToUndefined(value)
+ // Only include the key if the value is not undefined
+ // This makes { notes: null } become {} (field absent) instead of { notes: undefined }
+ if (transformed !== undefined) {
+ result[key] = transformed
+ }
+ }
+ return result as T
+ }
+
+ return obj
+}
+
+/**
+ * Transform a JSON schema to be compatible with OpenAI's structured output requirements.
+ * OpenAI requires:
+ * - All properties must be in the `required` array
+ * - Optional fields should have null added to their type union
+ * - additionalProperties must be false for objects
+ *
+ * @param schema - JSON schema to transform
+ * @param originalRequired - Original required array (to know which fields were optional)
+ * @returns Transformed schema compatible with OpenAI structured output
+ */
+function makeOpenAIStructuredOutputCompatible(
+ schema: Record,
+ originalRequired: Array = [],
+): Record {
+ const result = { ...schema }
+
+ // Handle object types
+ if (result.type === 'object' && result.properties) {
+ const properties = { ...result.properties }
+ const allPropertyNames = Object.keys(properties)
+
+ // Transform each property
+ for (const propName of allPropertyNames) {
+ const prop = properties[propName]
+ const wasOptional = !originalRequired.includes(propName)
+
+ // Recursively transform nested objects/arrays
+ if (prop.type === 'object' && prop.properties) {
+ properties[propName] = makeOpenAIStructuredOutputCompatible(
+ prop,
+ prop.required || [],
+ )
+ } else if (prop.type === 'array' && prop.items) {
+ properties[propName] = {
+ ...prop,
+ items: makeOpenAIStructuredOutputCompatible(
+ prop.items,
+ prop.items.required || [],
+ ),
+ }
+ } else if (wasOptional) {
+ // Make optional fields nullable by adding null to the type
+ if (prop.type && !Array.isArray(prop.type)) {
+ properties[propName] = {
+ ...prop,
+ type: [prop.type, 'null'],
+ }
+ } else if (Array.isArray(prop.type) && !prop.type.includes('null')) {
+ properties[propName] = {
+ ...prop,
+ type: [...prop.type, 'null'],
+ }
+ }
+ }
+ }
+
+ result.properties = properties
+ // ALL properties must be required for OpenAI structured output
+ result.required = allPropertyNames
+ // additionalProperties must be false
+ result.additionalProperties = false
+ }
+
+ // Handle array types with object items
+ if (result.type === 'array' && result.items) {
+ result.items = makeOpenAIStructuredOutputCompatible(
+ result.items,
+ result.items.required || [],
+ )
+ }
+
+ return result
+}
+
+/**
+ * Converts a Zod schema to JSON Schema format compatible with OpenAI's structured output.
+ *
+ * OpenAI's structured output has strict requirements:
+ * - All properties must be in the `required` array
+ * - Optional fields should have null added to their type union
+ * - additionalProperties must be false for all objects
+ *
+ * @param schema - Zod schema to convert
+ * @returns JSON Schema object compatible with OpenAI's structured output API
+ *
+ * @example
+ * ```typescript
+ * import { z } from 'zod';
+ *
+ * const zodSchema = z.object({
+ * location: z.string().describe('City name'),
+ * unit: z.enum(['celsius', 'fahrenheit']).optional()
+ * });
+ *
+ * const jsonSchema = convertZodToOpenAISchema(zodSchema);
+ * // Returns:
+ * // {
+ * // type: 'object',
+ * // properties: {
+ * // location: { type: 'string', description: 'City name' },
+ * // unit: { type: ['string', 'null'], enum: ['celsius', 'fahrenheit'] }
+ * // },
+ * // required: ['location', 'unit'],
+ * // additionalProperties: false
+ * // }
+ * ```
+ */
+export function convertZodToOpenAISchema(
+ schema: z.ZodType,
+): Record {
+ if (!isZodSchema(schema)) {
+ throw new Error('Expected a Zod schema')
+ }
+
+ // Use Zod's built-in toJSONSchema
+ const jsonSchema = toJSONSchema(schema, {
+ target: 'openapi-3.0',
+ reused: 'ref',
+ })
+
+ // Remove $schema property as it's not needed for LLM providers
+ let result = jsonSchema
+ if (typeof result === 'object' && '$schema' in result) {
+ const { $schema, ...rest } = result
+ result = rest
+ }
+
+ // Ensure object schemas always have type: "object"
+ if (typeof result === 'object') {
+ const isZodObject =
+ typeof schema === 'object' &&
+ 'def' in schema &&
+ schema.def.type === 'object'
+
+ if (isZodObject && !result.type) {
+ result.type = 'object'
+ }
+
+ if (Object.keys(result).length === 0) {
+ result.type = 'object'
+ }
+
+ if ('properties' in result && !result.type) {
+ result.type = 'object'
+ }
+
+ if (result.type === 'object' && !('properties' in result)) {
+ result.properties = {}
+ }
+
+ if (result.type === 'object' && !('required' in result)) {
+ result.required = []
+ }
+
+ // Apply OpenAI-specific transformations for structured output
+ result = makeOpenAIStructuredOutputCompatible(result, result.required || [])
+ }
+
+ return result
+}
diff --git a/packages/typescript/ai-openai/src/video/video-provider-options.ts b/packages/typescript/ai-openai/src/video/video-provider-options.ts
new file mode 100644
index 00000000..b0355128
--- /dev/null
+++ b/packages/typescript/ai-openai/src/video/video-provider-options.ts
@@ -0,0 +1,123 @@
+/**
+ * OpenAI Video Generation Provider Options
+ *
+ * Based on https://platform.openai.com/docs/api-reference/videos/create
+ *
+ * @experimental Video generation is an experimental feature and may change.
+ */
+
+/**
+ * Supported video sizes for OpenAI Sora video generation.
+ * Based on the official API documentation.
+ *
+ * @experimental Video generation is an experimental feature and may change.
+ */
+export type OpenAIVideoSize =
+ | '1280x720' // 720p landscape (16:9)
+ | '720x1280' // 720p portrait (9:16)
+ | '1792x1024' // Landscape wide
+ | '1024x1792' // Portrait tall
+
+/**
+ * Supported video durations (in seconds) for OpenAI Sora video generation.
+ * The API uses the `seconds` parameter with STRING values '4', '8', or '12'.
+ * Yes, really. They're strings.
+ *
+ * @experimental Video generation is an experimental feature and may change.
+ */
+export type OpenAIVideoSeconds = '4' | '8' | '12'
+
+/**
+ * Provider-specific options for OpenAI video generation.
+ *
+ * @experimental Video generation is an experimental feature and may change.
+ */
+export interface OpenAIVideoProviderOptions {
+ /**
+ * Video size in WIDTHxHEIGHT format.
+ * Supported: '1280x720', '720x1280', '1792x1024', '1024x1792'
+ */
+ size?: OpenAIVideoSize
+
+ /**
+ * Video duration in seconds.
+ * Supported values: 4, 8, or 12 seconds.
+ */
+ seconds?: OpenAIVideoSeconds
+}
+
+/**
+ * Model-specific provider options mapping.
+ *
+ * @experimental Video generation is an experimental feature and may change.
+ */
+export type OpenAIVideoModelProviderOptionsByName = {
+ 'sora-2': OpenAIVideoProviderOptions
+ 'sora-2-pro': OpenAIVideoProviderOptions
+}
+
+/**
+ * Validate video size for a given model.
+ *
+ * @experimental Video generation is an experimental feature and may change.
+ */
+export function validateVideoSize(
+ model: string,
+ size?: string,
+): asserts size is OpenAIVideoSize | undefined {
+ const validSizes: Array = [
+ '1280x720',
+ '720x1280',
+ '1792x1024',
+ '1024x1792',
+ ]
+
+ if (size && !validSizes.includes(size as OpenAIVideoSize)) {
+ throw new Error(
+ `Size "${size}" is not supported by model "${model}". Supported sizes: ${validSizes.join(', ')}`,
+ )
+ }
+}
+
+/**
+ * Validate video duration (seconds) for a given model.
+ * Accepts both string and number for convenience, but the API requires strings.
+ *
+ * @experimental Video generation is an experimental feature and may change.
+ */
+export function validateVideoSeconds(
+ model: string,
+ seconds?: number | string,
+): asserts seconds is OpenAIVideoSeconds | number | undefined {
+ const validSeconds: Array = ['4', '8', '12']
+ const validNumbers: Array = [4, 8, 12]
+
+ if (seconds !== undefined) {
+ const isValid =
+ typeof seconds === 'string'
+ ? validSeconds.includes(seconds)
+ : validNumbers.includes(seconds)
+
+ if (!isValid) {
+ throw new Error(
+ `Duration "${seconds}" is not supported by model "${model}". Supported durations: 4, 8, or 12 seconds`,
+ )
+ }
+ }
+}
+
+/**
+ * Convert duration to API format (string).
+ * The OpenAI Sora API inexplicably requires seconds as a string.
+ */
+export function toApiSeconds(
+ seconds: number | string | undefined,
+): OpenAIVideoSeconds | undefined {
+ if (seconds === undefined) return undefined
+ return String(seconds) as OpenAIVideoSeconds
+}
+
+/**
+ * @deprecated Use OpenAIVideoSeconds instead
+ */
+export type OpenAIVideoDuration = OpenAIVideoSeconds
diff --git a/packages/typescript/ai-openai/tests/image-adapter.test.ts b/packages/typescript/ai-openai/tests/image-adapter.test.ts
new file mode 100644
index 00000000..78ca7fb3
--- /dev/null
+++ b/packages/typescript/ai-openai/tests/image-adapter.test.ts
@@ -0,0 +1,220 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { OpenAIImageAdapter, createOpenaiImage } from '../src/adapters/image'
+import {
+ validateImageSize,
+ validateNumberOfImages,
+ validatePrompt,
+} from '../src/image/image-provider-options'
+
+describe('OpenAI Image Adapter', () => {
+ describe('createOpenaiImage', () => {
+ it('creates an adapter with the provided API key', () => {
+ const adapter = createOpenaiImage('test-api-key')
+ expect(adapter).toBeInstanceOf(OpenAIImageAdapter)
+ expect(adapter.kind).toBe('image')
+ expect(adapter.name).toBe('openai')
+ })
+
+ it('has the correct models', () => {
+ const adapter = createOpenaiImage('test-api-key')
+ expect(adapter.models).toContain('gpt-image-1')
+ expect(adapter.models).toContain('gpt-image-1-mini')
+ expect(adapter.models).toContain('dall-e-3')
+ expect(adapter.models).toContain('dall-e-2')
+ })
+ })
+
+ describe('validateImageSize', () => {
+ describe('gpt-image-1', () => {
+ it('accepts valid sizes', () => {
+ expect(() =>
+ validateImageSize('gpt-image-1', '1024x1024'),
+ ).not.toThrow()
+ expect(() =>
+ validateImageSize('gpt-image-1', '1536x1024'),
+ ).not.toThrow()
+ expect(() =>
+ validateImageSize('gpt-image-1', '1024x1536'),
+ ).not.toThrow()
+ expect(() => validateImageSize('gpt-image-1', 'auto')).not.toThrow()
+ })
+
+ it('rejects invalid sizes', () => {
+ expect(() => validateImageSize('gpt-image-1', '512x512')).toThrow()
+ expect(() => validateImageSize('gpt-image-1', '1792x1024')).toThrow()
+ })
+
+ it('accepts undefined size', () => {
+ expect(() => validateImageSize('gpt-image-1', undefined)).not.toThrow()
+ })
+ })
+
+ describe('dall-e-3', () => {
+ it('accepts valid sizes', () => {
+ expect(() => validateImageSize('dall-e-3', '1024x1024')).not.toThrow()
+ expect(() => validateImageSize('dall-e-3', '1792x1024')).not.toThrow()
+ expect(() => validateImageSize('dall-e-3', '1024x1792')).not.toThrow()
+ })
+
+ it('rejects invalid sizes', () => {
+ expect(() => validateImageSize('dall-e-3', '512x512')).toThrow()
+ expect(() => validateImageSize('dall-e-3', '256x256')).toThrow()
+ })
+
+ it('accepts auto size (passes through)', () => {
+ // auto is treated as a pass-through and not validated
+ expect(() => validateImageSize('dall-e-3', 'auto')).not.toThrow()
+ })
+ })
+
+ describe('dall-e-2', () => {
+ it('accepts valid sizes', () => {
+ expect(() => validateImageSize('dall-e-2', '256x256')).not.toThrow()
+ expect(() => validateImageSize('dall-e-2', '512x512')).not.toThrow()
+ expect(() => validateImageSize('dall-e-2', '1024x1024')).not.toThrow()
+ })
+
+ it('rejects invalid sizes', () => {
+ expect(() => validateImageSize('dall-e-2', '1792x1024')).toThrow()
+ expect(() => validateImageSize('dall-e-2', '1024x1792')).toThrow()
+ })
+ })
+ })
+
+ describe('validateNumberOfImages', () => {
+ describe('dall-e-3', () => {
+ it('only accepts 1 image', () => {
+ expect(() => validateNumberOfImages('dall-e-3', 1)).not.toThrow()
+ expect(() => validateNumberOfImages('dall-e-3', 2)).toThrow()
+ expect(() =>
+ validateNumberOfImages('dall-e-3', undefined),
+ ).not.toThrow()
+ })
+ })
+
+ describe('dall-e-2', () => {
+ it('accepts 1-10 images', () => {
+ expect(() => validateNumberOfImages('dall-e-2', 1)).not.toThrow()
+ expect(() => validateNumberOfImages('dall-e-2', 5)).not.toThrow()
+ expect(() => validateNumberOfImages('dall-e-2', 10)).not.toThrow()
+ expect(() => validateNumberOfImages('dall-e-2', 11)).toThrow()
+ expect(() => validateNumberOfImages('dall-e-2', 0)).toThrow()
+ })
+ })
+
+ describe('gpt-image-1', () => {
+ it('accepts 1-10 images', () => {
+ expect(() => validateNumberOfImages('gpt-image-1', 1)).not.toThrow()
+ expect(() => validateNumberOfImages('gpt-image-1', 10)).not.toThrow()
+ expect(() => validateNumberOfImages('gpt-image-1', 11)).toThrow()
+ })
+ })
+ })
+
+ describe('validatePrompt', () => {
+ it('rejects empty prompts', () => {
+ expect(() =>
+ validatePrompt({ prompt: '', model: 'gpt-image-1' }),
+ ).toThrow()
+ })
+
+ it('accepts whitespace-only prompts (does not trim)', () => {
+ // The validation checks length, not trimmed length
+ expect(() =>
+ validatePrompt({ prompt: ' ', model: 'gpt-image-1' }),
+ ).not.toThrow()
+ })
+
+ it('accepts non-empty prompts', () => {
+ expect(() =>
+ validatePrompt({ prompt: 'A cat', model: 'gpt-image-1' }),
+ ).not.toThrow()
+ })
+ })
+
+ describe('generateImages', () => {
+ it('calls the OpenAI images.generate API', async () => {
+ const mockResponse = {
+ data: [
+ {
+ b64_json: 'base64encodedimage',
+ revised_prompt: 'A beautiful cat',
+ },
+ ],
+ usage: {
+ input_tokens: 10,
+ output_tokens: 100,
+ total_tokens: 110,
+ },
+ }
+
+ const mockGenerate = vi.fn().mockResolvedValueOnce(mockResponse)
+
+ const adapter = createOpenaiImage('test-api-key')
+ // Replace the internal OpenAI SDK client with our mock
+ ;(
+ adapter as unknown as { client: { images: { generate: unknown } } }
+ ).client = {
+ images: {
+ generate: mockGenerate,
+ },
+ }
+
+ const result = await adapter.generateImages({
+ model: 'gpt-image-1',
+ prompt: 'A cat wearing a hat',
+ numberOfImages: 1,
+ size: '1024x1024',
+ })
+
+ expect(mockGenerate).toHaveBeenCalledWith({
+ model: 'gpt-image-1',
+ prompt: 'A cat wearing a hat',
+ n: 1,
+ size: '1024x1024',
+ stream: false,
+ })
+
+ expect(result.model).toBe('gpt-image-1')
+ expect(result.images).toHaveLength(1)
+ expect(result.images[0].b64Json).toBe('base64encodedimage')
+ expect(result.images[0].revisedPrompt).toBe('A beautiful cat')
+ expect(result.usage).toEqual({
+ inputTokens: 10,
+ outputTokens: 100,
+ totalTokens: 110,
+ })
+ })
+
+ it('generates a unique ID for each response', async () => {
+ const mockResponse = {
+ data: [{ b64_json: 'base64' }],
+ }
+
+ const mockGenerate = vi.fn().mockResolvedValue(mockResponse)
+
+ const adapter = createOpenaiImage('test-api-key')
+ ;(
+ adapter as unknown as { client: { images: { generate: unknown } } }
+ ).client = {
+ images: {
+ generate: mockGenerate,
+ },
+ }
+
+ const result1 = await adapter.generateImages({
+ model: 'dall-e-3',
+ prompt: 'Test prompt',
+ })
+
+ const result2 = await adapter.generateImages({
+ model: 'dall-e-3',
+ prompt: 'Test prompt',
+ })
+
+ expect(result1.id).not.toBe(result2.id)
+ expect(result1.id).toMatch(/^openai-/)
+ expect(result2.id).toMatch(/^openai-/)
+ })
+ })
+})
diff --git a/packages/typescript/ai-openai/tests/openai-adapter.test.ts b/packages/typescript/ai-openai/tests/openai-adapter.test.ts
index febed8db..dc5bfcef 100644
--- a/packages/typescript/ai-openai/tests/openai-adapter.test.ts
+++ b/packages/typescript/ai-openai/tests/openai-adapter.test.ts
@@ -1,8 +1,9 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
-import { chat, type Tool, type StreamChunk } from '@tanstack/ai'
-import { OpenAI, type OpenAIProviderOptions } from '../src/openai-adapter'
+import { ai, type Tool, type StreamChunk } from '@tanstack/ai'
+import { OpenAITextAdapter } from '../src/adapters/text'
+import type { OpenAIProviderOptions } from '../src/openai-adapter'
-const createAdapter = () => new OpenAI({ apiKey: 'test-key' })
+const createAdapter = () => new OpenAITextAdapter({ apiKey: 'test-key' })
const toolArguments = JSON.stringify({ location: 'Berlin' })
@@ -77,7 +78,7 @@ describe('OpenAI adapter option mapping', () => {
}
const chunks: StreamChunk[] = []
- for await (const chunk of chat({
+ for await (const chunk of ai({
adapter,
model: 'gpt-4o-mini',
messages: [
diff --git a/packages/typescript/ai-react-ui/package.json b/packages/typescript/ai-react-ui/package.json
index 2c10e419..5dd9b063 100644
--- a/packages/typescript/ai-react-ui/package.json
+++ b/packages/typescript/ai-react-ui/package.json
@@ -50,7 +50,7 @@
},
"devDependencies": {
"@vitest/coverage-v8": "4.0.14",
- "vite": "^7.2.4"
+ "vite": "^7.2.7"
},
"files": [
"dist"
diff --git a/packages/typescript/ai-react/package.json b/packages/typescript/ai-react/package.json
index 89a3e8ae..04855ee1 100644
--- a/packages/typescript/ai-react/package.json
+++ b/packages/typescript/ai-react/package.json
@@ -49,7 +49,7 @@
"@types/react": "^19.2.7",
"@vitest/coverage-v8": "4.0.14",
"jsdom": "^27.2.0",
- "vite": "^7.2.4",
+ "vite": "^7.2.7",
"zod": "^4.1.13"
},
"peerDependencies": {
diff --git a/packages/typescript/ai-react/src/use-chat.ts b/packages/typescript/ai-react/src/use-chat.ts
index 1064bb52..2f38c14c 100644
--- a/packages/typescript/ai-react/src/use-chat.ts
+++ b/packages/typescript/ai-react/src/use-chat.ts
@@ -80,14 +80,15 @@ export function useChat = any>(
}, []) // Only run on mount - initialMessages are handled by ChatClient constructor
// Cleanup on unmount: stop any in-flight requests
+ // Note: We only cleanup when client changes or component unmounts.
+ // DO NOT include isLoading in dependencies - that would cause the cleanup
+ // to run when isLoading changes, aborting continuation requests.
useEffect(() => {
return () => {
- // Stop any active generation when component unmounts
- if (isLoading) {
- client.stop()
- }
+ // Stop any active generation when component unmounts or client changes
+ client.stop()
}
- }, [client, isLoading])
+ }, [client])
// Note: Callback options (onResponse, onChunk, onFinish, onError, onToolCall)
// are captured at client creation time. Changes to these callbacks require
diff --git a/packages/typescript/ai-solid-ui/package.json b/packages/typescript/ai-solid-ui/package.json
index 8ade3b13..e1b310a1 100644
--- a/packages/typescript/ai-solid-ui/package.json
+++ b/packages/typescript/ai-solid-ui/package.json
@@ -52,7 +52,7 @@
},
"devDependencies": {
"@vitest/coverage-v8": "4.0.14",
- "vite": "^7.2.4"
+ "vite": "^7.2.7"
},
"files": [
"src",
diff --git a/packages/typescript/ai-solid/src/use-chat.ts b/packages/typescript/ai-solid/src/use-chat.ts
index 8dd8ae1a..2a15fb37 100644
--- a/packages/typescript/ai-solid/src/use-chat.ts
+++ b/packages/typescript/ai-solid/src/use-chat.ts
@@ -63,14 +63,14 @@ export function useChat = any>(
}) // Only run on mount - initialMessages are handled by ChatClient constructor
// Cleanup on unmount: stop any in-flight requests
+ // Note: We use createEffect with a cleanup return to handle component unmount.
+ // The cleanup only runs on disposal (unmount), not on signal changes.
createEffect(() => {
return () => {
// Stop any active generation when component unmounts
- if (isLoading()) {
- client().stop()
- }
+ client().stop()
}
- }, [client, isLoading])
+ })
// Note: Callback options (onResponse, onChunk, onFinish, onError, onToolCall)
// are captured at client creation time. Changes to these callbacks require
diff --git a/packages/typescript/ai-svelte/package.json b/packages/typescript/ai-svelte/package.json
index f8292bfb..035631e6 100644
--- a/packages/typescript/ai-svelte/package.json
+++ b/packages/typescript/ai-svelte/package.json
@@ -56,7 +56,7 @@
"svelte": "^5.20.0",
"svelte-check": "^4.2.0",
"typescript": "5.9.3",
- "vite": "^7.2.4",
+ "vite": "^7.2.7",
"zod": "^4.1.13"
},
"peerDependencies": {
diff --git a/packages/typescript/ai-svelte/src/create-chat.svelte.ts b/packages/typescript/ai-svelte/src/create-chat.svelte.ts
index 29d58301..c4081d27 100644
--- a/packages/typescript/ai-svelte/src/create-chat.svelte.ts
+++ b/packages/typescript/ai-svelte/src/create-chat.svelte.ts
@@ -68,6 +68,11 @@ export function createChat = any>(
},
})
+ // Note: Cleanup is handled by calling stop() directly when needed.
+ // Unlike React/Vue/Solid, Svelte 5 runes like $effect can only be used
+ // during component initialization, so we don't add automatic cleanup here.
+ // Users should call chat.stop() in their component's cleanup if needed.
+
// Define methods
const sendMessage = async (content: string) => {
await client.sendMessage(content)
diff --git a/packages/typescript/ai-vue-ui/package.json b/packages/typescript/ai-vue-ui/package.json
index 54835e7f..6c008d97 100644
--- a/packages/typescript/ai-vue-ui/package.json
+++ b/packages/typescript/ai-vue-ui/package.json
@@ -51,7 +51,7 @@
},
"devDependencies": {
"@vitest/coverage-v8": "4.0.14",
- "vite": "^7.2.4",
+ "vite": "^7.2.7",
"vue-tsc": "^2.2.10"
},
"files": [
diff --git a/packages/typescript/ai-vue/src/use-chat.ts b/packages/typescript/ai-vue/src/use-chat.ts
index 946ad4a9..f190d0ee 100644
--- a/packages/typescript/ai-vue/src/use-chat.ts
+++ b/packages/typescript/ai-vue/src/use-chat.ts
@@ -39,10 +39,9 @@ export function useChat = any>(
})
// Cleanup on unmount: stop any in-flight requests
+ // Note: client.stop() is safe to call even if nothing is in progress
onScopeDispose(() => {
- if (isLoading.value) {
- client.stop()
- }
+ client.stop()
})
// Note: Callback options (onResponse, onChunk, onFinish, onError, onToolCall)
diff --git a/packages/typescript/ai/package.json b/packages/typescript/ai/package.json
index c395b371..8895e42f 100644
--- a/packages/typescript/ai/package.json
+++ b/packages/typescript/ai/package.json
@@ -17,6 +17,10 @@
"types": "./dist/esm/index.d.ts",
"import": "./dist/esm/index.js"
},
+ "./adapters": {
+ "types": "./dist/esm/activities/index.d.ts",
+ "import": "./dist/esm/activities/index.js"
+ },
"./event-client": {
"types": "./dist/esm/event-client.d.ts",
"import": "./dist/esm/event-client.js"
diff --git a/packages/typescript/ai/src/activities/embedding/adapter.ts b/packages/typescript/ai/src/activities/embedding/adapter.ts
new file mode 100644
index 00000000..2118b206
--- /dev/null
+++ b/packages/typescript/ai/src/activities/embedding/adapter.ts
@@ -0,0 +1,69 @@
+import type { EmbeddingOptions, EmbeddingResult } from '../../types'
+
+/**
+ * Configuration for embedding adapter instances
+ */
+export interface EmbeddingAdapterConfig {
+ apiKey?: string
+ baseUrl?: string
+ timeout?: number
+ maxRetries?: number
+ headers?: Record
+}
+
+/**
+ * Base interface for embedding adapters.
+ * Provides type-safe embedding generation functionality.
+ *
+ * Generic parameters:
+ * - TModels: Array of supported embedding model names
+ * - TProviderOptions: Provider-specific options for embedding endpoint
+ */
+export interface EmbeddingAdapter<
+ TModels extends ReadonlyArray = ReadonlyArray,
+ TProviderOptions extends object = Record,
+> {
+ /** Discriminator for adapter kind - used by generate() to determine API shape */
+ readonly kind: 'embedding'
+ /** Adapter name identifier */
+ readonly name: string
+ /** Supported embedding models */
+ readonly models: TModels
+
+ // Type-only properties for type inference
+ /** @internal Type-only property for provider options inference */
+ _providerOptions?: TProviderOptions
+
+ /**
+ * Create embeddings for the given input
+ */
+ createEmbeddings: (options: EmbeddingOptions) => Promise
+}
+
+/**
+ * Abstract base class for embedding adapters.
+ * Extend this class to implement an embedding adapter for a specific provider.
+ */
+export abstract class BaseEmbeddingAdapter<
+ TModels extends ReadonlyArray = ReadonlyArray,
+ TProviderOptions extends object = Record,
+> implements EmbeddingAdapter {
+ readonly kind = 'embedding' as const
+ abstract readonly name: string
+ abstract readonly models: TModels
+
+ // Type-only property - never assigned at runtime
+ declare _providerOptions?: TProviderOptions
+
+ protected config: EmbeddingAdapterConfig
+
+ constructor(config: EmbeddingAdapterConfig = {}) {
+ this.config = config
+ }
+
+ abstract createEmbeddings(options: EmbeddingOptions): Promise
+
+ protected generateId(): string {
+ return `${this.name}-${Date.now()}-${Math.random().toString(36).substring(7)}`
+ }
+}
diff --git a/packages/typescript/ai/src/activities/embedding/index.ts b/packages/typescript/ai/src/activities/embedding/index.ts
new file mode 100644
index 00000000..a1e77c45
--- /dev/null
+++ b/packages/typescript/ai/src/activities/embedding/index.ts
@@ -0,0 +1,161 @@
+/**
+ * Embedding Activity
+ *
+ * Generates vector embeddings from text input.
+ * This is a self-contained module with implementation, types, and JSDoc.
+ */
+
+import { aiEventClient } from '../../event-client.js'
+import type { EmbeddingAdapter } from './adapter'
+import type { EmbeddingOptions, EmbeddingResult } from '../../types'
+
+// ===========================
+// Activity Kind
+// ===========================
+
+/** The adapter kind this activity handles */
+export const kind = 'embedding' as const
+
+// ===========================
+// Type Extraction Helpers
+// ===========================
+
+/** Extract model types from an EmbeddingAdapter */
+export type EmbeddingModels =
+ TAdapter extends EmbeddingAdapter ? M[number] : string
+
+/** Extract provider options from an EmbeddingAdapter */
+export type EmbeddingProviderOptions =
+ TAdapter extends EmbeddingAdapter ? P : object
+
+// ===========================
+// Activity Options Type
+// ===========================
+
+/**
+ * Options for the embedding activity.
+ *
+ * @template TAdapter - The embedding adapter type
+ * @template TModel - The model name type (inferred from adapter)
+ */
+export interface EmbeddingActivityOptions<
+ TAdapter extends EmbeddingAdapter, object>,
+ TModel extends EmbeddingModels,
+> {
+ /** The embedding adapter to use */
+ adapter: TAdapter & { kind: typeof kind }
+ /** The model name (autocompletes based on adapter) */
+ model: TModel
+ /** Text input to embed (single string or array of strings) */
+ input: string | Array
+ /** Optional: Number of dimensions for the embedding vector */
+ dimensions?: number
+ /** Provider-specific options */
+ providerOptions?: EmbeddingProviderOptions
+}
+
+// ===========================
+// Activity Result Type
+// ===========================
+
+/** Result type for the embedding activity */
+export type EmbeddingActivityResult = Promise
+
+// ===========================
+// Helper Functions
+// ===========================
+
+function createId(prefix: string): string {
+ return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
+}
+
+// ===========================
+// Activity Implementation
+// ===========================
+
+/**
+ * Embedding activity - generates vector embeddings from text.
+ *
+ * Embeddings are numerical representations of text that capture semantic meaning.
+ * They can be used for similarity search, clustering, classification, and more.
+ *
+ * @example Generate embeddings for a single text
+ * ```ts
+ * import { ai } from '@tanstack/ai'
+ * import { openaiEmbed } from '@tanstack/ai-openai'
+ *
+ * const result = await ai({
+ * adapter: openaiEmbed(),
+ * model: 'text-embedding-3-small',
+ * input: 'Hello, world!'
+ * })
+ *
+ * console.log(result.embeddings[0]) // Array of numbers
+ * ```
+ *
+ * @example Generate embeddings for multiple texts
+ * ```ts
+ * const result = await ai({
+ * adapter: openaiEmbed(),
+ * model: 'text-embedding-3-small',
+ * input: ['Hello', 'World', 'How are you?']
+ * })
+ *
+ * // result.embeddings is an array of embedding vectors
+ * result.embeddings.forEach((embedding, i) => {
+ * console.log(`Text ${i}: ${embedding.length} dimensions`)
+ * })
+ * ```
+ *
+ * @example Specify embedding dimensions
+ * ```ts
+ * const result = await ai({
+ * adapter: openaiEmbed(),
+ * model: 'text-embedding-3-small',
+ * input: 'Hello, world!',
+ * dimensions: 256 // Reduce to 256 dimensions
+ * })
+ * ```
+ */
+export async function embeddingActivity<
+ TAdapter extends EmbeddingAdapter, object>,
+ TModel extends EmbeddingModels,
+>(
+ options: EmbeddingActivityOptions,
+): EmbeddingActivityResult {
+ const { adapter, model, input, dimensions } = options
+ const requestId = createId('embedding')
+ const inputCount = Array.isArray(input) ? input.length : 1
+ const startTime = Date.now()
+
+ aiEventClient.emit('embedding:started', {
+ requestId,
+ model: model as string,
+ inputCount,
+ timestamp: startTime,
+ })
+
+ const embeddingOptions: EmbeddingOptions = {
+ model: model as string,
+ input,
+ dimensions,
+ }
+
+ const result = await adapter.createEmbeddings(embeddingOptions)
+
+ const duration = Date.now() - startTime
+
+ aiEventClient.emit('embedding:completed', {
+ requestId,
+ model: model as string,
+ inputCount,
+ duration,
+ timestamp: Date.now(),
+ })
+
+ return result
+}
+
+// Re-export adapter types
+export type { EmbeddingAdapter, EmbeddingAdapterConfig } from './adapter'
+export { BaseEmbeddingAdapter } from './adapter'
diff --git a/packages/typescript/ai/src/activities/image/adapter.ts b/packages/typescript/ai/src/activities/image/adapter.ts
new file mode 100644
index 00000000..d4e91dae
--- /dev/null
+++ b/packages/typescript/ai/src/activities/image/adapter.ts
@@ -0,0 +1,91 @@
+import type { ImageGenerationOptions, ImageGenerationResult } from '../../types'
+
+/**
+ * Configuration for image adapter instances
+ */
+export interface ImageAdapterConfig {
+ apiKey?: string
+ baseUrl?: string
+ timeout?: number
+ maxRetries?: number
+ headers?: Record
+}
+
+/**
+ * Base interface for image generation adapters.
+ * Provides type-safe image generation functionality with support for
+ * model-specific provider options.
+ *
+ * Generic parameters:
+ * - TModels: Array of supported image model names
+ * - TProviderOptions: Base provider-specific options for image generation
+ * - TModelProviderOptionsByName: Map from model name to its specific provider options
+ * - TModelSizeByName: Map from model name to its supported sizes
+ */
+export interface ImageAdapter<
+ TModels extends ReadonlyArray = ReadonlyArray,
+ TProviderOptions extends object = Record,
+ TModelProviderOptionsByName extends Record = Record,
+ TModelSizeByName extends Record = Record,
+> {
+ /** Discriminator for adapter kind - used by generate() to determine API shape */
+ readonly kind: 'image'
+ /** Adapter name identifier */
+ readonly name: string
+ /** Supported image generation models */
+ readonly models: TModels
+
+ // Type-only properties for type inference
+ /** @internal Type-only property for provider options inference */
+ _providerOptions?: TProviderOptions
+ /** @internal Type-only map from model name to its specific provider options */
+ _modelProviderOptionsByName?: TModelProviderOptionsByName
+ /** @internal Type-only map from model name to its supported sizes */
+ _modelSizeByName?: TModelSizeByName
+
+ /**
+ * Generate images from a prompt
+ */
+ generateImages: (
+ options: ImageGenerationOptions,
+ ) => Promise
+}
+
+/**
+ * Abstract base class for image generation adapters.
+ * Extend this class to implement an image adapter for a specific provider.
+ */
+export abstract class BaseImageAdapter<
+ TModels extends ReadonlyArray = ReadonlyArray,
+ TProviderOptions extends object = Record,
+ TModelProviderOptionsByName extends Record = Record,
+ TModelSizeByName extends Record = Record,
+> implements ImageAdapter<
+ TModels,
+ TProviderOptions,
+ TModelProviderOptionsByName,
+ TModelSizeByName
+> {
+ readonly kind = 'image' as const
+ abstract readonly name: string
+ abstract readonly models: TModels
+
+ // Type-only properties - never assigned at runtime
+ declare _providerOptions?: TProviderOptions
+ declare _modelProviderOptionsByName?: TModelProviderOptionsByName
+ declare _modelSizeByName?: TModelSizeByName
+
+ protected config: ImageAdapterConfig
+
+ constructor(config: ImageAdapterConfig = {}) {
+ this.config = config
+ }
+
+ abstract generateImages(
+ options: ImageGenerationOptions,
+ ): Promise
+
+ protected generateId(): string {
+ return `${this.name}-${Date.now()}-${Math.random().toString(36).substring(7)}`
+ }
+}
diff --git a/packages/typescript/ai/src/activities/image/index.ts b/packages/typescript/ai/src/activities/image/index.ts
new file mode 100644
index 00000000..6248dc20
--- /dev/null
+++ b/packages/typescript/ai/src/activities/image/index.ts
@@ -0,0 +1,155 @@
+/**
+ * Image Activity
+ *
+ * Generates images from text prompts.
+ * This is a self-contained module with implementation, types, and JSDoc.
+ */
+
+import type { ImageAdapter } from './adapter'
+import type { ImageGenerationResult } from '../../types'
+
+// ===========================
+// Activity Kind
+// ===========================
+
+/** The adapter kind this activity handles */
+export const kind = 'image' as const
+
+// ===========================
+// Type Extraction Helpers
+// ===========================
+
+/** Extract model types from an ImageAdapter */
+export type ImageModels =
+ TAdapter extends ImageAdapter ? M[number] : string
+
+/**
+ * Extract model-specific provider options from an ImageAdapter.
+ * If the model has specific options defined in ModelProviderOptions (and not just via index signature),
+ * use those; otherwise fall back to base provider options.
+ */
+export type ImageProviderOptionsForModel =
+ TAdapter extends ImageAdapter
+ ? string extends keyof ModelOptions
+ ? // ModelOptions is Record or has index signature - use BaseOptions
+ BaseOptions
+ : // ModelOptions has explicit keys - check if TModel is one of them
+ TModel extends keyof ModelOptions
+ ? ModelOptions[TModel]
+ : BaseOptions
+ : object
+
+/**
+ * Extract model-specific size options from an ImageAdapter.
+ * If the model has specific sizes defined, use those; otherwise fall back to string.
+ */
+export type ImageSizeForModel =
+ TAdapter extends ImageAdapter
+ ? string extends keyof SizeByName
+ ? // SizeByName has index signature - fall back to string
+ string
+ : // SizeByName has explicit keys - check if TModel is one of them
+ TModel extends keyof SizeByName
+ ? SizeByName[TModel]
+ : string
+ : string
+
+// ===========================
+// Activity Options Type
+// ===========================
+
+/**
+ * Options for the image activity.
+ *
+ * @template TAdapter - The image adapter type
+ * @template TModel - The model name type (inferred from adapter)
+ */
+export interface ImageActivityOptions<
+ TAdapter extends ImageAdapter, object, any, any>,
+ TModel extends ImageModels,
+> {
+ /** The image adapter to use */
+ adapter: TAdapter & { kind: typeof kind }
+ /** The model name (autocompletes based on adapter) */
+ model: TModel
+ /** Text description of the desired image(s) */
+ prompt: string
+ /** Number of images to generate (default: 1) */
+ numberOfImages?: number
+ /** Image size in WIDTHxHEIGHT format (e.g., "1024x1024") */
+ size?: ImageSizeForModel