Tool call failures in the Vercel AI SDK usually show up as cryptic tool_calls parse errors, silent fallbacks to text, or model refusals that leave no trace in the console. This walkthrough covers the vercel ai sdk tool call errors debugging process I use in production — seven steps that take you from “it doesn’t work” to a reproducible fix, with code you can drop into your project today.
Step 1: Understand the error surface
The SDK sits between your code and the provider. Errors originate in three places: your tool schema (Zod/JSON Schema), the SDK’s serialization layer, or the provider’s execution environment. Most “tool not called” bugs are actually schema validation failures that the SDK swallows before the request leaves your process.
Start by classifying the symptom:
- No tool call emitted — model returns text instead of
tool_calls. Usually a schema issue or missingtoolChoice. - Tool call emitted but malformed — provider returns
400with “invalid function arguments.” Often a Zod-to-JSON Schema conversion gap. - Tool executes but result lost — your
onToolCallhandler runs but the assistant never sees the result. CheckmaxStepsand message ordering. - Streaming cuts off mid-call — partial JSON in the stream breaks
parseToolCalls. Requires buffering.
Open your browser dev tools or terminal and reproduce the failure with a minimal prompt. Capture the exact error message and the model’s raw response if possible.
Step 2: Enable verbose logging
The SDK’s experimental_telemetry flag and onFinish callback expose what the provider actually receives and returns. Add this to your streamText or generateText call:
import { streamText, tool } from 'ai';
import { z } from 'zod';
const result = streamText({
model: yourModel,
tools: {
getWeather: tool({
parameters: z.object({
location: z.string().describe('City and state, e.g. "San Francisco, CA"'),
unit: z.enum(['celsius', 'fahrenheit']).default('fahrenheit'),
}),
execute: async ({ location, unit }) => {
// your implementation
},
}),
},
experimental_telemetry: {
isEnabled: true,
functionId: 'weather-chat',
},
onFinish: async ({ response, usage, finishReason }) => {
console.log('finishReason:', finishReason);
console.log('usage:', usage);
// response.messages contains the full conversation including tool calls
response.messages.forEach((msg, i) => {
console.log(`Message ${i}:`, JSON.stringify(msg, null, 2));
});
},
});
Run the request again. The onFinish payload shows every message the SDK sent to the provider — including the serialized tools array and the model’s raw tool_calls output. Compare the schema in the request against your Zod definition. Look for missing required fields, type mismatches (Zod string() becomes JSON Schema "type": "string" but z.enum() becomes "enum": [...]), and description truncation.
Step 3: Inspect the tool call request payload
If onFinish doesn’t reveal the issue, intercept the HTTP request. The SDK uses standard fetch; you can wrap it with a logging proxy or use the experimental_fetch option:
import { openai } from '@ai-sdk/openai';
const model = openai('gpt-4o', {
experimental_fetch: async (url, options) => {
console.log('>>> REQUEST', url);
console.log('>>> BODY', options?.body);
const response = await fetch(url, options);
const cloned = response.clone();
const body = await cloned.text();
console.log('<<< RESPONSE', response.status, body);
return response;
},
});
This prints the exact JSON the provider receives. Verify three things:
- Tools array structure — each tool must have
type: "function"and afunctionobject withname,description, andparameters(JSON Schema). The SDK handles this conversion, but customprepareToolsoverrides can break it. - Parameter schema validity — providers reject schemas with
additionalProperties: true, recursive refs, or unsupported formats likeformat: "date-time"on some models. - Tool choice — if you need forced tool use, pass
toolChoice: { type: 'function', function: { name: 'getWeather' } }. Omitting this lets the model decide, which often results in text responses for ambiguous prompts.
Step 4: Validate tool schema against provider requirements
Not all providers accept the full JSON Schema spec. OpenAI supports a subset; Anthropic’s tool use has different constraints; open models via Ollama or vLLM vary by quantization and tokenizer. Common failure patterns:
| Pattern | Fails on | Fix |
|---|---|---|
z.union([z.string(), z.number()]) |
OpenAI, Anthropic | Flatten to z.string() and parse in execute |
z.record(z.string(), z.unknown()) |
Most providers | Use z.object({}).passthrough() or define explicit keys |
z.array(z.union(...)) |
OpenAI | Avoid unions in arrays; use discriminated unions at object level |
refine / superRefine |
All (stripped) | Move validation into execute; schema is for the model, not runtime |
Write a test that serializes your tools to JSON Schema and validates against the provider’s documented limits. For OpenAI, the parameters object must be a valid JSON Schema Draft 2020-12 object with type: "object" at the root. Run this once per tool definition:
import { jsonSchema } from 'ai';
const schema = jsonSchema(getWeather.parameters);
console.log(JSON.stringify(schema, null, 2));
// Paste output into https://jsonschemavalidator.net/ with Draft 2020-12
If the validator flags anything, fix the Zod schema before blaming the model.
Step 5: Handle streaming vs non-streaming differences
streamText and generateText handle tool calls differently. In streaming mode, the SDK parses incremental tool_calls chunks and emits tool-call events. If the stream ends before the full JSON arrives, parseToolCalls throws and the SDK may drop the call entirely.
Two mitigations:
Buffer the stream — collect chunks until finishReason === 'tool-calls' before parsing:
const { textStream, toolCalls } = streamText({
model,
tools,
maxSteps: 5,
});
// toolCalls is a Promise<ToolCall[]> that resolves after the full response
const calls = await toolCalls;
for (const call of calls) {
const result = await call.execute();
// feed result back via next step
}
Use generateText for debugging — it returns complete toolCalls array synchronously. Swap streamText for generateText in your test harness to isolate streaming parser bugs:
import { generateText } from 'ai';
const { toolCalls, steps } = await generateText({
model,
tools,
maxSteps: 5,
prompt: 'What is the weather in Tokyo?',
});
console.log('Tool calls:', toolCalls);
console.log('Steps taken:', steps.length);
If generateText works but streamText fails, the issue is in the streaming parser — file a bug with the SDK team or increase maxSteps to allow the model to retry.
Step 6: Debug provider-specific quirks
Each provider has undocumented behaviors. Keep a cheatsheet for the ones you use:
OpenAI (gpt-4o, gpt-4-turbo)
- Requires
strict: truein function schema for guaranteed valid JSON. The SDK sets this automatically whenexperimental_strictToolCalling: trueis passed to the model. - Rejects
nullvalues in required fields even if Zod allows.nullable(). Mark fields optional in Zod if the model might omit them. - Tool names must match
^[a-zA-Z0-9_-]{1,64}$. No spaces, no Unicode.
Anthropic (Claude 3.5 Sonnet, Opus)
- Uses
tool_useblocks in content array, not top-leveltool_calls. The SDK normalizes this, butonToolCallreceives Anthropic’s format. - Requires
cache_controlhints for long tool results. Passexperimental_providerMetadata: { anthropic: { cacheControl: { type: 'ephemeral' } } }in tool config. - Max 4096 tokens for tool result; truncate in
execute.
Google (Gemini 1.5 Pro/Flash)
- Function calling uses
functionCall/functionResponseparts. SDK handles conversion. - Does not support
toolChoice: 'required'. Model decides. - Schema must omit
additionalPropertiesentirely — settingfalsecauses 400.
Open models (Llama 3.1, Qwen 2.5 via Ollama/vLLM)
- Many quantized models ignore tool schemas entirely. Test with
generateTextfirst. - Some require system prompt injection: “You have access to tools. Use them.” The SDK’s
systemprompt handles this but verify the model actually sees it. - Temperature > 0.7 often breaks JSON output. Force
temperature: 0for tool calls.
If you route through a gateway that normalizes across providers (n4n.ai forwards provider cache-control hints and honors client routing directives), verify the gateway isn’t stripping strict flags or rewriting tool names. Log the request at the gateway layer.
Step 7: Build a reusable debugging wrapper
Wrap the pattern above into a utility you can drop into any route. This captures request/response, validates schemas, and surfaces actionable errors:
// lib/ai/debug-tools.ts
import { generateText, tool, Tool } from 'ai';
import { z } from 'zod';
import { jsonSchema } from 'ai';
interface DebugOptions {
model: any;
tools: Record<string, Tool>;
prompt: string;
maxSteps?: number;
}
export async function debugToolCalls({ model, tools, prompt, maxSteps = 3 }: DebugOptions) {
// 1. Validate all schemas upfront
for (const [name, t] of Object.entries(tools)) {
const schema = jsonSchema(t.parameters);
if (schema.type !== 'object') {
throw new Error(`Tool ${name}: root schema must be object, got ${schema.type}`);
}
// Check for known problematic patterns
const schemaStr = JSON.stringify(schema);
if (schemaStr.includes('"additionalProperties":true')) {
console.warn(`Tool ${name}: additionalProperties: true may cause provider errors`);
}
}
// 2. Run with full logging
const start = Date.now();
const { toolCalls, steps, finishReason, usage } = await generateText({
model,
tools,
prompt,
maxSteps,
experimental_telemetry: { isEnabled: true, functionId: 'debug-tool-calls' },
onFinish: ({ response }) => {
console.log('[debug] Finish reason:', finishReason);
console.log('[debug] Steps:', steps.length);
console.log('[debug] Usage:', usage);
response.messages.forEach((m, i) => {
if (m.role === 'assistant' && m.toolCalls?.length) {
console.log(`[debug] Assistant tool calls (msg ${i}):`, m.toolCalls);
}
if (m.role === 'tool') {
console.log(`[debug] Tool result (msg ${i}):`, m.content);
}
});
},
});
console.log(`[debug] Completed in ${Date.now() - start}ms`);
return { toolCalls, steps, finishReason };
}
Use it in your API route:
// app/api/chat/route.ts
import { openai } from '@ai-sdk/openai';
import { debugToolCalls } from '@/lib/ai/debug-tools';
import { z } from 'zod';
import { tool } from 'ai';
export async function POST(req: Request) {
const { prompt } = await req.json();
const tools = {
getWeather: tool({
parameters: z.object({
location: z.string(),
unit: z.enum(['c', 'f']).default('f'),
}),
execute: async ({ location, unit }) => ({ temp: 72, unit, location }),
}),
};
const result = await debugToolCalls({
model: openai('gpt-4o'),
tools,
prompt,
});
return Response.json(result);
}
Verification checklist
After each fix, run through this list before declaring victory:
- Schema validates —
jsonSchema(tool.parameters)passes a JSON Schema Draft 2020-12 validator with no warnings. - Forced call works —
toolChoice: { type: 'function', function: { name: 'yourTool' } }produces a valid tool call on a relevant prompt. - Optional call works — without
toolChoice, the model calls the tool on a relevant prompt and skips it on irrelevant prompts. - Streaming completes —
streamTextwithmaxSteps: 3returns full tool results intoolCallspromise; no parse errors in console. - Multi-step works — tool result feeds back, model calls another tool or finishes. Check
steps.length === expected. - Error handling — throw inside
execute; verify the error surfaces inonFinishmessages and doesn’t crash the stream. - Provider swap — change model to a different provider (e.g., OpenAI → Anthropic) without code changes; tool calls still fire.
If all seven pass, your tool calling is solid. The next failure will be a new provider quirk — add it to your cheatsheet and move on.