n4nAI

Debugging tool call errors in the Vercel AI SDK

A step-by-step guide to diagnosing and fixing tool call failures in the Vercel AI SDK, from schema mismatches to provider quirks.

n4n Team5 min read1,094 words

Audio narration

Coming soon — every post will get a voice note here.

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 missing toolChoice.
  • Tool call emitted but malformed — provider returns 400 with “invalid function arguments.” Often a Zod-to-JSON Schema conversion gap.
  • Tool executes but result lost — your onToolCall handler runs but the assistant never sees the result. Check maxSteps and 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:

  1. Tools array structure — each tool must have type: "function" and a function object with name, description, and parameters (JSON Schema). The SDK handles this conversion, but custom prepareTools overrides can break it.
  2. Parameter schema validity — providers reject schemas with additionalProperties: true, recursive refs, or unsupported formats like format: "date-time" on some models.
  3. 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: true in function schema for guaranteed valid JSON. The SDK sets this automatically when experimental_strictToolCalling: true is passed to the model.
  • Rejects null values 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_use blocks in content array, not top-level tool_calls. The SDK normalizes this, but onToolCall receives Anthropic’s format.
  • Requires cache_control hints for long tool results. Pass experimental_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 / functionResponse parts. SDK handles conversion.
  • Does not support toolChoice: 'required'. Model decides.
  • Schema must omit additionalProperties entirely — setting false causes 400.

Open models (Llama 3.1, Qwen 2.5 via Ollama/vLLM)

  • Many quantized models ignore tool schemas entirely. Test with generateText first.
  • Some require system prompt injection: “You have access to tools. Use them.” The SDK’s system prompt handles this but verify the model actually sees it.
  • Temperature > 0.7 often breaks JSON output. Force temperature: 0 for 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:

  1. Schema validatesjsonSchema(tool.parameters) passes a JSON Schema Draft 2020-12 validator with no warnings.
  2. Forced call workstoolChoice: { type: 'function', function: { name: 'yourTool' } } produces a valid tool call on a relevant prompt.
  3. Optional call works — without toolChoice, the model calls the tool on a relevant prompt and skips it on irrelevant prompts.
  4. Streaming completesstreamText with maxSteps: 3 returns full tool results in toolCalls promise; no parse errors in console.
  5. Multi-step works — tool result feeds back, model calls another tool or finishes. Check steps.length === expected.
  6. Error handling — throw inside execute; verify the error surfaces in onFinish messages and doesn’t crash the stream.
  7. 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.

Tagsvercel-ai-sdktool-callingdebuggingerror-handling

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All vercel ai sdk tool & function calling posts →