
Ai Sdk Agenticloop
- 1 installs
- Updated April 10, 2026
- hamstudy/create-agent-skills
Guides building a provider-agnostic agent with the Vercel AI SDK using ToolLoopAgent for automatic tool-execution loops across 15+ providers.
About
Teaches how to build agents that think, call tools, and loop using the Vercel AI SDK's ToolLoopAgent, with a provider registry, message normalization, and OAuth or API-key auth. A developer uses it to create tool-using agent systems that switch between OpenAI, Anthropic, and other providers without vendor lock-in.
- ToolLoopAgent handles tool loops without manual maxSteps management
- Provider registry plus quirk handling for 15+ providers
Ai Sdk Agenticloop by the numbers
- 1 all-time installs (skills.sh)
- Ranked #14,098 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 8, 2026 (Skillselion catalog sync)
npx skills add https://github.com/hamstudy/create-agent-skills --skill ai-sdk-agenticloopAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| Last updated | April 10, 2026 |
| Repository | hamstudy/create-agent-skills ↗ |
What it does
Guides building a provider-agnostic agent with the Vercel AI SDK using ToolLoopAgent for automatic tool-execution loops across 15+ providers.
Files
AI SDK Agentic Loop
Build AI agents that can use tools (search files, call APIs, run code) and work with any AI provider (OpenAI, Anthropic, Google, etc.) using the Vercel AI SDK.
What You Will Build
An agent is an AI that can:
1. Think - Process your request using an LLM 2. Act - Call tools (functions) to gather information or make changes 3. Loop - Use tool results to think again, repeating until done
Example conversation:
User: "Fix the bug in my code"
Agent (thinks): I need to find and read the code first
Agent (acts): Calls searchFiles tool → finds "src/utils.ts"
Agent (acts): Calls readFile tool → reads the file
Agent (thinks): I see the bug. Let me fix it.
Agent (acts): Calls writeFile tool → fixes the bug
Agent (done): "Fixed! The bug was on line 23..."This guide shows you how to build agents that:
- Use `ToolLoopAgent` for automatic tool loop handling (no manual maxSteps management)
- Work with any AI provider (switch from OpenAI to Anthropic by changing one string)
- Handle provider quirks automatically (message formatting, tool ID sanitization)
- Support OAuth and API keys with automatic token refresh
- Scale from simple scripts to production systems
---
Prerequisites
Required Knowledge:
- Basic TypeScript/JavaScript
- Node.js fundamentals
- What an API key is
Required Tools:
- Node.js 18+ or Bun
- An API key from at least one provider (OpenAI, Anthropic, etc.)
Install Dependencies:
npm install ai @ai-sdk/openai zod
# Or if using Bun:
bun add ai @ai-sdk/openai zodCost Warning: ⚠️ Running agents costs money. Each "step" in the conversation calls the AI provider's API. A 10-step conversation with GPT-4 might cost $0.05-$0.50. Start with cheaper models like gpt-5.4-mini while learning.
---
Hello World: Your First Agent
Create a file called agent.ts:
import { ToolLoopAgent, tool } from "ai";
import { z } from "zod";
import "dotenv/config"; // Loads API keys from .env file
// 1. Define a tool (a function the AI can call)
const calculator = tool({
description: "Add two numbers",
inputSchema: z.object({
a: z.number(),
b: z.number(),
}),
execute: async ({ a, b }) => {
console.log(`[Tool called] Adding ${a} + ${b}`);
return { result: a + b };
},
});
// 2. Create the agent with ToolLoopAgent
const agent = new ToolLoopAgent({
model: "openai/gpt-5.4-mini", // Cheap model for testing
instructions: "You are a helpful assistant that can use tools to help users.",
tools: { calculator },
});
// 3. Run the agent
async function main() {
const result = await agent.run("What is 123 + 456?");
console.log(result.text);
}
main();Set up your API key:
# Create .env file
echo "OPENAI_API_KEY=sk-your-key-here" > .envRun it:
npx tsx agent.ts
# Or with Bun:
bun run agent.tsExpected output:
[Tool called] Adding 123 + 456
The result is 579.What Just Happened?
1. ToolLoopAgent - Created an agent that automatically handles the tool-calling loop 2. AI decided to use tool - GPT-4 recognized this was a math problem 3. Tool executed - Your calculator function ran with {a: 123, b: 456} 4. AI responded - Used the tool result to answer your question 5. Automatic loop handling - ToolLoopAgent manages the conversation flow, not maxSteps
Key Concepts Explained
What is `ToolLoopAgent`? A class from the AI SDK that:
- Manages the entire agent loop automatically (no manual step counting)
- Handles conversation state between tool calls
- Works with any AI provider through a unified interface
- Provides type safety for tools and responses
What are `tools`? Functions you define that the AI can call. Each tool has:
description- Tells the AI when to use itinputSchema- Zod schema defining what arguments the AI should passexecute- Your code that runs when the AI calls the tool
Why ToolLoopAgent instead of streamText with maxSteps?
| Feature | streamText + maxSteps | ToolLoopAgent |
|---|---|---|
| Loop management | Manual (you track steps) | Automatic (built-in) |
| Provider syntax | openai("gpt-4") function call | "openai/gpt-4" string |
| Tool execution | Inferred from response | Native support |
| State management | You handle messages | Agent manages state |
| Type safety | Limited | Full inference with InferAgentUIMessage |
Recommendation: Use ToolLoopAgent for all new agent development. It's the modern, recommended approach.
---
Core Concepts Glossary
Before diving deeper, understand these terms:
| Term | Definition | Example |
|---|---|---|
| ToolLoopAgent | AI SDK class for automatic agent loops | new ToolLoopAgent({ model, tools }) |
| Provider | Company that hosts AI models | OpenAI, Anthropic, Google |
| Model | Specific AI version | GPT-4, Claude 3, Gemini |
| Agent | AI + Tools + Loop | Your ToolLoopAgent instance |
| Tool | Function the AI can call | searchFiles, readFile |
| Tool Call | When AI decides to use a tool | AI sends {tool: "readFile", args: {path: "x"}} |
| Streaming | Getting response word-by-word | for await (const chunk of stream) |
| Blocking | Waiting for complete response | await agent.run() |
| Provider-Agnostic | Works with any provider | Switch OpenAI → Anthropic easily |
| Transform | Modifying messages for a provider | Fixing tool IDs for Mistral |
| Registry | Map of available providers | providers.get("openai") |
---
Why Build a Provider Registry?
You might wonder: _"Why not just hardcode the model string?"_
Without a registry:
// Tightly coupled to one provider
const agent = new ToolLoopAgent({
model: "openai/gpt-5.4",
tools,
});Problems:
- Hard to switch providers (find/replace across codebase)
- Can't fallback if OpenAI is down
- Provider quirks handled inline (messy)
- No centralized config
With a registry:
// Switch providers by changing one string
const agent = createAgent("openai", "gpt-5.4");
// const agent = createAgent("anthropic", "claude-3-sonnet");
// Automatic fallback
const agent = createAgentWithFallback(["openai", "anthropic"]);
// Quirks handled automatically
const agent = createAgent("mistral", "large"); // Tool IDs auto-sanitizedWhen you DON'T need a registry:
- Simple script using one provider
- Prototype/MVP
- You know you'll never switch providers
When you DO need a registry:
- Production system requiring reliability (fallbacks)
- Multiple providers for different use cases
- Team working on same codebase (centralized config)
- Testing with cheap models, deploying with expensive ones
---
Documentation Structure
This skill is organized by complexity:
Level 1: Just Getting Started
- You're here (Hello World above)
- Troubleshooting Common Errors
Level 2: Building Your First Agent
- Provider Registry Guide - Set up multi-provider support
- Complete Agent Example - Working code combining all patterns
Level 3: Production-Ready
- Authentication System - OAuth, API keys, secure storage
- Message Transforms - Handle provider quirks
- Architecture Decisions - Why these patterns work
- Troubleshooting - Debugging production issues
Level 4: Reference
- Provider Matrix - Capabilities comparison
- Examples Directory - More working code
Recommended path:
1. Run the Hello World above ☝️ 2. Read Complete Agent Example 3. Build something simple 4. Add authentication when ready 5. Add transforms when you hit provider quirks
---
Common Errors (And How to Fix Them)
"Cannot find module 'ai'"
Cause: Dependencies not installed Fix:
npm install ai @ai-sdk/openai zod"API key required"
Cause: OPENAI_API_KEY not set Fix:
# Create .env file
echo "OPENAI_API_KEY=sk-..." > .env
# Or export directly
export OPENAI_API_KEY=sk-..."Rate limit exceeded"
Cause: Too many requests to provider Fix: Add retry logic (see troubleshooting.md)
"Tool call failed - invalid parameters"
Cause: AI sent wrong arguments to your tool Fix: Check your Zod schema - make descriptions clearer
"Context length exceeded"
Cause: Conversation too long for model Fix: Summarize conversation periodically (see troubleshooting.md)
---
Environment Setup Checklist
Before building production agents:
- [ ] Create `.env` file with API keys
- [ ] Add `.env` to `.gitignore` (never commit keys!)
- [ ] Install `dotenv` for loading env vars
- [ ] Set up TypeScript (
tsconfig.json) - [ ] Choose primary provider (start with one)
- [ ] Test with cheap model (gpt-5.4-mini, claude-3-haiku)
- [ ] Budget monitoring (track API costs)
---
Key Design Decisions
1. Why ToolLoopAgent instead of streamText?
ToolLoopAgent (modern approach):
- ✅ Automatic loop management (no maxSteps needed)
- ✅ Cleaner API (model as string, not function call)
- ✅ Better type safety with
InferAgentUIMessage - ✅ Native tool execution support
- ✅ Simpler mental model
streamText (legacy approach):
- ❌ Manual step tracking with maxSteps
- ❌ More verbose syntax
- ❌ Limited type inference
- ❌ You manage conversation state
Recommendation: Use ToolLoopAgent for all new development.
2. Why Provider-Agnostic?
Scenario: You build on OpenAI, hit rate limits during a product launch.
Without registry: Scramble to rewrite code for Anthropic. Downtime: hours.
With registry: Change one string: createAgent("openai", ...) → createAgent("anthropic", ...). Downtime: seconds.
3. Why Not Just Use a Framework?
Frameworks like LangChain, LlamaIndex exist. This skill teaches the underlying patterns so you:
- Understand what's happening
- Can customize when frameworks don't fit
- Aren't locked into framework updates
---
Next Steps
New to agents?
1. Modify the Hello World above to add more tools 2. Read Complete Agent Example 3. Build a simple file-search agent
Building production system?
1. Read Architecture Decisions 2. Set up Provider Registry 3. Add Authentication 4. Review Provider Quirks
Having issues? → See Troubleshooting
---
Quick Reference
Install providers:
npm install @ai-sdk/openai @ai-sdk/anthropic @ai-sdk/googleBasic agent structure:
import { ToolLoopAgent, tool } from "ai";
import { z } from "zod";
const myTool = tool({
description: "What this tool does",
inputSchema: z.object({ param: z.string() }),
execute: async ({ param }) => ({ result: "..." }),
});
const agent = new ToolLoopAgent({
model: "openai/gpt-5.4-mini",
instructions: "You are a helpful assistant.",
tools: { myTool },
});
const result = await agent.run("Do something");Switch providers:
// OpenAI
const agent = new ToolLoopAgent({
model: "openai/gpt-5.4",
tools,
});
// Anthropic
const agent = new ToolLoopAgent({
model: "anthropic/claude-3-sonnet",
tools,
});
// Same code works for both!
const result = await agent.run("...");Type-safe UI with React:
import { InferAgentUIMessage } from "ai";
import { useChat } from "@ai-sdk/react";
// Infer types from your agent
export type MyAgentUIMessage = InferAgentUIMessage<typeof agent>;
// Use in your React component
const { messages } = useChat<MyAgentUIMessage>();Architecture Decisions
Why we built it this way and when to use each pattern.
---
The Core Problem
You're building an AI agent. You start simple:
import { openai } from "@ai-sdk/openai"
const result = await generateText({
model: openai("gpt-4"),
messages: [{ role: "user", content: "Hello" }],
})This works! But then requirements grow:
- "Can we use Claude instead?"
- "We need to search files"
- "It's too slow, add caching"
- "OpenAI is down, we need a fallback"
- "Different models need different settings"
Each requirement adds complexity. Without structure, you end up with spaghetti code.
This guide explains the patterns that keep your code organized as it grows.
---
Pattern 1: Provider Abstraction
The Problem
Different AI providers have different APIs:
// OpenAI
import { createOpenAI } from "@ai-sdk/openai"
const openai = createOpenAI({ apiKey })
// Anthropic
import { createAnthropic } from "@ai-sdk/anthropic"
const anthropic = createAnthropic({ apiKey })
// Your code now has provider-specific calls everywhere
if (provider === "openai") {
model = openai.languageModel(modelId)
} else if (provider === "anthropic") {
model = anthropic.languageModel(modelId)
}
// ... repeat for every providerThe Solution
Create a unified interface:
// All providers implement this interface
interface ProviderAdapter {
languageModel(modelId: string): LanguageModel
}
// Usage - works for any provider
const model = registry.getModel("openai", "gpt-4")
const model = registry.getModel("anthropic", "claude-3")When to Use
Use this when:
- You need to switch providers (dev vs prod, fallback, etc.)
- Multiple team members use different providers
- You want to test with cheap models, deploy with expensive ones
Skip this when:
- Simple script using one provider
- Prototype/MVP
- You're certain you'll never switch
The Trade-off
With abstraction:
- ✅ Switch providers by changing one string
- ✅ Test with GPT-4, deploy with Claude
- ✅ Centralized configuration
- ❌ Extra code to maintain
- ❌ Learning curve for new devs
Without abstraction:
- ✅ Less code
- ✅ Direct access to provider features
- ❌ Switching providers requires find/replace
- ❌ Configuration scattered
---
Pattern 2: Transform Pipeline
The Problem
Different providers need different message formats:
| Provider | Tool ID Format | Empty Messages | Sequence Rules |
|---|---|---|---|
| OpenAI | Any string | Allowed | Flexible |
| Anthropic | Alphanumeric only | Rejected | Flexible |
| Mistral | 9 chars, alphanumeric | Allowed | tool→user invalid |
Without handling this, code works with one provider but fails with another.
The Solution
Transform messages before sending:
Raw Messages
↓
Filter Modalities (remove unsupported content)
↓
Normalize for Provider (fix IDs, sequences)
↓
Apply Caching (if supported)
↓
Final Messages → ProviderWhen to Use
Use this when:
- Supporting 3+ providers
- Hitting provider-specific errors
- Need caching/optimization
Skip this when:
- Only 1-2 providers
- You're okay handling errors as they come
Real Example
Without transforms (broken with Anthropic):
const messages = [
{ role: "user", content: "Hi" },
{ role: "assistant", content: "" }, // Empty!
]
// Anthropic: "Error: messages must have non-empty content"With transforms (works everywhere):
const normalized = normalizeMessages(messages, "anthropic")
// Empty messages filtered out
// Works with all providers ✓---
Pattern 3: Tool Abstraction
The Problem
Tools need:
- Description (tells AI when to use them)
- Parameters (schema for arguments)
- Execution (your code)
Without structure, tools are inconsistent:
// Tool 1 - simple
function search(query) { return results }
// Tool 2 - different pattern
const readFile = {
execute: (path) => { return content },
schema: { path: "string" }
}
// Tool 3 - yet another pattern
async function write(path, content) { ... }The Solution
Standardize with the tool() helper:
const myTool = tool({
description: "What this tool does",
parameters: z.object({ ... }), // Zod schema
execute: async (args) => {
// Your implementation
return result
},
})Benefits
- Type safety: Zod validates arguments automatically
- Documentation: Description tells AI when to use it
- Consistency: All tools follow same pattern
- Testing: Easy to test execute() function in isolation
---
Pattern 4: Authentication Abstraction
The Problem
Different auth methods:
- API Key: Simple string
- OAuth: Token + refresh token + expiration
- AWS: Access key + secret key + region
Without abstraction, auth logic is scattered:
// OpenAI - simple
headers["Authorization"] = `Bearer ${apiKey}`
// OAuth - complex
if (token.expiresAt < Date.now()) {
token = await refreshToken(token)
}
headers["Authorization"] = `Bearer ${token.accessToken}`
// AWS - totally different
const signature = createAwsSignature(credentials)
headers["Authorization"] = signatureThe Solution
Unified auth interface:
interface AuthCredentials {
type: "api" | "oauth" | "aws"
// Different fields based on type
}
// Usage - auth manager handles details
const auth = await authManager.getCredentials("openai")
const headers = authManager.createHeaders(auth)When to Use
Use this when:
- Mixing API keys and OAuth
- Need automatic token refresh
- Multiple auth providers
Skip this when:
- Only API keys
- Simple scripts
---
Pattern 5: Streaming vs Blocking
The Problem
Do you wait for the full response or show it as it comes?
Blocking:
const result = await generateText({ model, messages })
console.log(result.text) // Full response at onceStreaming:
const result = await streamText({ model, messages })
for await (const chunk of result.textStream) {
process.stdout.write(chunk) // Word by word
}When to Use Each
Blocking (generateText):
- ✅ Simpler code
- ✅ Access full metadata (tokens used, finish reason)
- ✅ Easier error handling
- ❌ Must wait for complete response
Use for: Batch processing, scripts, APIs
Streaming (streamText):
- ✅ Lower latency (see first word immediately)
- ✅ Better UX (feels faster)
- ✅ Can cancel mid-generation
- ❌ More complex code
- ❌ Harder to get metadata
Use for: Chat interfaces, interactive apps
Hybrid Approach
You can support both:
async function runAgent(prompt: string, options: { stream?: boolean }) {
const model = getModel()
if (options.stream) {
const result = await streamText({ model, messages })
for await (const chunk of result.textStream) {
yield chunk
}
} else {
const result = await generateText({ model, messages })
return result.text
}
}---
Pattern 6: Caching
The Problem
Repeated calls with same context waste money:
// Each call sends full system prompt - $$$ adds up
await callModel([systemPrompt, ...context])
await callModel([systemPrompt, ...context])
await callModel([systemPrompt, ...context])
// Pay for systemPrompt 3 timesThe Solution
Mark messages for caching (Anthropic/Bedrock/OpenRouter):
const cached = applyCaching(messages, "anthropic")
await callModel(cached) // System prompt cached
await callModel(cached) // Reuse cache (cheaper!)
await callModel(cached) // Reuse cache (cheaper!)When to Use
Use this when:
- Large system prompts
- Repeated similar queries
- Using Anthropic/Bedrock/OpenRouter
Skip this when:
- Short conversations
- One-off queries
- Provider doesn't support caching
---
Scaling Patterns
Small Project (1-2 providers)
// Simple is fine
import { openai } from "@ai-sdk/openai"
const model = openai("gpt-4")
const result = await generateText({ model, messages })Medium Project (3-5 providers)
Add registry and basic transforms:
- Provider registry
- Simple message normalization
- Tool abstraction
Large Project (Production)
Full architecture:
- Provider registry with caching
- Transform pipeline
- Auth abstraction with auto-refresh
- Error handling and retries
- Metrics and monitoring
- Rate limiting
When to Add Complexity
Start simple, add patterns when you feel pain:
1. Pain: "Switching providers requires find/replace" Solution: Add provider registry
2. Pain: "Anthropic fails with my messages" Solution: Add transforms
3. Pain: "OAuth tokens keep expiring" Solution: Add auth manager
4. Pain: "Same system prompt sent repeatedly" Solution: Add caching
Don't add patterns before you need them.
---
Anti-Patterns to Avoid
1. Premature Abstraction
Bad: Building full registry for a one-off script
Better: Start simple, abstract when you have 2+ providers
2. Ignoring Provider Quirks
Bad: "I'll deal with errors if they happen"
Better: Handle quirks proactively (see provider-transforms.md)
3. Hardcoding Model IDs
Bad: if (modelId === "gpt-4") { ... }
Better: Check capabilities, not model names
4. No Error Handling
Bad: await streamText({ model, messages })
Better: Wrap in try/catch, handle rate limits
5. Leaking API Keys
Bad: Committing .env file
Better: Add .env to .gitignore, use env vars
---
Decision Framework
Building an agent?
│
├─ Only 1 provider?
│ └─ Use SDK directly (simplest)
│
├─ Might switch providers?
│ └─ Add provider registry
│
├─ Supporting 3+ providers?
│ └─ Add transform pipeline
│
├─ Mixing API keys and OAuth?
│ └─ Add auth abstraction
│
├─ Large system prompts?
│ └─ Add caching
│
└─ Production system?
└─ Add all patterns + monitoring---
Common Questions
Q: Should I use a framework instead?
Frameworks like LangChain exist. This skill teaches the underlying patterns so you:
- Understand what's happening
- Can customize when frameworks don't fit
- Aren't locked into framework updates
Q: How do I test this?
See complete-agent.ts for testable patterns:
- Tools are pure functions (easy to unit test)
- Provider adapters are swappable (use mock in tests)
- Registry pattern allows dependency injection
Q: What's the performance cost?
Minimal:
- Registry: One Map lookup
- Transforms: O(n) where n = message count
- Caching: Saves money (slight latency to check cache)
Q: Can I use this with [specific provider]?
If Vercel AI SDK supports it, yes. Check provider-matrix.md for tested providers.
---
Summary
| Pattern | Use When | Skip When |
|---|---|---|
| Provider Registry | 2+ providers, need fallback | 1 provider, simple script |
| Transform Pipeline | 3+ providers, hitting errors | 1-2 providers |
| Auth Abstraction | Mixing auth types | Only API keys |
| Caching | Large prompts, repeated calls | Short conversations |
| Streaming | Interactive apps, chat | Batch processing, scripts |
Golden rule: Start simple. Add patterns when you feel pain, not before.
Authentication System
Implement secure authentication for API keys, OAuth, and custom auth methods.
Auth Types
interface ApiAuth {
type: "api";
apiKey: string;
}
interface OAuthAuth {
type: "oauth";
accessToken: string;
refreshToken: string;
expiresAt: number; // Unix timestamp
}
interface CustomAuth {
type: "custom";
data: Record<string, any>;
}
type AuthCredentials = ApiAuth | OAuthAuth | CustomAuth;
interface AuthStore {
get(providerId: string): Promise<AuthCredentials | null>;
set(providerId: string, credentials: AuthCredentials): Promise<void>;
delete(providerId: string): Promise<void>;
list(): Promise<string[]>;
}File-Based Auth Store
import { readFile, writeFile, mkdir } from "fs/promises";
import { homedir } from "os";
import { join } from "path";
class FileAuthStore implements AuthStore {
private filePath: string;
constructor(filePath?: string) {
this.filePath = filePath || join(homedir(), ".ai-agents", "auth.json");
}
private async ensureDir() {
const dir = this.filePath.substring(0, this.filePath.lastIndexOf("/"));
await mkdir(dir, { recursive: true });
}
private async readData(): Promise<Record<string, any>> {
try {
const content = await readFile(this.filePath, "utf-8");
return JSON.parse(content);
} catch {
return {};
}
}
private async writeData(data: Record<string, any>) {
await this.ensureDir();
await writeFile(this.filePath, JSON.stringify(data, null, 2));
// Secure file permissions
await chmod(this.filePath, 0o600);
}
async get(providerId: string): Promise<AuthCredentials | null> {
const data = await this.readData();
const auth = data[providerId];
if (!auth) return null;
// Check if OAuth token needs refresh
if (auth.type === "oauth" && auth.expiresAt < Date.now() / 1000) {
return this.refreshOAuth(providerId, auth);
}
return auth;
}
async set(providerId: string, credentials: AuthCredentials) {
const data = await this.readData();
data[providerId] = credentials;
await this.writeData(data);
}
async delete(providerId: string) {
const data = await this.readData();
delete data[providerId];
await this.writeData(data);
}
async list(): Promise<string[]> {
const data = await this.readData();
return Object.keys(data);
}
}OAuth Manager
import { createHash, randomBytes } from "crypto";
interface OAuthConfig {
clientId: string;
clientSecret: string;
redirectUri: string;
tokenUrl: string;
authUrl: string;
}
class OAuthManager {
private codeVerifier?: string;
constructor(private config: OAuthConfig) {}
generateAuthUrl(scopes: string[]): string {
this.codeVerifier = this.generateCodeVerifier();
const challenge = this.generateCodeChallenge();
const params = new URLSearchParams();
params.append("client_id", this.config.clientId);
params.append("redirect_uri", this.config.redirectUri);
params.append("response_type", "code");
params.append("scope", scopes.join(" "));
params.append("code_challenge", challenge);
params.append("code_challenge_method", "S256");
return `${this.config.authUrl}?${params.toString()}`;
}
private generateCodeChallenge(): string {
return createHash("sha256").update(this.codeVerifier!).digest("base64url");
}
private generateCodeVerifier(): string {
return randomBytes(16).toString("hex");
}
async exchangeCodeForToken(code: string): Promise<OAuthAuth> {
const body = new URLSearchParams();
body.append("grant_type", "authorization_code");
body.append("code", code);
body.append("client_id", this.config.clientId);
body.append("client_secret", this.config.clientSecret);
body.append("redirect_uri", this.config.redirectUri);
body.append("code_verifier", this.codeVerifier!);
const response = await fetch(this.config.tokenUrl, {
method: "POST",
body: body.toString(),
headers: { "Content-Type": "application/x-www-form-urlencoded" },
});
const data = (await response.json()) as any;
return {
type: "oauth",
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresAt: Math.floor(Date.now() / 1000) + data.expires_in,
};
}
async refreshToken(auth: OAuthAuth): Promise<OAuthAuth> {
const body = new URLSearchParams();
body.append("grant_type", "refresh_token");
body.append("refresh_token", auth.refreshToken);
body.append("client_id", this.config.clientId);
body.append("client_secret", this.config.clientSecret);
const response = await fetch(this.config.tokenUrl, {
method: "POST",
body: body.toString(),
headers: { "Content-Type": "application/x-www-form-urlencoded" },
});
const data = (await response.json()) as any;
return {
type: "oauth",
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresAt: Math.floor(Date.now() / 1000) + data.expires_in,
};
}
}Environment Variable Setup
// Required environment variables for different providers:
// OpenAI
process.env.OPENAI_API_KEY = "sk-...";
// Anthropic
process.env.ANTHROPIC_API_KEY = "sk-ant-...";
// Google Gemini
process.env.GOOGLE_API_KEY = "AIzaSy...";
// Azure
process.env.AZURE_API_KEY = "...";
process.env.AZURE_RESOURCE_NAME = "...";
// AWS Bedrock
process.env.AWS_ACCESS_KEY_ID = "...";
process.env.AWS_SECRET_ACCESS_KEY = "...";
process.env.AWS_REGION = "us-west-2";
// Mistral
process.env.MISTRAL_API_KEY = "...";
// Groq
process.env.GROQ_API_KEY = "...";
// For OAuth flows, also set:
process.env.AUTH_STORE_PATH = homedir() + "/.config/ai-agents/auth.json";Usage Example
import { FileAuthStore } from "./auth-store";
import { OAuthManager } from "./oauth-manager";
async function setupAuth() {
const store = new FileAuthStore();
// Setup API key auth
await store.set("openai", {
type: "api",
apiKey: process.env.OPENAI_API_KEY!,
});
// Setup OAuth
const oauthMgr = new OAuthManager({
clientId: "...",
clientSecret: "...",
redirectUri: "http://localhost:3000/callback",
tokenUrl: "https://provider.com/token",
authUrl: "https://provider.com/authorize",
});
const authUrl = oauthMgr.generateAuthUrl(["scope1", "scope2"]);
console.log("Visit:", authUrl);
// After user redirects back with code:
const token = await oauthMgr.exchangeCodeForToken(code);
await store.set("provider", token);
// Retrieve and use stored credentials
const creds = await store.get("openai");
if (creds?.type === "api") {
console.log("Using API key:", creds.apiKey.slice(0, 10) + "...");
}
}
setupAuth().catch(console.error);/**
* COMPLETE AGENT SYSTEM EXAMPLE WITH TOOLOOPAGENT
*
* This file demonstrates a production-ready agent system with:
* - ToolLoopAgent for automatic tool loop management (modern approach)
* - Multi-provider support (OpenAI, Anthropic, etc.)
* - Tool calling (search, read, write files)
* - Provider registry pattern
* - Type-safe agent definitions
*
* PREREQUISITES:
* 1. Create a .env file with:
* OPENAI_API_KEY=sk-your-key-here
* ANTHROPIC_API_KEY=sk-ant-your-key-here
*
* 2. Install dependencies:
* npm install ai @ai-sdk/openai @ai-sdk/anthropic zod dotenv
*
* 3. Run with:
* npx tsx complete-agent.ts
*
* COST WARNING: This example makes real API calls that cost money.
* Start with cheap models (gpt-5.4-mini, claude-haiku) while testing.
*/
import { ToolLoopAgent, tool } from "ai";
import { z } from "zod";
import "dotenv/config"; // Automatically loads .env file
// =============================================================================
// SECTION 1: TYPE DEFINITIONS
// =============================================================================
/**
* ModelInfo describes what a model can do.
* This lets us check capabilities before using features.
*/
interface ModelInfo {
id: string; // Model ID like "gpt-5.4" or "claude-3-sonnet"
provider: string; // Provider ID like "openai" or "anthropic"
capabilities: {
input: string[]; // What inputs it accepts: ["text", "image", "audio", "pdf"]
output: string[]; // What outputs it produces: ["text", "image"]
tools: boolean; // Can it call tools?
reasoning: boolean; // Does it support reasoning mode?
};
}
// =============================================================================
// SECTION 2: TOOL DEFINITIONS
// =============================================================================
/**
* Tools are functions the AI can call to interact with the world.
*
* Each tool has:
* - description: Tells the AI when to use this tool
* - inputSchema: Zod schema defining what arguments to pass
* - execute: Your code that runs when AI calls the tool
*/
const tools = {
/**
* Search for files matching a pattern.
* AI uses this when user asks to find files.
*/
search: tool({
description: "Search for files matching a glob pattern (e.g., '**/*.ts')",
inputSchema: z.object({
pattern: z.string().describe("Glob pattern like '**/*.ts' or '*.json'"),
}),
execute: async ({ pattern }: { pattern: string }) => {
console.log(`[Tool: search] Looking for: ${pattern}`);
// Simulated results
return {
files: ["src/index.ts", "src/utils.ts", "src/types.ts"],
};
},
}),
/**
* Read contents of a file.
* AI uses this to examine code, configs, etc.
*/
readFile: tool({
description: "Read the contents of a file",
inputSchema: z.object({
path: z.string().describe("Relative file path like 'src/index.ts'"),
}),
execute: async ({ path }) => {
console.log(`[Tool: readFile] Reading: ${path}`);
// Simulated file content
return {
content: `// Simulated content of ${path}\nexport const hello = "world"`,
};
},
}),
/**
* Write content to a file.
* AI uses this to create or modify files.
*/
writeFile: tool({
description: "Write content to a file (creates or overwrites)",
inputSchema: z.object({
path: z.string().describe("File path to write"),
content: z.string().describe("Content to write to the file"),
}),
execute: async ({ path, content }) => {
console.log(`[Tool: writeFile] Writing to: ${path}`);
console.log(
`[Tool: writeFile] Content preview: ${content.slice(0, 100)}...`,
);
return { success: true, bytesWritten: content.length };
},
}),
};
// =============================================================================
// SECTION 3: PROVIDER REGISTRY WITH TOOLOOPAGENT
// =============================================================================
/**
* ProviderRegistry manages multiple AI providers in one place.
*
* BENEFITS:
* 1. Switch providers by changing one string
* 2. Centralized error handling
* 3. Easy to add new providers
* 4. Works seamlessly with ToolLoopAgent
*
* WITHOUT THIS: You'd have provider-specific code scattered throughout your app.
* WITH THIS: One place to manage all providers.
*/
class ProviderRegistry {
// Map of provider ID → boolean (registered or not)
private providers = new Map<string, boolean>();
/**
* Register a provider.
* Call this once at startup for each provider you want to use.
*/
register(providerId: string, apiKey?: string) {
if (apiKey) {
this.providers.set(providerId, true);
console.log(`[ProviderRegistry] ${providerId} provider registered`);
} else {
console.log(
`[ProviderRegistry] ${providerId} API key not set, skipping`,
);
}
}
/**
* Check if a provider is registered.
*/
isRegistered(providerId: string): boolean {
return this.providers.has(providerId);
}
/**
* Get a list of registered providers.
*/
getRegisteredProviders(): string[] {
return Array.from(this.providers.keys());
}
}
// =============================================================================
// SECTION 4: AGENT SYSTEM WITH TOOLOOPAGENT
// =============================================================================
/**
* AgentSystem is your main application class using ToolLoopAgent.
*
* It orchestrates:
* - Provider management
* - ToolLoopAgent creation and execution
* - Multi-provider support
*
* This is where you'd add your business logic:
* - Custom logging
* - Metrics tracking
* - Error handling
* - User session management
*/
class AgentSystem {
private registry: ProviderRegistry;
constructor() {
this.registry = new ProviderRegistry();
// Register providers from environment variables
this.registry.register("openai", process.env.OPENAI_API_KEY);
this.registry.register("anthropic", process.env.ANTHROPIC_API_KEY);
}
/**
* Create a ToolLoopAgent for the specified provider and model.
*
* @param providerId - Which provider to use ("openai", "anthropic")
* @param modelId - Which model ("gpt-5.4", "claude-3-sonnet")
* @param customInstructions - Optional custom system instructions
*
* EXAMPLE USAGE:
* const agent = new AgentSystem()
*
* // Create an agent with OpenAI
* const openaiAgent = agent.createAgent("openai", "gpt-5.4-mini")
*
* // Create an agent with Anthropic
* const anthropicAgent = agent.createAgent("anthropic", "claude-haiku")
*/
createAgent(
providerId: string,
modelId: string,
customInstructions?: string,
): ToolLoopAgent<typeof tools> {
if (!this.registry.isRegistered(providerId)) {
throw new Error(
`Provider not registered: ${providerId}. ` +
`Registered: ${this.registry.getRegisteredProviders().join(", ")}`,
);
}
// ToolLoopAgent uses "provider/model" format for model IDs
const fullModelId = `${providerId}/${modelId}`;
return new ToolLoopAgent({
model: fullModelId,
instructions:
customInstructions ||
"You are a helpful assistant that can use tools to help users accomplish tasks. Be concise and direct in your responses.",
tools,
});
}
/**
* Run an agent with the given prompt.
*
* @param providerId - Which provider to use
* @param modelId - Which model
* @param prompt - User's request
*
* EXAMPLE USAGE:
* const agent = new AgentSystem()
* const result = await agent.run("openai", "gpt-5.4-mini", "Find all TS files")
* console.log(result.text)
*/
async run(
providerId: string,
modelId: string,
prompt: string,
): Promise<{ text: string; toolCalls: unknown[] }> {
console.log(`\n[AgentSystem] Running with ${providerId}/${modelId}`);
console.log(`[AgentSystem] Prompt: ${prompt.slice(0, 80)}...\n`);
const agent = this.createAgent(providerId, modelId);
// ToolLoopAgent.run() automatically handles the tool loop
// No need to manage maxSteps or conversation state manually
const result = await agent.run(prompt);
return {
text: result.text,
toolCalls: result.toolCalls || [],
};
}
}
// =============================================================================
// SECTION 5: USAGE EXAMPLES
// =============================================================================
/**
* Example: Running the agent with different providers.
*
* Uncomment the examples you want to try.
* Remember: These make real API calls that cost money!
*/
async function main() {
const system = new AgentSystem();
// Example 1: Simple request with OpenAI (cheapest option)
console.log("=== Example 1: OpenAI Agent ===");
const result1 = await system.run(
"openai",
"gpt-5.4-mini", // Cheap model for testing
"What files exist in this project?",
);
console.log("\n[Response]", result1.text);
console.log("[Tool calls made]", result1.toolCalls.length);
// Example 2: Request with Anthropic
console.log("\n=== Example 2: Anthropic Agent ===");
const result2 = await system.run(
"anthropic",
"claude-haiku", // Also cheap for testing
"Read the file src/index.ts and explain what it does",
);
console.log("\n[Response]", result2.text);
// Example 3: Multi-step agent task
console.log("\n=== Example 3: Multi-step Task ===");
const result3 = await system.run(
"openai",
"gpt-5.4-mini",
"Find all TypeScript files, then read the first one and summarize it",
);
console.log("\n[Response]", result3.text);
console.log("[Tool calls made]", result3.toolCalls.length);
// Example 4: Custom instructions
console.log("\n=== Example 4: Custom Instructions ===");
const customAgent = system.createAgent(
"openai",
"gpt-5.4-mini",
"You are a file management expert. Be very thorough when analyzing files.",
);
const result4 = await customAgent.run("What files do we have?");
console.log("\n[Response]", result4.text);
console.log("\n=== All examples complete ===");
}
// Run main() if this file is executed directly
if (import.meta.main) {
main().catch((error) => {
console.error("\n[Error]", error.message);
console.error("\nDid you set up your .env file with API keys?");
console.error("Create .env with:");
console.error(" OPENAI_API_KEY=sk-your-key");
console.error(" ANTHROPIC_API_KEY=sk-ant-your-key");
process.exit(1);
});
}
// Export classes for use in other files
export { AgentSystem, ProviderRegistry, tools };
/**
* MULTI-PROVIDER AGENT WITH TOOLOOPAGENT AND FALLBACK
*
* This example demonstrates:
* - Using ToolLoopAgent across multiple providers
* - Automatic provider fallback when one fails
* - Unified agent interface regardless of provider
*
* PREREQUISITES:
* 1. Create a .env file with:
* OPENAI_API_KEY=sk-your-key-here
* ANTHROPIC_API_KEY=sk-ant-your-key-here
*
* 2. Install dependencies:
* npm install ai @ai-sdk/openai @ai-sdk/anthropic zod dotenv
*
* 3. Run with:
* npx tsx multi-provider.ts
*/
import { ToolLoopAgent, tool } from "ai";
import { z } from "zod";
interface ModelConfig {
provider: string;
model: string;
priority?: number;
}
/**
* MultiProviderPool manages agents across multiple AI providers.
*
* Unlike the old approach using streamText with model instances,
* ToolLoopAgent uses simple "provider/model" strings, making
* multi-provider management much cleaner.
*/
class MultiProviderPool {
private availableProviders = new Set<string>();
private toolSet = {
calculate: tool({
description: "Calculate a mathematical expression",
inputSchema: z.object({
expression: z.string().describe("Math expression like '123 * 456'"),
}),
execute: async ({ expression }) => {
try {
// eslint-disable-next-line no-eval
const result = eval(expression);
return { result, success: true };
} catch {
return { result: null, success: false, error: "Invalid expression" };
}
},
}),
search: tool({
description: "Search for information",
inputSchema: z.object({
query: z.string().describe("Search query"),
}),
execute: async ({ query }) => {
return { results: [`Simulated result for: ${query}`] };
},
}),
};
constructor() {
this.initProviders();
}
private initProviders() {
// Check which providers have API keys configured
if (process.env.OPENAI_API_KEY) {
this.availableProviders.add("openai");
console.log("[MultiProviderPool] OpenAI available");
}
if (process.env.ANTHROPIC_API_KEY) {
this.availableProviders.add("anthropic");
console.log("[MultiProviderPool] Anthropic available");
}
if (process.env.GOOGLE_API_KEY) {
this.availableProviders.add("google");
console.log("[MultiProviderPool] Google available");
}
if (this.availableProviders.size === 0) {
console.warn(
"[MultiProviderPool] No providers configured! Set API keys in .env",
);
}
}
/**
* Check if a provider is available.
*/
isProviderAvailable(providerId: string): boolean {
return this.availableProviders.has(providerId);
}
/**
* Get list of available providers.
*/
getAvailableProviders(): string[] {
return Array.from(this.availableProviders);
}
/**
* Create a ToolLoopAgent for a specific provider and model.
*
* ToolLoopAgent uses the "provider/model" format (e.g., "openai/gpt-5.4")
* making it trivial to switch providers.
*/
createAgent(
providerId: string,
modelId: string,
): ToolLoopAgent<typeof this.toolSet> {
if (!this.isProviderAvailable(providerId)) {
throw new Error(
`Provider not available: ${providerId}. ` +
`Available: ${this.getAvailableProviders().join(", ")}`,
);
}
const fullModelId = `${providerId}/${modelId}`;
return new ToolLoopAgent({
model: fullModelId,
instructions:
"You are a helpful assistant with access to calculation and search tools.",
tools: this.toolSet,
});
}
/**
* Execute with automatic fallback across providers.
*
* @param configs - Array of provider/model configs with priority
* @param prompt - User's prompt
*
* Tries providers in priority order until one succeeds.
*/
async executeWithFallback(
configs: ModelConfig[],
prompt: string,
): Promise<{ text: string; provider: string; model: string }> {
// Sort by priority (highest first)
const sorted = configs.sort(
(a, b) => (b.priority || 0) - (a.priority || 0),
);
for (const config of sorted) {
try {
console.log(`\n[Trying] ${config.provider}/${config.model}...`);
const agent = this.createAgent(config.provider, config.model);
const result = await agent.run(prompt);
console.log(`[Success] ${config.provider}/${config.model}`);
return {
text: result.text,
provider: config.provider,
model: config.model,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[Failed] ${config.provider}/${config.model}: ${message}`);
continue;
}
}
throw new Error("All providers failed");
}
/**
* Execute with the best available provider.
*
* Automatically selects from available providers.
*/
async executeWithBestProvider(
prompt: string,
): Promise<{ text: string; provider: string; model: string }> {
const configs: ModelConfig[] = [];
// Define preferred models for each provider
if (this.isProviderAvailable("openai")) {
configs.push({ provider: "openai", model: "gpt-5.4", priority: 100 });
configs.push({ provider: "openai", model: "gpt-5.4-mini", priority: 80 });
}
if (this.isProviderAvailable("anthropic")) {
configs.push({ provider: "anthropic", model: "claude-sonnet-4", priority: 90 });
configs.push({ provider: "anthropic", model: "claude-haiku", priority: 70 });
}
if (configs.length === 0) {
throw new Error("No providers available");
}
return this.executeWithFallback(configs, prompt);
}
}
// Usage example
async function main() {
const pool = new MultiProviderPool();
console.log("=== Example 1: Fallback Chain ===");
try {
const result1 = await pool.executeWithFallback(
[
{ provider: "openai", model: "gpt-5.4", priority: 100 },
{ provider: "anthropic", model: "claude-sonnet-4", priority: 90 },
{ provider: "openai", model: "gpt-5.4-mini", priority: 50 },
],
"Calculate 12345 * 67890 and explain the result",
);
console.log("\n[Result]");
console.log(`Provider: ${result1.provider}/${result1.model}`);
console.log(`Response: ${result1.text.slice(0, 200)}...`);
} catch (error) {
console.error("All providers failed:", error);
}
console.log("\n=== Example 2: Best Available Provider ===");
try {
const result2 = await pool.executeWithBestProvider(
"What is the capital of France?",
);
console.log("\n[Result]");
console.log(`Provider: ${result2.provider}/${result2.model}`);
console.log(`Response: ${result2.text}`);
} catch (error) {
console.error("Failed:", error);
}
console.log("\n=== Example 3: Direct Agent Creation ===");
try {
const agent = pool.createAgent("openai", "gpt-5.4-mini");
const result3 = await agent.run("Calculate 2 + 2");
console.log("[Result]", result3.text);
} catch (error) {
console.error("Failed:", error);
}
}
if (import.meta.main) {
main().catch((error) => {
console.error("\n[Error]", error.message);
console.error("\nDid you set up your .env file with API keys?");
console.error("Create .env with:");
console.error(" OPENAI_API_KEY=sk-your-key");
console.error(" ANTHROPIC_API_KEY=sk-ant-your-key");
process.exit(1);
});
}
export { MultiProviderPool };
/**
* OAUTH IMPLEMENTATION FOR CHATGPT/CODEX WITH MANDATORY REFRESH TOKEN SUPPORT
*
* ⚠️ CRITICAL: OpenAI/Codex OAuth uses DIFFERENT ENDPOINTS and BEHAVIOR than regular OpenAI API:
*
* ENDPOINTS:
* OAuth Flow (ChatGPT Pro/Plus subscription):
* - Authorization: https://chatgpt.com/backend-api/codex/authorize
* - Token: https://chatgpt.com/backend-api/codex/token
* - API Endpoint: https://chatgpt.com/backend-api/codex/responses
* - Client ID: "codex_cli"
*
* Regular API Key Auth:
* - API Endpoint: https://api.openai.com/v1
*
* BEHAVIOR DIFFERENCES:
* 1. Model filtering: Only specific models work with OAuth (gpt-5.* variants, codex models)
* 2. No costs: Usage is included with ChatGPT subscription (costs shown as 0)
* 3. Special headers: Requires ChatGPT-Account-Id header for organization accounts
* 4. URL rewriting: Requests to /v1/responses or /chat/completions are rewritten to Codex endpoint
* 5. Parameter differences: maxOutputTokens should be undefined for Codex
* 6. Bearer tokens: Uses OAuth access token instead of API key in Authorization header
*
* When using OAuth, requests are rewritten to the Codex endpoint (chatgpt.com),
* NOT the standard OpenAI API endpoint (api.openai.com).
*
* ⚠️ CRITICAL REQUIREMENT: Your OAuth implementation MUST support refresh tokens.
* Access tokens expire (usually in 1-2 hours). Without refresh token support,
* users will be forced to re-authenticate constantly.
*
* This example demonstrates:
* - PKCE flow for secure OAuth authentication
* - Automatic token refresh (REQUIRED - not optional)
* - Secure token storage with proper file permissions
* - Complete token lifecycle management
*
* PREREQUISITES:
* 1. Install dependencies:
* npm install open
*
* 2. For OpenAI/Codex OAuth, use the endpoints shown below.
* For other OAuth providers, adapt the endpoints accordingly.
*
* TOKEN LIFECYCLE:
* 1. First auth: User completes OAuth flow → receives access_token + refresh_token
* 2. Usage: Use access_token for API calls
* 3. Expiration: When access_token expires (or is about to), use refresh_token to get new tokens
* 4. Storage: Save both tokens securely - refresh_token is long-lived and reusable
*
* REFRESH TOKEN IS MANDATORY:
* - Never implement OAuth without refresh token support
* - Always check token expiration before API calls
* - Automatically refresh when expired or about to expire (5 min buffer recommended)
* - Store refresh_token securely - it's equivalent to a password
*/
import { createServer } from "http";
import { randomBytes, createHash } from "crypto";
import { chmod, writeFile, readFile, mkdir } from "fs/promises";
import { homedir, platform } from "os";
import { dirname, join } from "path";
import open from "open";
interface OAuthConfig {
clientId: string;
authorizationEndpoint: string;
tokenEndpoint: string;
redirectUri: string;
scopes: string[];
}
/**
* TokenData represents the complete OAuth token response.
*
* ⚠️ CRITICAL: Both accessToken AND refreshToken must be stored.
* The refreshToken is required for automatic token renewal.
*/
interface TokenData {
/** Short-lived token for API calls (expires in 1-2 hours typically) */
accessToken: string;
/** Long-lived token used to get new access tokens (REQUIRED - store this!) */
refreshToken: string;
/** Unix timestamp when accessToken expires */
expiresAt: number;
/** Optional account identifier */
accountId?: string;
}
/**
* CodexOAuthFlow handles the OAuth PKCE authentication flow.
*
* Includes MANDATORY refresh token support. Never use OAuth without this.
*/
class CodexOAuthFlow {
private codeVerifier: string;
private config: OAuthConfig;
constructor() {
// OpenAI/Codex OAuth endpoints - REQUIRED for Codex authentication
// These are the official endpoints for OpenAI/Codex OAuth flow
this.config = {
clientId: "codex_cli",
authorizationEndpoint: "https://chatgpt.com/backend-api/codex/authorize",
tokenEndpoint: "https://chatgpt.com/backend-api/codex/token",
redirectUri: "http://localhost:8080/callback",
scopes: ["openid", "codex"],
};
// Generate PKCE code verifier (one-time use per auth flow)
this.codeVerifier = this.generateCodeVerifier();
}
private generateCodeVerifier() {
// PKCE requires a random code verifier (32 bytes minimum)
return randomBytes(32).toString("base64url");
}
private generateCodeChallenge() {
// Code challenge = SHA256(code verifier)
return createHash("sha256").update(this.codeVerifier).digest("base64url");
}
private generateState() {
// Random state to prevent CSRF attacks
return randomBytes(16).toString("hex");
}
/**
* Initiate the OAuth flow to get initial tokens.
*
* This opens a browser for user authentication.
* Returns both accessToken AND refreshToken.
*/
async authenticate(): Promise<TokenData> {
const state = this.generateState();
const codeChallenge = this.generateCodeChallenge();
// Build authorization URL with PKCE parameters
const authUrl = new URL(this.config.authorizationEndpoint);
authUrl.searchParams.set("client_id", this.config.clientId);
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("redirect_uri", this.config.redirectUri);
authUrl.searchParams.set("scope", this.config.scopes.join(" "));
authUrl.searchParams.set("state", state);
authUrl.searchParams.set("code_challenge", codeChallenge);
authUrl.searchParams.set("code_challenge_method", "S256");
console.log("Starting OAuth flow...");
console.log("Opening browser for authentication...");
// Start local server to receive the authorization callback
const code = await this.startCallbackServer(state, authUrl);
// Exchange authorization code for tokens
return this.exchangeCode(code);
}
private startCallbackServer(
expectedState: string,
authUrl: URL,
): Promise<string> {
return new Promise((resolve, reject) => {
const server = createServer((req, res) => {
const url = new URL(req.url!, `http://localhost:8080`);
// Only handle callback path
if (url.pathname !== "/callback") {
res.writeHead(404);
res.end("Not found");
return;
}
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const error = url.searchParams.get("error");
const errorDescription = url.searchParams.get("error_description");
// Handle OAuth errors
if (error) {
res.writeHead(400);
res.end(`Error: ${error}\n${errorDescription || ""}`);
server.close();
reject(new Error(`OAuth error: ${error} - ${errorDescription}`));
return;
}
// Verify state parameter (CSRF protection)
if (state !== expectedState) {
res.writeHead(400);
res.end("Invalid state parameter");
server.close();
reject(new Error("Invalid OAuth state - possible CSRF attack"));
return;
}
// Verify we got an authorization code
if (!code) {
res.writeHead(400);
res.end("No authorization code received");
server.close();
reject(new Error("No authorization code in callback"));
return;
}
// Success - show user a nice message
res.writeHead(200, { "Content-Type": "text/html" });
res.end(`
<html>
<body style="font-family: sans-serif; text-align: center; padding: 50px;">
<h1>✓ Authentication Successful</h1>
<p>You can close this window and return to the CLI.</p>
</body>
</html>
`);
server.close();
resolve(code);
});
server.listen(8080, () => {
console.log(
"Waiting for authentication callback on http://localhost:8080...",
);
// Open browser for user to authenticate
open(authUrl.toString());
});
// Timeout after 5 minutes
setTimeout(() => {
server.close();
reject(new Error("OAuth timeout - authentication took too long"));
}, 300000);
});
}
/**
* Exchange authorization code for access and refresh tokens.
*
* ⚠️ CRITICAL: The response MUST include a refresh_token.
* If your OAuth provider doesn't return refresh_token, you cannot
* implement automatic token renewal.
*/
private async exchangeCode(code: string): Promise<TokenData> {
const response = await fetch(this.config.tokenEndpoint, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: this.config.clientId,
code,
redirect_uri: this.config.redirectUri,
code_verifier: this.codeVerifier,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Token exchange failed: ${response.status} - ${error}`);
}
const data = await response.json();
// ⚠️ CRITICAL: Verify we got a refresh token
if (!data.refresh_token) {
throw new Error(
"OAuth response missing refresh_token. " +
"Automatic token renewal is impossible. " +
"Check your OAuth scope includes 'offline_access' or equivalent.",
);
}
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresAt: Date.now() / 1000 + data.expires_in,
accountId: data.account_id,
};
}
/**
* Refresh expired access token using the refresh token.
*
* ⚠️ MANDATORY: This method MUST be implemented and used.
* Access tokens expire frequently (1-2 hours).
* Without this, users will constantly need to re-authenticate.
*
* @param refreshToken - The long-lived refresh token from initial auth
* @returns New TokenData with fresh accessToken and possibly new refreshToken
*/
async refreshTokens(refreshToken: string): Promise<TokenData> {
console.log("Refreshing access token...");
const response = await fetch(this.config.tokenEndpoint, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "refresh_token",
client_id: this.config.clientId,
refresh_token: refreshToken,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Token refresh failed: ${response.status} - ${error}`);
}
const data = await response.json();
// Some providers return a new refresh_token, others don't
// Always use the new one if provided, otherwise keep the old one
const newRefreshToken = data.refresh_token || refreshToken;
console.log("Token refreshed successfully");
return {
accessToken: data.access_token,
refreshToken: newRefreshToken,
expiresAt: Date.now() / 1000 + data.expires_in,
accountId: data.account_id,
};
}
}
/**
* TokenStore handles secure storage of OAuth tokens.
*
* ⚠️ CRITICAL: Both accessToken AND refreshToken must be persisted.
* The refreshToken is required for automatic token renewal.
*/
class TokenStore {
private filePath: string;
constructor() {
// Platform-specific config directory with proper security
const home = homedir();
const configDir =
platform() === "darwin"
? join(home, "Library", "Application Support", "ai-agent")
: platform() === "win32"
? join(home, "AppData", "Local", "ai-agent")
: join(home, ".config", "ai-agent");
this.filePath = join(configDir, "auth.json");
}
/**
* Save tokens to secure storage.
*
* ⚠️ CRITICAL: Must save BOTH accessToken AND refreshToken.
*/
async save(provider: string, tokens: TokenData) {
await mkdir(dirname(this.filePath), { recursive: true });
let data: Record<string, TokenData> = {};
try {
const existing = await readFile(this.filePath, "utf-8");
data = JSON.parse(existing);
} catch {
// File doesn't exist yet - that's fine
}
data[provider] = tokens;
await writeFile(this.filePath, JSON.stringify(data, null, 2));
// Set restrictive permissions (Unix only)
// 0o600 = read/write for owner only
if (platform() !== "win32") {
await chmod(this.filePath, 0o600);
}
console.log(`Tokens saved for ${provider}`);
}
/**
* Load tokens from secure storage.
*/
async load(provider: string): Promise<TokenData | null> {
try {
const content = await readFile(this.filePath, "utf-8");
const data = JSON.parse(content);
return data[provider] || null;
} catch {
return null;
}
}
}
/**
* Authenticate with automatic token refresh.
*
* ⚠️ CRITICAL: This demonstrates the COMPLETE token lifecycle:
* 1. Check for existing tokens
* 2. If expired (or about to expire), refresh them
* 3. If no tokens, start OAuth flow
* 4. Return valid, non-expired tokens
*
* ALWAYS implement this full workflow. Never skip refresh support.
*/
async function authenticateCodex(): Promise<TokenData> {
const store = new TokenStore();
const oauth = new CodexOAuthFlow();
// Step 1: Check for existing tokens
let tokens = await store.load("codex");
if (tokens) {
// Step 2: Check if token is expired or about to expire
// 300 second (5 minute) buffer - refresh before actual expiration
const isExpiredOrExpiringSoon = tokens.expiresAt < Date.now() / 1000 + 300;
if (isExpiredOrExpiringSoon) {
console.log("Token expired or expiring soon, refreshing...");
try {
// ⚠️ MANDATORY: Use refresh token to get new access token
tokens = await oauth.refreshTokens(tokens.refreshToken);
await store.save("codex", tokens);
console.log("Token refreshed successfully");
} catch (error) {
console.error("Token refresh failed:", error);
console.log("Falling back to full re-authentication...");
// If refresh fails (e.g., refresh token revoked), start over
tokens = await oauth.authenticate();
await store.save("codex", tokens);
console.log("Re-authentication successful");
}
} else {
console.log("Using existing valid token");
}
} else {
// Step 3: No tokens found - start OAuth flow
console.log("No tokens found, starting OAuth flow...");
tokens = await oauth.authenticate();
await store.save("codex", tokens);
console.log("Authentication successful, tokens saved");
}
return tokens;
}
// Example usage
if (import.meta.main) {
authenticateCodex()
.then((tokens) => {
console.log("\n✓ Authentication complete");
console.log("Access token:", tokens.accessToken.slice(0, 20) + "...");
console.log("Expires at:", new Date(tokens.expiresAt * 1000).toLocaleString());
console.log("\nThis token will be automatically refreshed when needed.");
})
.catch((error) => {
console.error("\n✗ Authentication failed:", error.message);
process.exit(1);
});
}
export { CodexOAuthFlow, TokenStore, authenticateCodex, type TokenData };
Getting Started Guide
Your first 30 minutes with the AI SDK Agentic Loop skill.
---
Minute 0-5: Understanding What You're Building
An AI agent is:
1. AI thinks → Processes your request 2. AI acts → Calls tools (functions) to do things 3. AI loops → Uses tool results to think again
Example:
You: "Fix the bug"
AI: I'll search for files
→ Calls searchFiles tool
AI: Found src/utils.ts, let me read it
→ Calls readFile tool
AI: I see the bug! Let me fix it
→ Calls writeFile tool
AI: Done! The bug was...Why this skill exists: The Vercel AI SDK is powerful but low-level. This skill shows you patterns for:
- Using multiple AI providers (OpenAI, Anthropic, etc.)
- Handling each provider's quirks automatically
- Building production-ready systems
---
Minute 5-10: Setup
1. Install dependencies:
npm install ai @ai-sdk/openai zod dotenv2. Get an API key:
- Go to https://platform.openai.com/api-keys
- Create a new key
- Copy it
3. Create `.env` file:
echo "OPENAI_API_KEY=sk-your-key-here" > .env4. Add `.env` to `.gitignore`:
echo ".env" >> .gitignore⚠️ Never commit API keys!
---
Minute 10-15: Run Your First Agent
Create first-agent.ts:
import { openai } from "@ai-sdk/openai"
import { streamText, tool } from "ai"
import { z } from "zod"
import "dotenv/config"
// Define a tool
const calculator = tool({
description: "Add two numbers",
parameters: z.object({
a: z.number(),
b: z.number(),
}),
execute: async ({ a, b }) => {
return { result: a + b }
},
})
// Run the agent
async function main() {
const result = await streamText({
model: openai("gpt-4o-mini"), // Cheap model for testing
tools: { calculator },
maxSteps: 5,
messages: [
{
role: "user",
content: "What is 123 + 456?",
},
],
})
// Print response as it streams
for await (const chunk of result.textStream) {
process.stdout.write(chunk)
}
console.log("\n[Done]")
}
main()Run it:
npx tsx first-agent.tsExpected output:
The result is 579.
[Done]What happened?
1. Your code called streamText() 2. OpenAI received: "What is 123 + 456?" 3. OpenAI decided to call your calculator tool 4. Your execute function ran with {a: 123, b: 456} 5. OpenAI received the result: {result: 579} 6. OpenAI responded: "The result is 579."
---
Minute 15-20: Add More Tools
Expand your agent:
const tools = {
calculator: tool({
description: "Add two numbers",
parameters: z.object({ a: z.number(), b: z.number() }),
execute: async ({ a, b }) => ({ result: a + b }),
}),
getTime: tool({
description: "Get current time",
parameters: z.object({}), // No parameters needed
execute: async () => ({
time: new Date().toLocaleTimeString(),
}),
}),
searchFiles: tool({
description: "Search for files matching a pattern",
parameters: z.object({
pattern: z.string().describe("Glob pattern like '*.ts'"),
}),
execute: async ({ pattern }) => {
// In real code, use fs.glob or similar
return { files: ["src/index.ts", "src/utils.ts"] }
},
}),
}
// Use all tools
const result = await streamText({
model: openai("gpt-4o-mini"),
tools, // All tools available
maxSteps: 10,
messages: [
{
role: "user",
content: "What time is it? Also find all TypeScript files.",
},
],
})---
Minute 20-25: Switch Providers
Use Anthropic instead of OpenAI:
npm install @ai-sdk/anthropicimport { anthropic } from "@ai-sdk/anthropic"
const result = await streamText({
model: anthropic("claude-3-haiku"), // Just change this line
tools,
maxSteps: 10,
messages: [{ role: "user", content: "What is 123 + 456?" }],
})Same code, different provider!
---
Minute 25-30: Next Steps
You now understand the basics. What's next?
Level 1: Keep It Simple
- Add more tools to your agent
- Try different prompts
- Experiment with models
Level 2: Add Structure
- Read complete-agent.ts
- Add a provider registry for multiple providers
- Handle errors properly
Level 3: Production Ready
- Read architecture.md
- Add authentication management
- Add message transforms for provider quirks
- Set up monitoring and logging
Common Next Steps
Make it interactive:
import { createInterface } from "readline"
const rl = createInterface({
input: process.stdin,
output: process.stdout,
})
async function chat() {
const messages = []
while (true) {
const input = await new Promise((resolve) => {
rl.question("You: ", resolve)
})
if (input === "exit") break
messages.push({ role: "user", content: input })
const result = await streamText({
model: openai("gpt-4o-mini"),
tools,
maxSteps: 10,
messages,
})
let response = ""
for await (const chunk of result.textStream) {
process.stdout.write(chunk)
response += chunk
}
messages.push({ role: "assistant", content: response })
console.log("\n")
}
}
chat()---
Common First-Time Issues
"Cannot find module 'ai'" → Run npm install ai @ai-sdk/openai zod dotenv
"API key required" → Check your .env file has OPENAI_API_KEY=sk-...
"maxSteps reached" → Agent is confused or task is too complex. Increase maxSteps or simplify prompt.
"Rate limit exceeded" → You're calling the API too fast. Add delays between calls or upgrade your plan.
---
Cost Awareness
Running agents costs real money.
- GPT-4o-mini: ~$0.0001 per 1K tokens (very cheap)
- GPT-4o: ~$0.005 per 1K tokens (moderate)
- Claude 3 Opus: ~$0.015 per 1K tokens (expensive)
A typical agent conversation:
- 10 steps
- 500 tokens per step
- GPT-4o-mini: ~$0.0005 (half a cent)
- GPT-4o: ~$0.025 (2.5 cents)
Tips:
- Use
gpt-4o-miniwhile learning - Set
maxStepsto prevent infinite loops - Monitor your API usage dashboard
---
Key Concepts Checklist
By now you should understand:
- [ ] What an agent is (AI + Tools + Loop)
- [ ] How tools work (description + parameters + execute)
- [ ] What
streamTextdoes (calls AI with tools) - [ ] What
maxStepscontrols (tool loop limit) - [ ] How to switch providers (change import + model)
- [ ] That this costs money (use cheap models while learning)
---
Quick Reference
Install:
npm install ai @ai-sdk/openai zod dotenvBasic structure:
import { openai } from "@ai-sdk/openai"
import { streamText, tool } from "ai"
import { z } from "zod"
const myTool = tool({
description: "What this tool does",
parameters: z.object({ param: z.string() }),
execute: async ({ param }) => ({ result: "..." }),
})
const result = await streamText({
model: openai("gpt-4o-mini"),
tools: { myTool },
maxSteps: 10,
messages: [{ role: "user", content: "..." }],
})Switch providers:
// OpenAI
import { openai } from "@ai-sdk/openai"
const model = openai("gpt-4o")
// Anthropic
import { anthropic } from "@ai-sdk/anthropic"
const model = anthropic("claude-3-sonnet")Run:
npx tsx agent.ts---
Where to Go From Here
1. Build something real - File manager, code reviewer, data analyzer 2. Read the examples - complete-agent.ts 3. Add complexity when needed - See architecture.md 4. Debug issues - See troubleshooting.md
Remember: Start simple. Add patterns when you feel pain, not before.
Provider Support Matrix
Quick reference for provider capabilities and special handling.
Supported Providers
| Provider | Package | Auth | Tools | Vision | Caching | Streaming |
|---|---|---|---|---|---|---|
| OpenAI | @ai-sdk/openai | API Key | ✅ | ✅ | ❌ | ✅ |
| Anthropic | @ai-sdk/anthropic | API Key | ✅ | ✅ | ✅ | ✅ |
@ai-sdk/google | API Key | ✅ | ✅ | ❌ | ✅ | |
| Azure | @ai-sdk/azure | API Key | ✅ | ✅ | ❌ | ✅ |
| AWS Bedrock | @ai-sdk/amazon-bedrock | API Key | ✅ | ✅ | ✅ | ✅ |
| Mistral | @ai-sdk/mistral | API Key | ✅ | ❌ | ❌ | ✅ |
| Cohere | @ai-sdk/cohere | API Key | ✅ | ❌ | ❌ | ✅ |
| Groq | @ai-sdk/groq | API Key | ✅ | ❌ | ❌ | ✅ |
| OpenRouter | @openrouter/ai-sdk-provider | API Key | ✅ | ✅ | ✅ | ✅ |
| Copilot | @ai-sdk/github-copilot | OAuth | ✅ | ❌ | ✅ | ✅ |
Provider Quirks
OpenAI
// Special cases
const quirks = {
// Codex uses OAuth + different endpoint
codex: {
auth: "oauth",
endpoint: "chatgpt.com/backend-api",
notes: "Requires ChatGPT account, not API key",
},
// o1/o3 support reasoning effort
reasoning: {
models: ["o1", "o3"],
parameter: "reasoningEffort",
values: ["low", "medium", "high"],
},
// Responses API for newer models
responses: {
models: ["gpt-5"],
api: "responses",
},
};Anthropic
const quirks = {
// Tool call ID sanitization
toolId: {
pattern: /[^a-zA-Z0-9_-]/g,
replace: "_",
},
// Empty message filtering
filterEmpty: true,
// Caching support
caching: {
header: { cacheControl: { type: "ephemeral" } },
appliesTo: ["system", "final"],
},
// Thinking mode for Opus/Sonnet 4.6
thinking: {
models: ["opus-4-6", "sonnet-4-6"],
parameter: { type: "enabled", budget_tokens: number },
},
};Mistral
const quirks = {
// 9-character tool call ID limit
toolId: {
maxLength: 9,
padChar: "0",
},
// Message sequence fix required
sequence: {
invalid: "tool → user",
fix: "tool → assistant('Done.') → user",
},
};Google (Gemini)
const quirks = {
// Different parameter defaults
defaults: {
temperature: 1.0,
topP: 0.95,
topK: 64,
},
// Multimodal support
modalities: ["text", "image", "video", "audio"],
};AWS Bedrock
const quirks = {
// Same normalization as Anthropic
inherits: "anthropic",
// Different caching header
caching: {
header: { cachePoint: { type: "default" } },
},
};OpenRouter
const quirks = {
// Unified API for multiple providers
proxy: true,
// Caching support
caching: {
header: { cacheControl: { type: "ephemeral" } },
},
// Key remapping in providerOptions
providerKey: "openrouter",
};Default Parameters by Provider
| Provider | Temperature | Top P | Top K |
|---|---|---|---|
| OpenAI | - | - | - |
| Anthropic | - | - | - |
| 1.0 | 0.95 | 64 | |
| Mistral | - | - | - |
| Cohere | - | - | - |
| Qwen | 0.55 | 1.0 | - |
| Minimax | 1.0 | 0.95 | 20/40 |
| Kimi (base) | 0.6 | - | - |
| Kimi (thinking) | 1.0 | 0.95 | - |
Authentication Methods
| Method | Providers | Storage |
|---|---|---|
| API Key | Most | Environment var or secure file |
| OAuth | Copilot, some enterprise | Token store with refresh |
| AWS IAM | Bedrock | AWS credentials |
| Azure AD | Azure | Azure credentials |
Context Window Sizes
| Provider | Max Context |
|---|---|
| GPT-4 | 128K |
| Claude | 200K |
| Gemini | 1M |
| Mistral Large | 128K |
| Llama 3 | 128K |
Recommendations by Use Case
Coding Assistant
- Primary: Claude (strong reasoning)
- Fallback: GPT-4 (reliable)
- Budget: Codex (ChatGPT OAuth)
Content Generation
- Primary: GPT-4 (good prose)
- Fallback: Claude (creative)
- Budget: Gemini (competitive pricing)
Multi-modal
- Primary: Gemini (native multimodal)
- Fallback: GPT-4V (good vision)
- Budget: - (multimodal is expensive)
European Compliance
- Primary: Mistral (EU-based)
- Fallback: Claude (data handling)
High Throughput
- Primary: Groq (fast inference)
- Fallback: - (depends on load)
Provider Registry Implementation
Build a unified interface for managing multiple AI providers.
Core Interface
import type { LanguageModelV2 } from "@ai-sdk/provider";
interface ModelInfo {
id: string;
provider: string;
capabilities: {
input: string[]; // 'text', 'image', 'audio', 'pdf'
output: string[]; // 'text', 'image'
tools: boolean;
reasoning: boolean;
};
contextWindow: number;
pricing?: {
input: number;
output: number;
};
}
interface ProviderAdapter {
readonly id: string;
readonly name: string;
// Core method: get language model
languageModel(modelId: string): LanguageModelV2;
// Optional: list available models
models?(): Promise<ModelInfo[]>;
// Check if provider supports a feature
supports?(feature: string): boolean;
}Basic Registry Implementation
import { createOpenAI } from "@ai-sdk/openai";
import { createAnthropic } from "@ai-sdk/anthropic";
import { createGoogleGenerativeAI } from "@ai-sdk/google";
import { createMistral } from "@ai-sdk/mistral";
import { createCohere } from "@ai-sdk/cohere";
import { createAzure } from "@ai-sdk/azure";
class ProviderRegistry {
private providers = new Map<string, ProviderAdapter>();
private modelCache = new Map<string, LanguageModelV2>();
register(adapter: ProviderAdapter): void {
this.providers.set(adapter.id, adapter);
}
get(providerId: string): ProviderAdapter {
const provider = this.providers.get(providerId);
if (!provider) {
throw new Error(
`Provider not found: ${providerId}. ` + `Registered: ${Array.from(this.providers.keys()).join(", ")}`,
);
}
return provider;
}
getModel(providerId: string, modelId: string): LanguageModelV2 {
const cacheKey = `${providerId}/${modelId}`;
if (!this.modelCache.has(cacheKey)) {
const provider = this.get(providerId);
const model = provider.languageModel(modelId);
this.modelCache.set(cacheKey, model);
}
return this.modelCache.get(cacheKey)!;
}
list(): string[] {
return Array.from(this.providers.keys());
}
clearCache(): void {
this.modelCache.clear();
}
}
// Create global registry
export const registry = new ProviderRegistry();Adapter Implementations
OpenAI Adapter
class OpenAIAdapter implements ProviderAdapter {
readonly id = "openai";
readonly name = "OpenAI";
private client;
constructor(apiKey: string) {
this.client = createOpenAI({ apiKey });
}
languageModel(modelId: string) {
return this.client.languageModel(modelId);
}
async models(): Promise<ModelInfo[]> {
// You could fetch this from OpenAI API or hardcode common models
return [
{
id: "gpt-5.1",
provider: "openai",
capabilities: {
input: ["text", "image", "audio", "pdf"],
output: ["text"],
tools: true,
reasoning: false,
},
contextWindow: 128000,
},
{
id: "o1",
provider: "openai",
capabilities: {
input: ["text", "image"],
output: ["text"],
tools: true,
reasoning: true,
},
contextWindow: 200000,
},
];
}
supports(feature: string): boolean {
const features = ["streaming", "tools", "vision", "json"];
return features.includes(feature);
}
}
// Register
registry.register(new OpenAIAdapter(process.env.OPENAI_API_KEY!));Anthropic Adapter
class AnthropicAdapter implements ProviderAdapter {
readonly id = "anthropic";
readonly name = "Anthropic";
private client;
constructor(apiKey: string) {
this.client = createAnthropic({ apiKey });
}
languageModel(modelId: string) {
return this.client.languageModel(modelId);
}
supports(feature: string): boolean {
const features = ["streaming", "tools", "vision", "caching"];
return features.includes(feature);
}
}
registry.register(new AnthropicAdapter(process.env.ANTHROPIC_API_KEY!));Dynamic Provider Loading
Load providers on-demand to reduce startup time:
class DynamicProviderRegistry {
private providers = new Map<string, () => ProviderAdapter>();
private instances = new Map<string, ProviderAdapter>();
register(id: string, factory: () => ProviderAdapter): void {
this.providers.set(id, factory);
}
get(id: string): ProviderAdapter {
if (!this.instances.has(id)) {
const factory = this.providers.get(id);
if (!factory) throw new Error(`Provider not registered: ${id}`);
this.instances.set(id, factory());
}
return this.instances.get(id)!;
}
isRegistered(id: string): boolean {
return this.providers.has(id);
}
}
// Usage
const dynamicRegistry = new DynamicProviderRegistry();
dynamicRegistry.register("openai", () => new OpenAIAdapter(process.env.OPENAI_API_KEY!));
dynamicRegistry.register("anthropic", () => new AnthropicAdapter(process.env.ANTHROPIC_API_KEY!));
// Only instantiated when first accessed
const openai = dynamicRegistry.get("openai");Model Capabilities System
Check what a model supports before using features:
interface CapabilityChecker {
supportsModality(model: ModelInfo, modality: string): boolean;
supportsTools(model: ModelInfo): boolean;
supportsVision(model: ModelInfo): boolean;
supportsCaching(model: ModelInfo): boolean;
}
const capabilityChecker: CapabilityChecker = {
supportsModality(model, modality) {
return model.capabilities.input.includes(modality) || model.capabilities.output.includes(modality);
},
supportsTools(model) {
return model.capabilities.tools;
},
supportsVision(model) {
return model.capabilities.input.includes("image");
},
supportsCaching(model) {
// Only specific providers support caching
return ["anthropic", "bedrock", "openrouter"].includes(model.provider);
},
};
// Usage
const model = await registry.get("openai").models?.();
if (model && capabilityChecker.supportsVision(model[0])) {
// Can send images
}Provider Discovery
Discover available providers from a registry API:
interface ModelRegistryAPI {
fetchModels(): Promise<ModelInfo[]>;
}
class ModelsDevRegistry implements ModelRegistryAPI {
private baseUrl = "https://models.dev/api";
async fetchModels(): Promise<ModelInfo[]> {
const response = await fetch(`${this.baseUrl}/models`);
if (!response.ok) throw new Error(`Failed to fetch models: ${response.status}`);
const data = await response.json();
return data.models.map(this.transformModel);
}
private transformModel(apiModel: any): ModelInfo {
return {
id: apiModel.id,
provider: apiModel.provider,
capabilities: {
input: apiModel.capabilities?.input || ["text"],
output: apiModel.capabilities?.output || ["text"],
tools: apiModel.capabilities?.tools || false,
reasoning: apiModel.capabilities?.reasoning || false,
},
contextWindow: apiModel.context_window || 4096,
pricing: apiModel.pricing,
};
}
}
// Usage
const modelRegistry = new ModelsDevRegistry();
const allModels = await modelRegistry.fetchModels();
// Filter by capability
const visionModels = allModels.filter((m) => capabilityChecker.supportsVision(m));Factory Pattern for Providers
Create providers with different configurations:
interface ProviderConfig {
apiKey: string;
baseUrl?: string;
timeout?: number;
retries?: number;
}
class ProviderFactory {
static createOpenAI(config: ProviderConfig): ProviderAdapter {
return new OpenAIAdapter(config.apiKey);
}
static createAnthropic(config: ProviderConfig): ProviderAdapter {
return new AnthropicAdapter(config.apiKey);
}
static createAzure(config: ProviderConfig & { resource: string }): ProviderAdapter {
// Azure needs special handling
return new AzureAdapter(config);
}
static createCustom(
id: string,
factory: (config: ProviderConfig) => ProviderAdapter,
config: ProviderConfig,
): ProviderAdapter {
return factory(config);
}
}
// Usage
const openai = ProviderFactory.createOpenAI({
apiKey: process.env.OPENAI_API_KEY!,
timeout: 30000,
});
registry.register(openai);Error Handling
Standardize errors across providers:
class ProviderError extends Error {
constructor(
message: string,
public provider: string,
public code: string,
public retryable: boolean = false,
) {
super(message);
this.name = "ProviderError";
}
}
function handleProviderError(error: unknown, provider: string): never {
if (error instanceof ProviderError) throw error;
const message = error instanceof Error ? error.message : String(error);
// Classify errors
if (message.includes("rate limit")) {
throw new ProviderError(message, provider, "RATE_LIMIT", true);
}
if (message.includes("authentication") || message.includes("api key")) {
throw new ProviderError(message, provider, "AUTH", false);
}
if (message.includes("context length") || message.includes("too long")) {
throw new ProviderError(message, provider, "CONTEXT_LENGTH", false);
}
throw new ProviderError(message, provider, "UNKNOWN", true);
}
// Usage in adapter
class ResilientOpenAIAdapter extends OpenAIAdapter {
async languageModel(modelId: string) {
try {
return super.languageModel(modelId);
} catch (error) {
handleProviderError(error, this.id);
}
}
}Best Practices
1. Lazy Loading: Don't instantiate providers until needed 2. Caching: Cache model instances to avoid recreating 3. Error Standardization: Convert provider-specific errors to unified format 4. Capability Checking: Always check capabilities before using features 5. Timeout Handling: Set reasonable timeouts for each provider 6. Retry Logic: Implement retry with backoff for transient errors
Complete Example
See examples/complete-agent.ts for a full working implementation with all patterns combined.
Provider-Specific Transforms
Handle the quirks and requirements of different AI providers.
Why Transforms Are Needed
Different providers have different rules for message formatting. Without transforms, your code works with one provider but fails with another.
Example: Tool Call ID Formats
The Problem: Each provider requires different tool call ID formats:
| Provider | Format | Max Length | Example |
|---|---|---|---|
| OpenAI | Any string | Unlimited | call_abc123xyz |
| Anthropic | Alphanumeric + _- | Unlimited | call_abc-123 |
| Mistral | Alphanumeric only | 9 chars | abc123xyz |
Without transforms (broken):
// Your code generates this tool call ID
const toolCallId = "call_abc-123_xyz"
// Works with OpenAI ✓
// Works with Anthropic ✓
// FAILS with Mistral ✗ (too long, has hyphens)
// Error: "Invalid tool call ID format"With transforms (works everywhere):
// Mistral transform sanitizes the ID
const sanitizedId = sanitizeForMistral("call_abc-123_xyz")
// Result: "callabc12" (9 chars, alphanumeric only)
// Works with all providers ✓---
Message Types
Before understanding transforms, know the message structure:
interface ModelMessage {
role: "system" | "user" | "assistant" | "tool"
content: string | MessagePart[]
providerOptions?: Record<string, any>
}
type MessagePart =
| { type: "text"; text: string }
| { type: "image"; image: string | Uint8Array }
| { type: "tool-call"; toolCallId: string; toolName: string; args: any }
| { type: "tool-result"; toolCallId: string; toolName: string; result: any; isError?: boolean }Example conversation flow:
// 1. System prompt
{ role: "system", content: "You are a helpful assistant." }
// 2. User asks a question
{ role: "user", content: "What files are in the project?" }
// 3. AI decides to call a tool
{
role: "assistant",
content: [{
type: "tool-call",
toolCallId: "call_123",
toolName: "listFiles",
args: { path: "." }
}]
}
// 4. Tool returns result
{
role: "tool",
content: [{
type: "tool-result",
toolCallId: "call_123", // Must match the tool-call ID!
toolName: "listFiles",
result: { files: ["src", "package.json"] }
}]
}---
Anthropic/AWS Bedrock Normalization
The Problem: Anthropic rejects empty messages and requires sanitized tool IDs.
Without transforms (broken):
const messages = [
{ role: "user", content: "Hi" },
{ role: "assistant", content: "" }, // Empty!
{ role: "user", content: "Hello?" },
]
// Anthropic Error: "Messages must not be empty"With transforms (works):
// Transform filters empty messages
const normalized = normalizeAnthropicMessages(messages)
// Result: Empty assistant message removed
// Works with Anthropic ✓Implementation:
function normalizeAnthropicMessages(msgs: ModelMessage[]): ModelMessage[] {
return msgs
.map((msg) => {
// Remove empty string content
if (typeof msg.content === "string" && msg.content === "") {
return undefined
}
// Remove empty arrays
if (Array.isArray(msg.content)) {
const filtered = msg.content.filter((part) => {
if (part.type === "text" || part.type === "reasoning") {
return part.text !== ""
}
return true
})
if (filtered.length === 0) return undefined
msg = { ...msg, content: filtered }
}
// Sanitize tool IDs (replace special chars with _)
if (Array.isArray(msg.content)) {
msg.content = msg.content.map((part) => {
if (part.type === "tool-call" || part.type === "tool-result") {
return {
...part,
toolCallId: part.toolCallId.replace(/[^a-zA-Z0-9_-]/g, "_"),
}
}
return part
})
}
return msg
})
.filter((msg): msg is ModelMessage => msg !== undefined)
}---
Mistral/Devstral Normalization
The Problem: Mistral has TWO quirks:
1. Tool call IDs must be exactly 9 alphanumeric characters 2. Tool messages cannot be immediately followed by user messages
Without transforms (broken):
const messages = [
{ role: "user", content: "Run the test" },
{
role: "assistant",
content: [{ type: "tool-call", toolCallId: "call_abc123", toolName: "runTest", args: {} }],
},
{
role: "tool",
content: [{ type: "tool-result", toolCallId: "call_abc123", toolName: "runTest", result: "PASS" }],
},
{ role: "user", content: "Great!" }, // Immediately after tool!
]
// Mistral Error: "Invalid message sequence"With transforms (works):
const normalized = normalizeMistralMessages(messages)
// Result: Inserts assistant message between tool and user
// Works with Mistral ✓Implementation:
function normalizeMistralMessages(msgs: ModelMessage[]): ModelMessage[] {
const sanitizeId = (id: string) => {
return id
.replace(/[^a-zA-Z0-9]/g, "") // Remove non-alphanumeric
.substring(0, 9) // Max 9 chars
.padEnd(9, "0") // Pad to exactly 9
}
const result: ModelMessage[] = []
for (let i = 0; i < msgs.length; i++) {
const msg = msgs[i]
const nextMsg = msgs[i + 1]
// Sanitize tool IDs in this message
if (Array.isArray(msg.content)) {
msg.content = msg.content.map((part) => {
if (part.type === "tool-call" || part.type === "tool-result") {
return { ...part, toolCallId: sanitizeId(part.toolCallId) }
}
return part
})
}
result.push(msg)
// Fix sequence: tool → user is invalid, insert assistant
if (msg.role === "tool" && nextMsg?.role === "user") {
result.push({
role: "assistant",
content: [{ type: "text", text: "Done." }],
})
}
}
return result
}---
Default Parameters by Model
The Problem: Different models need different temperature/topP settings for optimal results.
| Model Family | Temperature | Top P | Top K | Why |
|---|---|---|---|---|
| GPT-4 | - | - | - | Provider defaults work well |
| Claude | - | - | - | Provider defaults work well |
| Gemini | 1.0 | 0.95 | 64 | Google's recommended values |
| Qwen | 0.55 | 1.0 | - | Lower temp for reasoning |
| Minimax | 1.0 | 0.95 | 20/40 | Varies by variant |
| Kimi | 0.6-1.0 | 0.95 | - | Varies by variant |
Implementation:
function getDefaultParameters(modelId: string) {
const id = modelId.toLowerCase()
let temperature: number | undefined
let topP: number | undefined
let topK: number | undefined
// Temperature
if (id.includes("qwen")) temperature = 0.55
else if (id.includes("gemini")) temperature = 1.0
else if (id.includes("minimax")) temperature = 1.0
else if (id.includes("kimi-k2")) {
temperature = id.includes("thinking") ? 1.0 : 0.6
}
// Top P
if (id.includes("qwen")) topP = 1.0
else if (id.includes("gemini") || id.includes("minimax") || id.includes("kimi")) {
topP = 0.95
}
// Top K
if (id.includes("minimax-m2")) {
topK = id.includes("m25") ? 40 : 20
} else if (id.includes("gemini")) {
topK = 64
}
return { temperature, topP, topK }
}
// Usage
const params = getDefaultParameters("gemini-pro")
const result = await streamText({
model: google("gemini-pro"),
...params, // Spread in the defaults
messages,
})---
Caching Implementation
The Problem: Repeated calls with the same context waste tokens and money.
Solution: Mark messages for caching (Anthropic/Bedrock/OpenRouter support this).
Before caching (expensive):
// Each call sends full context - $$$ adds up
await streamText({ model, messages: [systemPrompt, ...longContext] })
await streamText({ model, messages: [systemPrompt, ...longContext] })
await streamText({ model, messages: [systemPrompt, ...longContext] })
// Pay for systemPrompt 3 timesAfter caching (cheaper):
// Cache system prompt - only pay once
const cachedMessages = applyCaching(messages, "anthropic")
await streamText({ model, messages: cachedMessages })
await streamText({ model, messages: cachedMessages })
await streamText({ model, messages: cachedMessages })
// Pay for systemPrompt 1 time + small cache read feeImplementation:
function applyCaching(msgs: ModelMessage[], providerId: string): ModelMessage[] {
const system = msgs.filter((m) => m.role === "system").slice(0, 2)
const final = msgs.filter((m) => m.role !== "system").slice(-2)
const toCache = [...system, ...final]
const cacheConfigs: Record<string, any> = {
anthropic: { cacheControl: { type: "ephemeral" } },
openrouter: { cacheControl: { type: "ephemeral" } },
bedrock: { cachePoint: { type: "default" } },
}
const cacheConfig = cacheConfigs[providerId]
if (!cacheConfig) return msgs
return msgs.map((msg) => {
if (!toCache.includes(msg)) return msg
// Anthropic/Bedrock: Apply at message level
if (["anthropic", "bedrock"].includes(providerId)) {
return {
...msg,
providerOptions: { ...msg.providerOptions, ...cacheConfig },
}
}
// Others: Apply to last content part
if (Array.isArray(msg.content) && msg.content.length > 0) {
const lastPart = msg.content[msg.content.length - 1]
if (lastPart && typeof lastPart === "object") {
lastPart.providerOptions = {
...lastPart.providerOptions,
...cacheConfig,
}
}
}
return msg
})
}---
Reasoning/Thinking Support
The Problem: Some models (Claude Opus 4.6, Grok 3, o1/o3) support "thinking mode" for complex reasoning.
Without reasoning:
// Model answers immediately
// Good for simple questions
// May miss complex logicWith reasoning:
// Model thinks step-by-step internally
// Better for math, logic, complex analysis
// Costs more tokens (thinking uses tokens)Implementation:
function getReasoningOptions(modelId: string) {
const id = modelId.toLowerCase()
// Claude thinking mode
if (id.includes("opus-4-6") || id.includes("sonnet-4-6")) {
return {
thinking: { type: "enabled", budget_tokens: 16000 },
}
}
// Grok reasoning
if (id.includes("grok-3-mini")) {
return { reasoningEffort: "high" }
}
// OpenAI reasoning models
if (id.includes("o1") || id.includes("o3")) {
return { reasoningEffort: "medium" } // low | medium | high
}
return {}
}
// Usage
const reasoning = getReasoningOptions("claude-opus-4-6")
const result = await streamText({
model,
...reasoning,
messages,
})---
Provider Options Key Mapping
The Problem: Some providers use different keys in providerOptions.
Example: OpenRouter expects openrouter key, not openrouter-provider.
Implementation:
const PROVIDER_KEY_MAP: Record<string, string> = {
"github-copilot": "copilot",
"amazon-bedrock": "bedrock",
"google-vertex": "vertex",
gateway: "gateway",
}
function remapProviderOptions(options: Record<string, any>, providerId: string): Record<string, any> {
const sdkKey = PROVIDER_KEY_MAP[providerId]
if (!sdkKey || sdkKey === providerId) return options
const remapped = { ...options }
if (providerId in remapped) {
remapped[sdkKey] = remapped[providerId]
delete remapped[providerId]
}
return remapped
}---
Modality Filtering
The Problem: Not all models support all input types (images, audio, PDFs).
Without filtering (broken):
// User sends image to text-only model
const messages = [
{
role: "user",
content: [
{ type: "text", text: "Describe this" },
{ type: "image", image: "data:image/png;base64,..." },
],
},
]
// Model doesn't support images → ErrorWith filtering (graceful):
// Filter removes unsupported parts, adds error message
const filtered = filterUnsupportedParts(messages, ["text"])
// Result: Image replaced with error text
// Model receives: "Describe this [Image not supported]"Implementation:
function filterUnsupportedParts(msgs: ModelMessage[], supportedModalities: string[]): ModelMessage[] {
return msgs.map((msg) => {
if (msg.role !== "user" || !Array.isArray(msg.content)) return msg
const filtered = msg.content.map((part) => {
if (part.type === "image") {
const imageStr = part.image.toString()
// Check if image is empty
if (imageStr.startsWith("data:")) {
const match = imageStr.match(/^data:([^;]+);base64,(.*)$/)
if (match && (!match[2] || match[2].length === 0)) {
return {
type: "text",
text: "ERROR: Image file is empty or corrupted.",
}
}
}
// Check if images are supported
if (!supportedModalities.includes("image")) {
return {
type: "text",
text: "ERROR: This model does not support image input.",
}
}
}
return part
})
return { ...msg, content: filtered }
})
}---
Complete Transform Pipeline
Putting it all together:
class ProviderTransform {
transformMessages(
msgs: ModelMessage[],
providerId: string,
modelId: string,
capabilities: { input: string[] },
): ModelMessage[] {
// 1. Remove unsupported content types
msgs = filterUnsupportedParts(msgs, capabilities.input)
// 2. Apply provider-specific normalization
if (providerId === "anthropic" || providerId.includes("bedrock")) {
msgs = normalizeAnthropicMessages(msgs)
} else if (providerId.includes("mistral")) {
msgs = normalizeMistralMessages(msgs)
}
// 3. Apply caching
msgs = applyCaching(msgs, providerId)
// 4. Remap provider options keys
msgs = msgs.map((msg) => ({
...msg,
providerOptions: remapProviderOptions(msg.providerOptions, providerId),
}))
return msgs
}
getDefaultParams(modelId: string) {
return getDefaultParameters(modelId)
}
getSystemPrompt(modelId: string) {
return selectSystemPrompt(modelId)
}
}Usage:
const transform = new ProviderTransform()
// Before sending to any provider
const normalized = transform.transformMessages(rawMessages, "anthropic", "claude-3-sonnet", {
input: ["text", "image"],
})
const result = await streamText({
model: anthropic("claude-3-sonnet"),
messages: normalized,
})---
Provider Quirks Quick Reference
| Provider | Quirks | Transform Function |
|---|---|---|
| Anthropic | Empty messages, tool ID chars | normalizeAnthropicMessages |
| AWS Bedrock | Same as Anthropic | normalizeAnthropicMessages |
| Mistral | 9-char tool IDs, sequence fix | normalizeMistralMessages |
| Devstral | Same as Mistral | normalizeMistralMessages |
| Gemini | Temp=1.0, TopP=0.95, TopK=64 | getDefaultParameters |
| Claude | Provider defaults | getDefaultParameters |
| Kimi | Temp varies by variant | getDefaultParameters |
| Qwen | Temp=0.55, TopP=1.0 | getDefaultParameters |
| Minimax | Temp=1.0, TopP=0.95, TopK=20/40 | getDefaultParameters |
| Grok | Reasoning effort variants | getReasoningOptions |
| OpenAI o1/o3 | Reasoning effort control | getReasoningOptions |
---
Debugging Transforms
Enable logging to see what transforms are doing:
const DEBUG = process.env.DEBUG === "true"
function logTransform(stage: string, before: any, after: any) {
if (DEBUG) {
console.log(`[Transform: ${stage}]`)
console.log("Before:", JSON.stringify(before, null, 2))
console.log("After:", JSON.stringify(after, null, 2))
console.log("---")
}
}
// Usage in transform
function normalizeAnthropicMessages(msgs: ModelMessage[]): ModelMessage[] {
logTransform("anthropic-start", msgs, null)
const result = /* ... normalization logic ... */ logTransform("anthropic-end", null, result)
return result
}Run with debugging:
DEBUG=true bun run agent.tsTroubleshooting Guide
Real errors you'll encounter and exactly how to fix them.
Before You Start Debugging
Enable debug logging to see what's happening:
const DEBUG = process.env.DEBUG === "true"
function log(stage: string, data: any) {
if (DEBUG) console.log(`[${stage}]`, data)
}
// Run with:
// DEBUG=true bun run agent.ts---
Installation Errors
Error: "Cannot find module 'ai'"
error: Cannot find module 'ai'
Require stack:
- /path/to/agent.tsCause: Dependencies not installed Fix:
npm install ai @ai-sdk/openai zod
# Or with Bun:
bun add ai @ai-sdk/openai zod---
Error: "Cannot find module 'dotenv/config'"
error: Cannot find module 'dotenv/config'Fix:
npm install dotenvOr remove the import if not using .env files:
// Remove this line:
import "dotenv/config"
// And set env vars directly:
process.env.OPENAI_API_KEY = "sk-..."---
API Key Errors
Error: "API key required"
Error: API key requiredCause: OPENAI_API_KEY environment variable not set Fix:
# Option 1: Export in terminal
export OPENAI_API_KEY="sk-your-key-here"
# Option 2: Create .env file
echo "OPENAI_API_KEY=sk-your-key-here" > .env
# Option 3: Set in code (not recommended for production)
process.env.OPENAI_API_KEY = "sk-..."---
Error: "Invalid API key"
Error: 401 Unauthorized
{
"error": {
"message": "Incorrect API key provided",
"type": "invalid_request_error"
}
}Cause: API key is wrong or revoked Check:
1. Copy key from provider dashboard (no extra spaces) 2. Key format should be:
- OpenAI:
sk-...(starts with "sk-") - Anthropic:
sk-ant-...(starts with "sk-ant-")
Fix: Regenerate key from provider dashboard
---
Error: "No credentials found for provider"
Error: Provider not found: openai. Registered:Cause: Provider not registered because env var was missing Fix: Check your setup code:
// Add logging to see what's happening:
if (process.env.OPENAI_API_KEY) {
console.log("Registering OpenAI") // Should see this
registry.register(new OpenAIAdapter(process.env.OPENAI_API_KEY))
} else {
console.log("OPENAI_API_KEY not set") // If you see this, check .env
}---
Model Errors
Error: "Model not found"
Error: 404 Not Found
{
"error": {
"message": "The model 'gpt-5' does not exist",
"type": "invalid_request_error"
}
}Cause: Model ID is wrong or outdated Fix: Check current model IDs:
// These change frequently! Check provider docs:
// OpenAI: https://platform.openai.com/docs/models
// Anthropic: https://docs.anthropic.com/claude/docs/models-overview
// Common valid IDs:
const validModels = {
openai: ["gpt-4o", "gpt-4o-mini", "o1", "o3-mini"],
anthropic: ["claude-3-opus", "claude-3-sonnet", "claude-3-haiku"],
}---
Error: "Context length exceeded"
Error: 400 Bad Request
{
"error": {
"message": "This model's maximum context length is 128000 tokens...",
"type": "invalid_request_error",
"code": "context_length_exceeded"
}
}Cause: Conversation too long for model Fix - Truncate messages:
function truncateMessages(messages: any[], maxTokens: number = 120000): any[] {
// Rough token estimate: 1 token ≈ 4 characters
const estimateTokens = (text: string) => Math.ceil(text.length / 4)
let totalTokens = 0
const truncated = []
// Add messages from the end (most recent) until we hit the limit
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]
const content = typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content)
const tokens = estimateTokens(content)
if (totalTokens + tokens > maxTokens) break
totalTokens += tokens
truncated.unshift(msg) // Add to front since we're iterating backwards
}
return truncated
}
// Usage
const truncated = truncateMessages(allMessages, 120000)
const result = await streamText({ model, messages: truncated })---
Tool Calling Errors
Error: "maxSteps reached"
[AgentSystem] maxSteps reached after 10 stepsCause: Agent kept calling tools without finishing Common reasons:
1. Tool results are unclear 2. Agent is confused 3. maxSteps too low for complex task
Fix:
// Option 1: Increase maxSteps
const result = await streamText({
model,
tools,
maxSteps: 20, // Increase from default 10
messages,
})
// Option 2: Check if tools return clear results
const myTool = tool({
description: "...",
parameters: z.object({ ... }),
execute: async (args) => {
const result = await doSomething(args)
// Make result clear and actionable
return {
success: true,
summary: "Completed X successfully", // Clear summary
details: result,
}
},
})---
Error: "Tool execution failed"
Error: Tool execution failed: Cannot read property 'map' of undefinedCause: Your tool's execute() function threw an error Fix - Add error handling:
const myTool = tool({
description: "...",
parameters: z.object({ ... }),
execute: async (args) => {
try {
return await doSomething(args)
} catch (error) {
// Return error in a format the AI can understand
return {
error: true,
message: error instanceof Error ? error.message : String(error),
}
}
},
})---
Error: "Invalid tool parameters"
Error: 400 Bad Request
{
"error": {
"message": "Invalid schema for function 'myTool': ..."
}
}Cause: Zod schema is wrong or too complex Fix: Keep schemas simple
// BAD - Complex nested objects
const badSchema = z.object({
data: z.object({
nested: z.object({
deep: z.string(),
}),
}),
})
// GOOD - Flat, simple schema
const goodSchema = z.object({
query: z.string().describe("Search query"),
limit: z.number().optional().describe("Max results (default: 10)"),
})
const myTool = tool({
description: "Search for files. Use this when the user asks to find something.",
parameters: goodSchema,
execute: async ({ query, limit = 10 }) => {
// ...
},
})---
Provider-Specific Errors
Error: "Invalid tool call ID format" (Mistral)
Error: 400 Bad Request
{
"message": "Tool call ID must be alphanumeric and max 9 characters"
}Cause: Mistral requires 9-char alphanumeric tool IDs Fix: Apply Mistral transform
// Add to your transform pipeline
if (providerId.includes("mistral")) {
messages = normalizeMistralMessages(messages)
}
// Or inline fix:
const sanitizeMistralId = (id: string) =>
id
.replace(/[^a-zA-Z0-9]/g, "")
.substring(0, 9)
.padEnd(9, "0")See provider-transforms.md for full implementation.
---
Error: "Invalid message sequence" (Mistral)
Error: 400 Bad Request
{
"message": "Invalid message sequence: tool message cannot be followed by user message"
}Fix: Insert assistant message between tool and user
function fixMistralSequence(messages: any[]): any[] {
const result = []
for (let i = 0; i < messages.length; i++) {
result.push(messages[i])
// Check if current is tool and next is user
if (messages[i].role === "tool" && messages[i + 1]?.role === "user") {
result.push({
role: "assistant",
content: "Done.",
})
}
}
return result
}---
Error: "Empty messages not allowed" (Anthropic)
Error: 400 Bad Request
{
"error": {
"message": "messages: all messages must have non-empty content"
}
}Fix: Filter empty messages
function filterEmptyMessages(messages: any[]): any[] {
return messages.filter((msg) => {
if (typeof msg.content === "string") {
return msg.content.trim() !== ""
}
if (Array.isArray(msg.content)) {
return msg.content.some((part: any) => {
if (part.type === "text") return part.text !== ""
return true
})
}
return true
})
}---
Rate Limiting Errors
Error: "Rate limit exceeded"
Error: 429 Too Many Requests
{
"error": {
"message": "Rate limit reached for requests",
"type": "rate_limit_error"
}
}Fix - Add retry with exponential backoff:
async function withRetry<T>(fn: () => Promise<T>, maxRetries = 3): Promise<T> {
for (let i = 0; i < maxRetries; i++) {
try {
return await fn()
} catch (error: any) {
if (error.message?.includes("rate limit")) {
const delay = Math.pow(2, i) * 1000 // 1s, 2s, 4s
console.log(`Rate limited. Retrying in ${delay}ms...`)
await new Promise((r) => setTimeout(r, delay))
continue
}
throw error
}
}
throw new Error("Max retries exceeded")
}
// Usage
const result = await withRetry(() => streamText({ model, tools, messages }))---
Error: "Rate limit: Tokens per minute"
Error: 429 Too Many Requests
{
"error": {
"message": "Rate limit reached for tokens per minute"
}
}Cause: Sending too much text too quickly Fix: Reduce context size or add delays between calls
// Add delay between calls
await new Promise((r) => setTimeout(r, 1000))
// Or reduce context
const truncated = messages.slice(-10) // Only last 10 messages---
Streaming Errors
Error: "Stream unexpectedly ended"
Error: Stream unexpectedly endedCause: Network interruption or provider error Fix - Add resilient streaming:
async function* resilientStream(model: any, messages: any[]) {
let attempts = 0
const maxAttempts = 3
while (attempts < maxAttempts) {
try {
const result = await streamText({ model, messages })
for await (const chunk of result.textStream) {
yield chunk
}
return // Success
} catch (error) {
attempts++
if (attempts >= maxAttempts) throw error
console.log(`Stream failed, retry ${attempts}/${maxAttempts}...`)
await new Promise((r) => setTimeout(r, 1000 * attempts))
}
}
}
// Usage
for await (const chunk of resilientStream(model, messages)) {
process.stdout.write(chunk)
}---
Error: "Cannot read stream"
TypeError: result.textStream is not async iterableCause: Using wrong function (generateText instead of streamText) Fix:
// WRONG - generateText doesn't stream
const result = await generateText({ model, messages })
for await (const chunk of result.textStream) { // Error!
// CORRECT - use streamText for streaming
const result = await streamText({ model, messages })
for await (const chunk of result.textStream) { // Works!---
Authentication Errors
Error: "OAuth token expired"
Error: OAuth token expired at 1234567890Fix - Auto-refresh token:
async function getValidToken(providerId: string): Promise<string> {
const auth = await authStore.get(providerId)
if (auth.type !== "oauth") {
throw new Error("Not OAuth auth")
}
// Check expiration with 5-minute buffer
if (auth.expiresAt < Date.now() / 1000 + 300) {
console.log("Token expired, refreshing...")
const refreshed = await refreshOAuthToken(auth.refreshToken)
await authStore.set(providerId, refreshed)
return refreshed.accessToken
}
return auth.accessToken
}---
Debugging Checklist
When something breaks:
- [ ] Check API key is set:
echo $OPENAI_API_KEY - [ ] Check dependencies installed:
npm list ai - [ ] Enable debug logging:
DEBUG=true bun run agent.ts - [ ] Test with simple prompt: Start with "Hello" not complex tasks
- [ ] Use cheap model: Test with
gpt-4o-mininotgpt-4o - [ ] Check model ID: Verify it's current on provider's website
- [ ] Reduce context: Try with just 1-2 messages
- [ ] Check tool schemas: Simplify if complex
- [ ] Add try/catch: Wrap execute() functions
- [ ] Check rate limits: Wait a minute and retry
---
Getting Help
If you're stuck:
1. Enable DEBUG mode (see above) 2. Try the simplest possible case (one tool, one message) 3. Check provider status (OpenAI/Anthropic status pages) 4. Test the raw SDK (without your wrapper code) 5. Read the AI SDK docs: https://sdk.vercel.ai/docs