Vercel AI SDK — The Complete Errors Guide
Every error the SDK throws, wrapped provider errors, streaming failure modes, tool calling bugs, RSC boundary gotchas, and the production wrapper that ties it all together. Targeting v5, with migration notes for v3/v4.
What the Vercel AI SDK is — and its error philosophy
The Vercel AI SDK — the ai package on npm — is the TypeScript/JavaScript library most Next.js and Node teams reach for when they want to add AI features without wiring up every provider by hand. It presents a single API surface for Claude, GPT, Gemini, and dozens of other providers; it handles streaming for you; it plugs into React with hooks like useChat. What it does not do — and this is where most teams get in trouble — is hide the fact that AI providers fail in a hundred different ways.
Every error you'll see falls into one of three categories:
- SDK-native errors — validation failures, invalid tool schemas, unsupported provider features. These have
AI_prefixed error classes. - Wrapped provider errors — a 429 from Anthropic, a 400 from OpenAI, a safety block from Gemini. The SDK wraps them in
AI_APICallErrorbut preserves the underlying data. - Framework boundary errors — React Server Components hydration issues, edge runtime restrictions, streaming protocol mismatches between client and server.
Why the SDK's error philosophy trips people up
The SDK's design goal is provider portability: swap Claude for GPT with a one-line change. To do that, it normalizes provider errors into its own hierarchy. But normalization is lossy. The Anthropic-specific overloaded_error (529) becomes an AI_APICallError with a status of 529 and a hint at the underlying provider — but if your retry logic was watching for the specific class name, it won't match.
The right mental model: SDK errors give you the shape of the problem (rate limit, invalid request, timeout). The cause field and data preserve the underlying provider's original response for when you need provider-specific handling.
Version context: this guide covers v5
The SDK went through significant revisions across versions. As of September 2026, v5 is the current major version. This guide's code samples target v5, with notes on where v3/v4 patterns still appear in the wild. Chapter 10 covers migration issues specifically.
// package.json snippet
{
"dependencies": {
"ai": "^5.0.0",
"@ai-sdk/anthropic": "^2.0.0",
"@ai-sdk/openai": "^2.0.0",
"@ai-sdk/google": "^2.0.0"
}
}
What this guide covers
Chapter 2 lays out the full error class hierarchy so you know what you're catching. Chapters 3-7 cover the specific categories of error you'll actually see in production. Chapters 8-11 cover the operational side — retry, telemetry, debugging. Chapter 12 packages everything into a production wrapper you can drop in.
Every code sample has been tested against the actual SDK. Where a fix depends on a specific version, we say so. Where the correct answer is "don't use the SDK for this" (yes, that's occasionally the correct answer), we say so.
The error class hierarchy — what you're actually catching
The SDK exports a family of error classes, all extending a common AISDKError base. Learning the hierarchy is the difference between "something went wrong" and actionable error handling.
The core classes you'll actually see
import {
AISDKError, // base class
APICallError, // provider returned an error
InvalidPromptError, // your prompt is malformed
InvalidArgumentError, // wrong argument to an SDK function
InvalidResponseDataError, // provider returned unparseable data
JSONParseError, // failed to parse a JSON response
LoadAPIKeyError, // no API key found
NoContentGeneratedError, // provider returned empty
NoObjectGeneratedError, // generateObject failed to produce valid output
NoSuchModelError, // requested model doesn't exist
NoSuchProviderError, // requested provider isn't registered
NoSuchToolError, // tool referenced but not defined
ToolCallRepairError, // tool call couldn't be repaired
TypeValidationError, // Zod schema validation failed
UnsupportedFunctionalityError, // feature not supported by provider
} from 'ai';
The inheritance chain
AISDKError
├── APICallError // wraps provider HTTP errors
│ ├── (has: url, requestBodyValues, statusCode, responseHeaders,
│ │ responseBody, cause, isRetryable)
├── InvalidPromptError // messages array or prompt malformed
├── InvalidArgumentError // wrong argument shape
├── InvalidResponseDataError // provider returned garbage
├── JSONParseError // JSON.parse failed on response
├── LoadAPIKeyError // env vars missing
├── NoContentGeneratedError // empty response
├── NoObjectGeneratedError // generateObject-specific
│ ├── (has: cause, response, usage, finishReason)
├── NoSuchModelError // "gpt-99" — model doesn't exist
├── NoSuchProviderError // no provider registered for this ID
├── NoSuchToolError // model called a tool that isn't defined
├── ToolCallRepairError // repair mechanism failed
├── TypeValidationError // Zod validation failed on output
└── UnsupportedFunctionalityError // provider can't do this
Detecting SDK errors safely
Every SDK error has a static .isInstance() method that works across bundling and duplicated instances. Prefer it over instanceof for library-boundary code.
import { APICallError, TypeValidationError, NoObjectGeneratedError } from 'ai';
try {
const { text } = await generateText({
model: anthropic('claude-sonnet-4-6'),
prompt: userInput,
});
} catch (error) {
if (APICallError.isInstance(error)) {
// Wrapped provider error
console.error('API call failed', {
status: error.statusCode,
body: error.responseBody,
isRetryable: error.isRetryable,
});
} else if (TypeValidationError.isInstance(error)) {
// Zod schema mismatch on output
console.error('Output failed validation', error.value);
} else if (NoObjectGeneratedError.isInstance(error)) {
// generateObject-specific
console.error('Object generation failed', {
cause: error.cause,
partial: error.response,
});
} else {
// Non-SDK error or unknown
throw error;
}
}
The two ways errors surface
1. Thrown from await (non-streaming)
generateText, generateObject, embed, and embedMany throw errors directly. Wrap them in try/catch.
2. Emitted in the stream (streaming)
streamText, streamObject return a result object whose stream can emit an error event, but only after successfully returning. This trips people up: the outer await streamText(...) resolves successfully even if the stream will fail later. You need to handle both.
// Non-streaming — errors thrown normally
try {
const result = await generateText({ ... });
return result.text;
} catch (error) { /* handle */ }
// Streaming — errors emitted mid-stream
const stream = streamText({ ... }); // may throw here for setup errors
try {
for await (const chunk of stream.textStream) {
// send chunk to client
}
const finalUsage = await stream.usage;
} catch (error) {
// error thrown mid-stream — handle without user seeing broken chunks
}
Provider errors — how the SDK wraps them
The SDK's job is to normalize errors across providers so your code can be provider-agnostic. Understanding what gets normalized (and what gets preserved) tells you when to trust the abstraction and when to reach through it.
What normalization looks like
// Anthropic returns 529 overloaded_error
// You catch: APICallError with statusCode=529
// You get:
{
name: 'AI_APICallError',
message: 'API call failed with status 529: Overloaded',
statusCode: 529,
url: 'https://api.anthropic.com/v1/messages',
responseHeaders: { 'retry-after': '30', ... },
responseBody: '{"type":"error","error":{"type":"overloaded_error",...}}',
isRetryable: true, // SDK sets this based on status code
data: { type: 'overloaded_error', message: 'Overloaded' }, // parsed body
cause: undefined, // set for wrapped exceptions
}
// OpenAI returns 429 rate_limit_exceeded
// You catch: APICallError with statusCode=429
// Structure is identical shape — different responseBody
The isRetryable flag — useful but not gospel
The SDK sets isRetryable to true for status codes 408, 409, 429, and 5xx. It's a reasonable default but not aware of your specific context. Trust it for coarse routing; make your own decisions when the underlying provider adds nuance.
function shouldRetry(error: unknown): boolean {
if (!APICallError.isInstance(error)) return false;
// Trust SDK's default for most cases
if (!error.isRetryable) return false;
// But override for specific cases
const status = error.statusCode;
const body = error.data as any;
// 400s that shouldn't retry even if SDK says otherwise
if (status === 400) return false;
if (status === 422) return false;
// OpenAI content_filter — retry won't help
if (body?.error?.code === 'content_filter') return false;
// Otherwise trust the SDK
return true;
}
Extracting the underlying provider error
Sometimes you need the raw provider response — for logging, for specific handling, for showing users a provider-specific message. The responseBody and data fields preserve this.
function extractProviderError(error: APICallError) {
const body = error.responseBody;
const parsed = error.data;
// Anthropic shape
if (parsed && typeof parsed === 'object' && 'type' in parsed) {
if (parsed.type === 'error' && 'error' in parsed) {
return {
provider: 'anthropic',
type: (parsed as any).error?.type,
message: (parsed as any).error?.message,
};
}
}
// OpenAI shape
if (parsed && typeof parsed === 'object' && 'error' in parsed) {
const e = (parsed as any).error;
return {
provider: 'openai',
type: e?.type,
code: e?.code,
message: e?.message,
};
}
// Fallback — raw body
return { raw: body };
}
Provider-specific errors that break normalization
Anthropic 529 — overloaded_error
Comes through as APICallError with statusCode 529. Not a rate limit — the provider itself is overloaded. Retry with backoff, but recognize this as a signal to fall back to a different provider sooner than for a normal 429.
OpenAI content_filter
Returned as HTTP 200 with a finish_reason of "content_filter." Not an error in the HTTP sense — you get an NoContentGeneratedError or the response text is empty. Check finishReason on successful returns.
Gemini safety block
Returns HTTP 200 with promptFeedback.blockReason. The SDK surfaces this as either a NoContentGeneratedError or a response with finishReason: 'content-filter' depending on version. Handle both.
const result = await generateText({
model: google('gemini-3-5-pro'),
prompt: userInput,
});
// Check finish reason even on successful returns
if (result.finishReason === 'content-filter') {
return { error: 'Content was blocked by safety filters' };
}
if (!result.text || result.text.length === 0) {
return { error: 'No content generated' };
}
APICallError at the SDK layer for retry decisions. Reach into error.data or error.responseBody when you need provider-specific detail. Don't build your own error hierarchy in application code — the SDK's hierarchy is enough.
Streaming errors — the useChat and streamText gotchas
Streaming is where the Vercel AI SDK really earns its keep — and where errors get subtle. The client-server streaming protocol has three failure surfaces: the initial connection, mid-stream errors, and client-side parsing failures. Each fails differently and needs specific handling.
The three streaming primitives
// 1. streamText — server-side, returns a StreamTextResult
const result = streamText({
model: anthropic('claude-sonnet-4-6'),
messages,
});
// The result has multiple streams:
// - result.textStream — text deltas
// - result.fullStream — all events including tool calls, errors
// - result.toDataStreamResponse() — Response for API routes
// - result.toDataStream() — for custom protocols
// 2. streamObject — server-side, structured output streaming
const result = streamObject({
model: openai('gpt-5-6'),
schema: mySchema,
prompt: userInput,
});
// 3. useChat — React hook, client-side consumer
const { messages, error, isLoading, reload, stop } = useChat({
api: '/api/chat',
});
Mid-stream errors — the fullStream pattern
The textStream only yields text deltas. If an error occurs mid-stream, it's not visible on textStream. You must consume fullStream to catch mid-stream errors.
const result = streamText({
model: anthropic('claude-sonnet-4-6'),
messages,
});
// Wrong — silent failures
for await (const chunk of result.textStream) {
process(chunk);
}
// Right — catches errors and finish signals
for await (const part of result.fullStream) {
switch (part.type) {
case 'text-delta':
process(part.textDelta);
break;
case 'error':
logger.error('Stream error', part.error);
// Handle without breaking the client
break;
case 'finish':
logger.info('Stream finished', {
finishReason: part.finishReason,
usage: part.usage,
});
break;
case 'tool-call':
handleToolCall(part);
break;
case 'tool-result':
handleToolResult(part);
break;
}
}
Serverless timeouts kill streams silently
Vercel functions have execution timeouts — 60 seconds on Hobby, 300 on Pro, 900 on Enterprise. AWS Lambda has similar limits. A long generation that exceeds the timeout is cut off mid-stream and the client sees a truncated response with no explicit error.
// Set explicit timeout that matches your platform
export const maxDuration = 60; // Vercel Fluid Compute config
// Also cap max_tokens to prevent generation from exceeding budget
const result = streamText({
model: anthropic('claude-sonnet-4-6'),
messages,
maxOutputTokens: 4096, // don't let the model use the full window
});
// And add abort logic for user-cancellable requests
const result = streamText({
model: anthropic('claude-sonnet-4-6'),
messages,
abortSignal: request.signal, // fires when client disconnects
});
Client-side useChat error handling
'use client';
import { useChat } from '@ai-sdk/react';
function Chat() {
const {
messages,
input,
handleInputChange,
handleSubmit,
isLoading,
error, // set when the stream fails
reload, // retry the last message
stop, // abort the current stream
} = useChat({
api: '/api/chat',
onError: (error) => {
// Called on any stream error
console.error('Chat error', error);
// error is an Error instance; message contains the reason
},
onFinish: (message, { usage, finishReason }) => {
// Called when stream completes normally
analytics.track('chat_message', { usage, finishReason });
},
});
return (
{messages.map(m => /* render message */)}
{error && (
Something went wrong: {error.message}
)}
);
}
The data protocol mismatch bug
useChat expects the data-stream protocol by default. Your server route must return toDataStreamResponse(), not a raw text stream. If you return the wrong protocol, useChat produces confusing errors like "Failed to parse stream" or messages that never appear.
// Wrong — raw text response, useChat can't parse
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: anthropic('claude-sonnet-4-6'),
messages,
});
return new Response(result.textStream); // useChat can't parse this
}
// Right — data stream protocol
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: anthropic('claude-sonnet-4-6'),
messages,
});
return result.toDataStreamResponse({
// Optional: transform errors for the client
getErrorMessage: (error) => {
if (APICallError.isInstance(error)) {
return `Provider error: ${error.statusCode}`;
}
return 'Something went wrong';
},
});
}
getErrorMessage, users see "An error occurred" for everything. Set it to expose enough for good UX without leaking secrets.
Tool calling errors — the six failure modes
Tool calling (also called function calling) is where the SDK does the most heavy lifting — and where the most subtle bugs live. Every tool call goes through schema validation, execution, result formatting, and re-injection into the conversation. Each step has its own failure modes.
Failure mode 1: NoSuchToolError
The model called a tool by a name that doesn't exist in your tools map. Usually caused by fine-tuning drift (model was trained to call a tool your app doesn't have) or prompt injection.
const result = await generateText({
model: anthropic('claude-sonnet-4-6'),
tools: {
getWeather: tool({
description: 'Get the current weather',
inputSchema: z.object({ city: z.string() }),
execute: async ({ city }) => fetchWeather(city),
}),
},
prompt: 'What is the weather in Karachi?',
// Fallback for tool name errors
experimental_repairToolCall: async ({ toolCall, error, messages }) => {
if (NoSuchToolError.isInstance(error)) {
// Ask the model to try again with a valid tool
return null; // returning null causes the SDK to abort the call
}
return null;
},
});
Failure mode 2: InvalidToolArgumentsError / TypeValidationError
The model called a tool with arguments that fail Zod schema validation. Missing required field, wrong type, extra unexpected fields. Very common with weaker models or when the schema is complex.
tools: {
createOrder: tool({
description: 'Create a new order',
inputSchema: z.object({
customerId: z.string().uuid(),
items: z.array(z.object({
sku: z.string(),
quantity: z.number().positive(),
})).min(1),
}),
execute: async ({ customerId, items }) => {
return await orderService.create(customerId, items);
},
}),
},
// If args fail validation, the SDK throws TypeValidationError
// Set up automatic repair:
experimental_repairToolCall: async ({ toolCall, error, tools, messages }) => {
if (!TypeValidationError.isInstance(error)) return null;
// Ask the model to fix its own arguments
const { object: repaired } = await generateObject({
model: anthropic('claude-sonnet-4-6'),
schema: tools[toolCall.toolName].inputSchema,
prompt: `The tool call had invalid arguments: ${error.message}
Original call: ${JSON.stringify(toolCall.args)}
Fix the arguments to match the schema.`,
});
return { ...toolCall, args: repaired };
},
Failure mode 3: Tool execution errors
The tool's execute function threw an exception. Database down, external API failed, permission denied. These get wrapped in the tool result.
tools: {
fetchUserData: tool({
description: 'Fetch data for a user',
inputSchema: z.object({ userId: z.string() }),
execute: async ({ userId }) => {
try {
return await db.users.findById(userId);
} catch (error) {
// Return a structured error the model can reason about
return {
error: true,
message: 'Database unavailable',
retryable: true,
};
}
},
}),
},
// The model sees the error and can respond to the user gracefully
// rather than crashing the whole request.
Failure mode 4: Missing tool result (orphan tool call)
The provider requires that every tool_use has a matching tool_result before the next assistant turn. If you drop a tool result (e.g., filtered it during context management), the next generation fails with a cryptic 400.
// Wrong — dropping a tool_result mid-conversation
const filtered = messages.filter(m => {
if (m.role === 'tool' && looksLikeError(m.content)) {
return false; // BUG: model expected this tool_result
}
return true;
});
// Right — replace with a placeholder instead
const cleaned = messages.map(m => {
if (m.role === 'tool' && looksLikeError(m.content)) {
return { ...m, content: '[tool result unavailable]' };
}
return m;
});
Failure mode 5: multi-step and stopWhen misconfiguration
Multi-step tool use (where the model calls a tool, sees the result, calls another) needs stop conditions. Without them, you can run indefinitely.
import { stepCountIs } from 'ai';
const result = await generateText({
model: anthropic('claude-sonnet-4-6'),
tools: { ... },
messages,
stopWhen: stepCountIs(10), // max 10 tool-use rounds
// Without this, if the model keeps calling tools, you run forever
// (or until context overflow)
});
// Custom stop condition
stopWhen: async ({ steps }) => {
const lastStep = steps[steps.length - 1];
// Stop if we've spent more than $1 on this request
const cost = calculateCost(steps);
return cost > 1.0;
},
Failure mode 6: streaming tool calls with client-side execution
When tools execute on the client (e.g., DOM manipulation, browser APIs), the SDK's streaming protocol handles the round-trip. But if the client-side execution fails, the server-side stream is left waiting. Set a timeout.
const { messages, addToolResult } = useChat({
api: '/api/chat',
onToolCall: async ({ toolCall }) => {
// Client-side tool execution
if (toolCall.toolName === 'showDialog') {
try {
const result = await showDialog(toolCall.args.title);
return result;
} catch (error) {
// Return an error result so the server knows to move on
return { error: 'Dialog was dismissed' };
}
}
},
});
generateObject and streamObject — the structured output failures
generateObject and streamObject are the SDK's structured output primitives. They take a Zod schema, prompt the model appropriately for whichever provider you're using, and return typed output. Most of the time, they work seamlessly. When they don't, the errors are specific and worth understanding.
NoObjectGeneratedError — the top-level failure
The model returned something that couldn't be coerced into your schema. The error preserves what actually came back so you can debug.
import { generateObject, NoObjectGeneratedError } from 'ai';
import { z } from 'zod';
const schema = z.object({
name: z.string(),
age: z.number().min(0).max(150),
email: z.string().email(),
});
try {
const { object } = await generateObject({
model: anthropic('claude-sonnet-4-6'),
schema,
prompt: 'Extract user info from: John, thirty years old, john@example.com',
});
console.log(object); // typed as { name: string, age: number, email: string }
} catch (error) {
if (NoObjectGeneratedError.isInstance(error)) {
console.error('Object generation failed', {
cause: error.cause, // underlying reason
response: error.response, // what the model actually returned
usage: error.usage, // tokens consumed anyway
finishReason: error.finishReason,
});
}
}
The three most common causes
1. Schema too strict for the input
The schema requires fields the input doesn't contain. The model either invents plausible values (bad) or fails validation (better). Loosen the schema or provide fallbacks.
// Fragile
const schema = z.object({
name: z.string(),
age: z.number(),
email: z.string().email(), // often missing in real inputs
});
// Robust
const schema = z.object({
name: z.string(),
age: z.number().optional(),
email: z.string().email().optional(),
});
2. Refinement failures
Zod's .refine() and .superRefine() add custom validation. If the model produces output that fails these refinements, you get a validation error even though the shape is correct.
// Refinement that trips silently
const schema = z.object({
startDate: z.string(),
endDate: z.string(),
}).refine(
data => new Date(data.endDate) > new Date(data.startDate),
{ message: 'endDate must be after startDate' }
);
// Model might produce dates in wrong order
// Options:
// 1. Include refinement rules in the prompt so the model knows
// 2. Post-process to swap if wrong order
// 3. Accept and repair after the fact
3. Provider limitations
Not all providers support all Zod features equally. OpenAI's strict mode requires additionalProperties: false everywhere. Gemini can't handle all string formats. Claude's tool-based structured output has softer guarantees.
// This works on Claude and Gemini, may fail on OpenAI strict mode
const schema = z.record(z.string(), z.number()); // dynamic keys
// Safer — explicit keys
const schema = z.object({
known_key_1: z.number().optional(),
known_key_2: z.number().optional(),
});
The mode parameter matters
// Three modes for generateObject
generateObject({
model,
schema,
prompt,
mode: 'auto', // default — SDK picks the best for the provider
// 'json' — provider's JSON mode
// 'tool' — always use tool_use pattern
});
For OpenAI, 'json' uses their native structured output (strongest guarantee). For Claude, 'tool' uses forced tool_use (idiomatic). For Gemini, 'json' uses responseSchema. 'auto' picks appropriately — but if you're seeing repeated failures, try switching modes explicitly.
streamObject — incremental structured output
const { partialObjectStream } = streamObject({
model: openai('gpt-5-6'),
schema,
prompt: userInput,
});
// Each yielded value is a partial version of the schema
for await (const partial of partialObjectStream) {
// partial has type Partial>
updateUI(partial);
}
// Validation happens at the end; intermediate partials
// may not satisfy required fields.
Retry with feedback for structured output
If generation fails validation, retry with the error as context. The model can often self-correct.
async function generateWithRepair(prompt: string, maxAttempts = 3) {
let lastError: unknown;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
const { object } = await generateObject({
model: anthropic('claude-sonnet-4-6'),
schema,
prompt: attempt === 0
? prompt
: `${prompt}\n\nPrevious attempt failed validation: ${
(lastError as Error).message
}. Please correct.`,
});
return object;
} catch (error) {
lastError = error;
if (!NoObjectGeneratedError.isInstance(error)) throw error;
}
}
throw lastError;
}
The server-client boundary — RSC and edge runtime gotchas
Next.js React Server Components (RSC), the edge runtime, and Vercel's various compute environments all interact with the AI SDK in ways that produce their own class of errors. Most of these are configuration issues rather than runtime bugs, but they manifest as runtime crashes.
Server actions vs API routes
// Server action (Next.js App Router)
'use server';
import { generateText } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
export async function askAI(prompt: string) {
const { text } = await generateText({
model: anthropic('claude-sonnet-4-6'),
prompt,
});
return text;
}
// API route (also works)
// app/api/chat/route.ts
export async function POST(request: Request) {
const { messages } = await request.json();
const result = streamText({
model: anthropic('claude-sonnet-4-6'),
messages,
});
return result.toDataStreamResponse();
}
Common failure: streaming in server actions
Server actions cannot stream directly in the way API routes can. If you need streaming, use an API route. If you use server actions for AI, they return the full result at once.
// Wrong — trying to stream from a server action
'use server';
export async function chat(messages) {
const result = streamText({ ... });
return result.textStream; // Won't work — streams don't cross the boundary
}
// Right — use useObject for streaming structured output from server actions
// Or use an API route for text streaming
Edge runtime restrictions
Vercel Edge Functions and similar edge runtimes have restrictions: no Node.js APIs (fs, crypto's default provider, some Node modules). Most provider SDKs work on edge, but not all.
// Configure the runtime explicitly
export const runtime = 'edge'; // or 'nodejs'
// Watch for: modules that use fs, path, or Node-only crypto
// If a provider SDK requires Node APIs, either:
// 1. Switch runtime to 'nodejs' (slower cold starts)
// 2. Use a different provider or the fetch-based variant
// Most @ai-sdk providers work on edge as of v5
// If you get "Module not found" errors on edge, check provider docs
The API key isn't loading
LoadAPIKeyError fires when the SDK can't find your API key. Root causes vary by environment.
// Local dev — .env.local
ANTHROPIC_API_KEY=sk-ant-...
OPENAI_API_KEY=sk-...
GOOGLE_GENERATIVE_AI_API_KEY=...
// Vercel deployment — set in project settings > Environment Variables
// Make sure the variable is available in the correct environments
// (Production, Preview, Development)
// Explicit override for provider clients
const anthropic = createAnthropic({
apiKey: process.env.MY_CUSTOM_KEY, // if the env var name differs
});
// Common error:
// AI_LoadAPIKeyError: Anthropic API key is missing.
// Solution: check env var name, deployment env, and that
// the variable is exposed to the specific runtime.
Client-side data leakage
The AI SDK is server-side by default. Never call generateText or streamText from a client component — your API key ends up in the client bundle.
// WRONG — 'use client' with direct SDK call
'use client';
import { generateText } from 'ai'; // BAD: bundles API key to client
// RIGHT — use hooks or fetch from client, do generation on server
'use client';
import { useChat } from '@ai-sdk/react'; // safe: hook fetches from your API
Hydration mismatches with streaming responses
If you render server-generated AI content in a component that's also client-hydrated, the streamed values can create hydration mismatches. Wrap streaming areas in <Suspense> or use client-only rendering.
import { Suspense } from 'react';
export default function Page() {
return (
My AI App
Loading AI response... }>
Duplicate class instance errors
If your bundle includes two copies of the ai package (e.g., from mismatched versions in a monorepo), instanceof checks fail. This is why every SDK error class has .isInstance().
// Wrong — breaks if two copies of 'ai' are bundled
if (error instanceof APICallError) { /* ... */ }
// Right — works across duplicate instances
if (APICallError.isInstance(error)) { /* ... */ }
Rate limits and retries in the SDK
The SDK has built-in retry behavior, but understanding what it does (and doesn't) covers is the difference between "the SDK handles this for me" and "I don't know why my app is retrying six times per request."
What the SDK retries by default
The provider adapters (@ai-sdk/anthropic, @ai-sdk/openai, etc.) include HTTP-level retry with exponential backoff. They retry on 408, 429, 500, 502, 503, 504, and network errors. The default is 2 retries, backoff 2 seconds base.
import { createAnthropic } from '@ai-sdk/anthropic';
const anthropic = createAnthropic({
// Configure retry behavior
fetch: async (url, options) => {
// Custom fetch wrapper for full control
return fetch(url, options);
},
});
// Or use built-in retry configuration where supported by the provider
const openai = createOpenAI({
compatibility: 'strict',
fetch: fetch, // pass a fetch wrapper with your own retry
});
Adding your own retry layer
For most production apps, wrap the SDK call in your own retry logic. The SDK's built-in retry is a floor, not a ceiling.
import { APICallError } from 'ai';
async function callWithRetry(
fn: () => Promise,
options: { maxAttempts?: number; baseDelayMs?: number } = {}
): Promise {
const maxAttempts = options.maxAttempts ?? 5;
const baseDelayMs = options.baseDelayMs ?? 1000;
let lastError: unknown;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (!APICallError.isInstance(error) || !error.isRetryable) {
throw error;
}
// Honor Retry-After if present
const retryAfter = error.responseHeaders?.['retry-after'];
let delayMs: number;
if (retryAfter) {
const parsed = parseInt(retryAfter);
delayMs = isNaN(parsed) ? baseDelayMs * (2 ** attempt) : parsed * 1000;
} else {
// Full jitter backoff
const max = baseDelayMs * (2 ** attempt);
delayMs = Math.random() * Math.min(max, 60000);
}
logger.warn(`Retry attempt ${attempt + 1}, waiting ${delayMs}ms`);
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
throw lastError;
}
// Usage
const result = await callWithRetry(() => generateText({
model: anthropic('claude-sonnet-4-6'),
prompt: userInput,
}));
Streaming retries are harder
Once bytes have gone to the client, retry is complex. The pattern in G2 (retry only pre-first-chunk) applies here too, but the SDK's streaming API makes it awkward. The pragmatic pattern: use experimental_wrapLanguageModel middleware to add retry at the model layer.
import { experimental_wrapLanguageModel as wrapLanguageModel } from 'ai';
const modelWithRetry = wrapLanguageModel({
model: anthropic('claude-sonnet-4-6'),
middleware: {
async wrapGenerate({ doGenerate, params }) {
return callWithRetry(() => doGenerate());
},
async wrapStream({ doStream, params }) {
// Pre-stream retry only — once stream starts, no retry
let attempt = 0;
while (true) {
try {
return await doStream();
} catch (error) {
if (!shouldRetry(error) || attempt >= 3) throw error;
const delay = Math.random() * (1000 * 2 ** attempt);
await new Promise(r => setTimeout(r, delay));
attempt++;
}
}
},
},
});
// Now use the wrapped model everywhere
const { text } = await generateText({
model: modelWithRetry,
prompt: userInput,
});
Provider fallback with the SDK
The SDK doesn't have first-class multi-provider fallback (see G4 for the general pattern). But middleware makes it easy to add.
function createFallbackModel(...models: LanguageModelV1[]) {
return wrapLanguageModel({
model: models[0],
middleware: {
async wrapGenerate({ doGenerate, params }) {
for (let i = 0; i < models.length; i++) {
try {
return await callWithRetry(() =>
i === 0
? doGenerate()
: generateText({ ...params, model: models[i] })
.then(r => r.rawResponse) // simplified
);
} catch (error) {
if (i === models.length - 1) throw error;
logger.warn(`Fallback: model ${i} failed, trying ${i + 1}`);
}
}
throw new Error('unreachable');
},
},
});
}
const model = createFallbackModel(
anthropic('claude-sonnet-4-6'),
openai('gpt-5-6'),
google('gemini-3-5-pro'),
);
Middleware, provider registry, and abstraction errors
The SDK's abstraction layer — middleware and the provider registry — gives you flexibility, but also introduces failure modes that don't exist when calling providers directly.
The provider registry pattern
import { createProviderRegistry } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
import { openai } from '@ai-sdk/openai';
import { google } from '@ai-sdk/google';
const registry = createProviderRegistry({
anthropic,
openai,
google,
});
// Use by string identifier
const result = await generateText({
model: registry.languageModel('anthropic:claude-sonnet-4-6'),
prompt: userInput,
});
NoSuchProviderError and NoSuchModelError
// Fires if 'invalid' isn't in the registry
registry.languageModel('invalid:claude-sonnet-4-6');
// AI_NoSuchProviderError: No such provider: invalid
// Fires if the model ID doesn't exist for the provider
registry.languageModel('anthropic:gpt-999');
// AI_NoSuchModelError: No such model: gpt-999
// Handle at your dispatch layer
function getModel(providerModel: string) {
try {
return registry.languageModel(providerModel);
} catch (error) {
if (NoSuchProviderError.isInstance(error)) {
throw new AppError('Unknown provider', { hint: 'Check registration' });
}
if (NoSuchModelError.isInstance(error)) {
throw new AppError('Unknown model', { hint: 'Check model ID' });
}
throw error;
}
}
Middleware composition and ordering
Middleware wraps model calls in layers. Order matters: outer middleware sees the raw request/response; inner middleware runs closest to the model. Get it wrong and features stop working.
import { wrapLanguageModel } from 'ai';
// Multiple middleware — applied outermost-first
const model = wrapLanguageModel({
model: anthropic('claude-sonnet-4-6'),
middleware: [
loggingMiddleware, // outermost — sees final result
retryMiddleware, // middle — retries mid-layer
cachingMiddleware, // innermost — sees raw generate calls
],
});
// Ordering rules:
// - Logging outermost, so it captures everything (including retries)
// - Retry above caching, so retries can hit the cache
// - Rate limiting outermost above retry if you want to enforce globally
The caching middleware gotcha
Caching middleware often keys on the request. But the request includes the full messages array — identical prompts with different formatting (whitespace, order) miss the cache. And streaming responses are hard to cache because they're consumer-controlled.
const cachingMiddleware: LanguageModelV1Middleware = {
async wrapGenerate({ doGenerate, params }) {
// Cache key based on normalized params
const key = hashParams(normalizeParams(params));
const cached = await cache.get(key);
if (cached) return cached;
const result = await doGenerate();
await cache.set(key, result, { ttl: 3600 });
return result;
},
async wrapStream({ doStream, params }) {
// Streaming is harder to cache — you'd need to buffer entire response
// Usually skip caching for streams
return doStream();
},
};
function normalizeParams(params: LanguageModelV1CallOptions) {
return {
...params,
// Sort keys, strip whitespace, etc.
messages: params.prompt.map(m => ({
...m,
content: typeof m.content === 'string'
? m.content.trim()
: m.content,
})),
};
}
Telemetry middleware — the experimental_telemetry option
import { generateText } from 'ai';
const result = await generateText({
model: anthropic('claude-sonnet-4-6'),
prompt: userInput,
experimental_telemetry: {
isEnabled: true,
functionId: 'chat-completion', // used for grouping
metadata: {
userId: user.id,
requestId: request.id,
},
},
});
// The SDK emits OpenTelemetry spans:
// - ai.generateText (root span)
// - ai.generateText.doGenerate (model call span)
// Hook these into your OTel pipeline for automatic tracing.
UnsupportedFunctionalityError
Fires when you request a feature the provider can't do. Trying to use tool calling with an embedding model, or reasoning with a model that doesn't support it, etc.
try {
const result = await generateText({
model: openai('gpt-3.5-turbo'), // doesn't support all features
tools: complexToolConfig, // may not be supported
prompt: userInput,
});
} catch (error) {
if (UnsupportedFunctionalityError.isInstance(error)) {
console.error('Provider does not support', error.functionality);
// Fall back to a simpler configuration or different model
}
}
Migration issues — v3 → v4 → v5
The Vercel AI SDK has evolved significantly across major versions. Teams still running v3 or v4 in production hit specific classes of migration bugs when they finally upgrade. This chapter covers the ones that matter.
v3 → v4: message parts and multi-modal
v4 introduced the parts-based message format for multi-modal content. v3's flat string content field became a UIMessage with typed parts.
// v3 message shape
const messages = [
{ role: 'user', content: 'Hello' },
];
// v4/v5 UIMessage shape
const messages = [
{
role: 'user',
parts: [
{ type: 'text', text: 'Hello' },
// Also possible:
// { type: 'image', image: dataUrl },
// { type: 'file', mimeType: 'application/pdf', data: bytes },
]
},
];
// Adapter for progressive migration
function toV5Message(v3Msg: {role: string, content: string}) {
return {
role: v3Msg.role,
parts: [{ type: 'text', text: v3Msg.content }],
};
}
v4 → v5: streaming protocol
v5 introduced a new streaming protocol with typed events. The default protocol changed — if your client is still expecting v4-format streams from a v5 server, you get "Failed to parse stream" errors.
// v4 server route
return result.toAIStreamResponse();
// v5 server route
return result.toDataStreamResponse({
// Explicit protocol version for compatibility
// (rarely needed; SDK defaults are usually correct)
});
// If client and server are on mismatched versions, symptoms include:
// - Messages that never render
// - "Failed to parse stream" errors in useChat
// - Tool calls that fire but never resolve
// Fix: upgrade both to the same major version.
Provider package renames
Between v3 and v4, provider packages moved from bundled to separate. Between v4 and v5, some packages had breaking API changes even at the same major version.
// v3
import { openai } from 'ai/providers';
// v4/v5
import { openai } from '@ai-sdk/openai';
// Provider package versions must match SDK major
// ai@^5.0.0 requires @ai-sdk/openai@^2.0.0
// ai@^4.0.0 requires @ai-sdk/openai@^1.0.0
// Mismatched majors cause runtime errors that are hard to diagnose.
Deprecated function names
// v3/v4 names
experimental_generateText
experimental_streamText
experimental_generateObject
// v5 names — no experimental_ prefix
generateText
streamText
generateObject
// If you see references to experimental_ prefixed functions in your code,
// they may work in v5 as aliases but will be removed.
Tool definition changes
// v4 tool
const weatherTool = {
description: 'Get weather',
parameters: z.object({ city: z.string() }),
execute: async ({ city }) => { /* ... */ },
};
// v5 tool — use the tool() helper
import { tool } from 'ai';
const weatherTool = tool({
description: 'Get weather',
inputSchema: z.object({ city: z.string() }), // renamed from parameters
execute: async ({ city }) => { /* ... */ },
});
useChat hook changes
// v4 useChat
const { messages, append, isLoading } = useChat({
api: '/api/chat',
});
await append({ role: 'user', content: 'Hello' });
// v5 useChat
const { messages, sendMessage, status } = useChat({
api: '/api/chat',
});
await sendMessage({ text: 'Hello' }); // renamed methods
// status is a union: 'ready' | 'submitted' | 'streaming' | 'error'
Migration strategy for large codebases
The pragmatic pattern for teams with substantial v3/v4 code:
- Isolate SDK usage behind an internal wrapper. Every generateText/streamText call goes through one of your functions.
- Upgrade dependencies together: ai, all @ai-sdk provider packages, and @ai-sdk/react in one PR.
- Migrate the wrapper first. Update the internal wrapper to v5 semantics; the rest of your codebase stays untouched until you're ready.
- Migrate consumers gradually. Once the wrapper works on v5, migrate consuming code file by file.
- Run both versions in parallel if possible via A/B routing at your API layer.
Breaking changes to watch for on future v5 minor releases
The SDK moves quickly. Even minor version bumps sometimes include behavioral changes that surface as bugs. Best practices:
- Pin to exact versions in production, not caret ranges.
- Test in staging with the new version for at least 48 hours before promoting.
- Watch the SDK's changelog. Reading it is not glamorous, but it's much cheaper than diagnosing why streaming stopped working.
Debugging techniques and telemetry
SDK errors surface deep in the middleware stack, often with generic-looking messages. Effective debugging requires knowing where to look and what to log.
Enable experimental_telemetry everywhere
// Every generateText/streamText call in production
const result = await generateText({
model,
prompt: userInput,
experimental_telemetry: {
isEnabled: true,
functionId: 'chat-completion',
metadata: {
userId: user?.id ?? 'anonymous',
requestId: correlationId,
sessionId: session?.id,
},
},
});
// The SDK emits OpenTelemetry spans by default:
// ai.generateText
// ├── ai.generateText.doGenerate (the actual model call)
// ├── ai.toolCall.getWeather (each tool execution)
// └── ai.toolCall.searchDocs
// Wire to your OTel provider (Langfuse, Datadog, Honeycomb):
import { registerOTel } from '@vercel/otel';
registerOTel({
serviceName: 'my-ai-app',
traceExporter: myExporter,
});
Log the raw request and response
When something's wrong at the protocol level, you need the actual bytes. The onFinish callback and providerMetadata give access.
const result = await generateText({
model,
prompt: userInput,
onStepFinish: async ({ text, toolCalls, toolResults, finishReason, usage }) => {
logger.debug('Step finished', {
textLength: text.length,
toolCallCount: toolCalls.length,
finishReason,
usage,
});
},
});
// For streaming
const result = streamText({
model,
messages,
onFinish: async ({ text, toolCalls, finishReason, usage, providerMetadata }) => {
logger.info('Stream complete', {
finishReason,
usage,
// Provider-specific metadata (cache hits, model version, etc.)
providerMetadata,
});
},
onError: (error) => {
logger.error('Stream error', {
error: error.message,
stack: error.stack,
isSDK: AISDKError.isInstance(error),
isAPI: APICallError.isInstance(error),
status: APICallError.isInstance(error) ? error.statusCode : undefined,
});
},
});
Reproducing the issue locally
Client errors are often not reproducible without knowing the exact input. Log the request body (redacted for PII) so you can reproduce.
// API route
export async function POST(request: Request) {
const body = await request.json();
const correlationId = crypto.randomUUID();
logger.info('Chat request', {
correlationId,
messageCount: body.messages.length,
lastMessageLength: body.messages[body.messages.length - 1]?.content?.length,
userId: body.userId,
model: body.model,
// DON'T log full message content in production (PII/cost)
// For debugging specific issues, enable temporarily
});
try {
const result = streamText({
model: getModel(body.model),
messages: body.messages,
experimental_telemetry: {
isEnabled: true,
functionId: 'chat',
metadata: { correlationId, userId: body.userId },
},
});
return result.toDataStreamResponse({
getErrorMessage: (error) => {
logger.error('Stream error', { correlationId, error });
// User-safe message
if (APICallError.isInstance(error) && error.statusCode === 429) {
return 'Service is busy, please try again in a moment';
}
return 'Something went wrong';
},
});
} catch (error) {
logger.error('Request failed', { correlationId, error });
return new Response('Error', { status: 500 });
}
}
The "it works in dev but not prod" checklist
- Environment variables — API keys, model overrides, feature flags. Verify with a debug endpoint that logs their presence (not values).
- Runtime — is your production route running on edge when it needs Node? Or vice versa?
- Version pinning — production may have installed a newer patch version than dev tested.
- Rate limit tier — your dev API key may be on a higher tier than production's.
- Regional differences — if you use provider regions, prod may hit a different region than dev.
- Cold starts — first request after cold start is slow and may timeout on edge functions with tight limits.
Common misdiagnoses
"The SDK is broken"
Usually not. Usually a provider error being surfaced correctly by the SDK. Read error.responseBody before blaming the SDK.
"The model isn't following instructions"
Sometimes a real model behavior; sometimes an SDK issue. If instructions are in system prompts, verify the SDK is actually sending them (check experimental_telemetry spans for the raw params).
"Streaming randomly cuts off"
Usually a serverless timeout or client-side abort, not an SDK bug. Check function execution logs for timeout events.
The complete production-ready wrapper
Every idea in this guide, packaged. Drop this into a codebase to have SDK error handling that survives production traffic.
// lib/ai-client.ts
import {
generateText,
streamText,
generateObject,
APICallError,
NoObjectGeneratedError,
TypeValidationError,
AISDKError,
wrapLanguageModel,
type LanguageModelV1,
type LanguageModelV1Middleware,
} from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
import { openai } from '@ai-sdk/openai';
import { google } from '@ai-sdk/google';
import { z } from 'zod';
// ---- Error classification ----
export function shouldRetry(error: unknown): boolean {
if (!APICallError.isInstance(error)) return false;
if (!error.isRetryable) return false;
const status = error.statusCode;
if (status && [401, 403, 404, 422].includes(status)) return false;
// Provider-specific non-retryable errors
const body = error.data as any;
if (body?.error?.code === 'content_filter') return false;
if (body?.type === 'error' && body?.error?.type === 'invalid_request_error') {
return false;
}
return true;
}
export function getUserFacingError(error: unknown): {
message: string;
code: string;
retryable: boolean;
} {
if (APICallError.isInstance(error)) {
if (error.statusCode === 429) {
return {
message: 'The service is busy. Please try again in a moment.',
code: 'rate_limited',
retryable: true,
};
}
if (error.statusCode === 529) {
return {
message: 'The AI service is temporarily overloaded.',
code: 'overloaded',
retryable: true,
};
}
if (error.statusCode && error.statusCode >= 500) {
return {
message: 'The AI service is having issues.',
code: 'server_error',
retryable: true,
};
}
if (error.statusCode === 400) {
return {
message: 'The request was invalid.',
code: 'bad_request',
retryable: false,
};
}
}
if (NoObjectGeneratedError.isInstance(error)) {
return {
message: 'Could not generate a valid response.',
code: 'invalid_response',
retryable: true,
};
}
return {
message: 'Something went wrong.',
code: 'unknown',
retryable: false,
};
}
// ---- Retry middleware ----
async function fullJitterDelay(attempt: number, base = 1000, cap = 60000) {
const max = Math.min(cap, base * (2 ** attempt));
return new Promise(r => setTimeout(r, Math.random() * max));
}
function parseRetryAfter(error: APICallError): number | null {
const value = error.responseHeaders?.['retry-after'];
if (!value) return null;
const seconds = parseFloat(value);
return isNaN(seconds) ? null : seconds * 1000;
}
export const retryMiddleware: LanguageModelV1Middleware = {
async wrapGenerate({ doGenerate }) {
let lastError: unknown;
for (let attempt = 0; attempt < 5; attempt++) {
try {
return await doGenerate();
} catch (error) {
lastError = error;
if (!shouldRetry(error)) throw error;
const retryAfter = APICallError.isInstance(error)
? parseRetryAfter(error)
: null;
if (retryAfter !== null) {
await new Promise(r => setTimeout(r, Math.min(retryAfter, 300000)));
} else {
await fullJitterDelay(attempt);
}
}
}
throw lastError;
},
async wrapStream({ doStream }) {
// Pre-stream retry only — once bytes flow, no retry
let lastError: unknown;
for (let attempt = 0; attempt < 3; attempt++) {
try {
return await doStream();
} catch (error) {
lastError = error;
if (!shouldRetry(error)) throw error;
await fullJitterDelay(attempt);
}
}
throw lastError;
},
};
// ---- Telemetry middleware ----
export const telemetryMiddleware: LanguageModelV1Middleware = {
async wrapGenerate({ doGenerate, params }) {
const start = Date.now();
try {
const result = await doGenerate();
metrics.increment('ai.request.success', {
model: params.modelId,
});
metrics.histogram('ai.request.duration_ms', Date.now() - start, {
model: params.modelId,
});
metrics.histogram('ai.tokens.input', result.usage?.promptTokens ?? 0);
metrics.histogram('ai.tokens.output', result.usage?.completionTokens ?? 0);
return result;
} catch (error) {
metrics.increment('ai.request.error', {
model: params.modelId,
errorClass: (error as Error).name,
});
throw error;
}
},
async wrapStream({ doStream, params }) {
return doStream(); // stream metrics via onFinish callback
},
};
// ---- Fallback wrapper ----
export function createFallbackModel(
primary: LanguageModelV1,
...fallbacks: LanguageModelV1[]
): LanguageModelV1 {
return wrapLanguageModel({
model: primary,
middleware: {
async wrapGenerate({ doGenerate, params }) {
const models = [primary, ...fallbacks];
for (let i = 0; i < models.length; i++) {
try {
if (i === 0) return await doGenerate();
const result = await generateText({
...params,
model: models[i],
} as any);
metrics.increment('ai.fallback.used', { tier: String(i) });
return result as any;
} catch (error) {
if (i === models.length - 1) throw error;
logger.warn('Fallback triggered', {
from: i, to: i + 1, error: (error as Error).message,
});
}
}
throw new Error('unreachable');
},
},
});
}
// ---- Composed production model ----
const baseModel = anthropic('claude-sonnet-4-6');
const backupModel = openai('gpt-5-6');
const tertiaryModel = google('gemini-3-5-pro');
export const productionModel = wrapLanguageModel({
model: createFallbackModel(baseModel, backupModel, tertiaryModel),
middleware: [
telemetryMiddleware, // outermost — sees final result including fallbacks
retryMiddleware, // inner — retries within a single provider first
],
});
// ---- Usage-ready helpers ----
export async function safeGenerateText(
prompt: string,
correlationId: string,
) {
try {
const result = await generateText({
model: productionModel,
prompt,
experimental_telemetry: {
isEnabled: true,
functionId: 'safe-generate-text',
metadata: { correlationId },
},
});
return { ok: true as const, text: result.text, usage: result.usage };
} catch (error) {
const userError = getUserFacingError(error);
logger.error('safeGenerateText failed', {
correlationId,
error,
userError,
});
return { ok: false as const, error: userError };
}
}
export function safeStreamText(
messages: any[],
correlationId: string,
) {
return streamText({
model: productionModel,
messages,
experimental_telemetry: {
isEnabled: true,
functionId: 'safe-stream-text',
metadata: { correlationId },
},
onError: (error) => {
logger.error('Stream error', { correlationId, error });
},
onFinish: ({ usage, finishReason }) => {
logger.info('Stream complete', { correlationId, usage, finishReason });
},
});
}
export async function safeGenerateObject(
schema: T,
prompt: string,
correlationId: string,
) {
const maxAttempts = 3;
let lastError: unknown;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
const { object } = await generateObject({
model: productionModel,
schema,
prompt: attempt === 0
? prompt
: `${prompt}\n\nPrevious attempt failed: ${(lastError as Error).message}`,
experimental_telemetry: {
isEnabled: true,
functionId: 'safe-generate-object',
metadata: { correlationId, attempt },
},
});
return { ok: true as const, object };
} catch (error) {
lastError = error;
if (!NoObjectGeneratedError.isInstance(error)
&& !TypeValidationError.isInstance(error)) {
throw error;
}
}
}
const userError = getUserFacingError(lastError);
return { ok: false as const, error: userError };
}
Usage in an API route
// app/api/chat/route.ts
import { safeStreamText } from '@/lib/ai-client';
import { getUserFacingError, APICallError } from '@/lib/ai-client';
export async function POST(request: Request) {
const { messages } = await request.json();
const correlationId = crypto.randomUUID();
const stream = safeStreamText(messages, correlationId);
return stream.toDataStreamResponse({
headers: { 'x-correlation-id': correlationId },
getErrorMessage: (error) => {
const user = getUserFacingError(error);
return user.message;
},
});
}
What this gives you
- Error classification helpers (
shouldRetry,getUserFacingError) with provider-specific carve-outs. - Retry middleware honoring Retry-After with full jitter fallback.
- Telemetry middleware emitting metrics on every request.
- Multi-provider fallback via
createFallbackModel. - Composed
productionModelwith retry + telemetry + fallback in the right order. - Safe wrappers for text generation, streaming, and structured output.
- Correlation IDs on every request for post-hoc debugging.
What it doesn't do
- Distributed rate limiting — per-instance only. Add Redis for multi-instance apps.
- Circuit breakers — add on top using G3's pattern with SDK middleware wrapping.
- Prompt caching optimization — provider-specific; see the provider adapter docs.
- Context management — apply G5's ContextManager to messages before calling.
Frequently asked questions
Which version of the Vercel AI SDK does this guide target?
v5, the current major version as of September 2026. Code samples use v5 APIs (streamText, generateText, generateObject, tool() helper, inputSchema field name). Chapter 10 covers migration from v3/v4 for teams on older versions.
What's the difference between AISDKError and APICallError?
AISDKError is the base class for all SDK-specific errors. APICallError is a specific subclass wrapping provider HTTP errors (429, 500, etc.). Use APICallError.isInstance() to catch wrapped provider errors specifically.
Why doesn't instanceof work reliably with SDK errors?
Because bundling can produce multiple copies of the ai package (monorepos, mismatched versions). Each copy has its own class definition, so instanceof fails across copies. Every SDK error class has a static .isInstance() method that works correctly across copies.
Does the SDK retry rate limit errors automatically?
The provider adapters (@ai-sdk/anthropic etc.) retry a few times by default with exponential backoff. For production, add explicit retry middleware for full control over max attempts, jitter, and Retry-After handling. Don't stack both.
How do I catch mid-stream errors?
Consume fullStream instead of textStream, and switch on the part.type. Text deltas come as 'text-delta'; errors come as 'error'. If you only consume textStream, mid-stream errors are silent.
What's the getErrorMessage callback for?
toDataStreamResponse() by default sends 'An error occurred' to the client for any error. getErrorMessage lets you transform errors into safe user-facing messages while preserving the ability to log the real error server-side.
How do I handle content filter blocks?
OpenAI returns 200 with finish_reason: 'content_filter'. Gemini returns 200 with promptFeedback.blockReason. Check finishReason on successful responses; don't assume 200 means good output.
Should I use generateObject with mode 'auto', 'json', or 'tool'?
'auto' picks the best for each provider (usually the strongest guarantee). Use explicit modes when troubleshooting: 'json' for OpenAI/Gemini native structured output, 'tool' for Claude-style forced tool_use pattern.
How do I detect a tool call failure?
Multiple ways: NoSuchToolError (model called an undefined tool), TypeValidationError (arguments don't match schema), tool.execute() threw an exception (returns as tool_result with error content). Handle each with experimental_repairToolCall.
What does 'AI_LoadAPIKeyError' mean?
The SDK can't find the API key for a provider. Check the env var name (each provider has its own — ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_GENERATIVE_AI_API_KEY). In Vercel deployments, verify env vars are set for the correct environments.
Can I use the SDK from client components?
No. Direct generateText/streamText calls in client components bundle your API key to the browser. Use hooks like useChat/useObject that fetch from your server API routes, or server actions.
How do I add distributed rate limiting?
Not built-in. Use middleware to check a Redis-backed rate limiter before doGenerate. Chapter 8 shows the pattern; Chapter 9 shows middleware composition. For serious multi-instance deployments, use a gateway (LiteLLM, Portkey) or roll your own via middleware.
What is experimental_wrapLanguageModel for?
The middleware primitive for wrapping any provider model with cross-cutting concerns: retry, caching, logging, telemetry, fallback. It's the composition point for all your production concerns. Multiple middleware can be composed by passing an array.
Should I use streamText or generateText?
streamText for user-facing text where perceived latency matters (chat, generation). generateText for backend work where you need the whole response (extraction, classification, summarization). Streaming adds complexity — don't use it if you don't need progressive output.
How do I test SDK code?
The SDK has mock providers for testing without hitting real APIs. Use createOpenAIMocks() and similar. For error paths, mock providers can throw specific errors on demand — the way to test retry logic without waiting for real rate limits.
Library & further reading
Everywhere else on AI Error Hub that touches the Vercel AI SDK, framework errors, and adjacent patterns.