n4nAI

Vercel AI SDK toolChoice parameter: forced vs auto

Compare Vercel AI SDK toolChoice modes — auto, required, and forced function — with code examples, a decision table, and use-case verdicts.

n4n Team5 min read1,029 words

Audio narration

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

The toolChoice parameter in Vercel AI SDK controls how aggressively the model uses your functions. Most developers only know auto and required, but the SDK also supports forcing a specific function by name — a mode that unlocks deterministic workflows and testing patterns you can’t get otherwise. Understanding the trade-offs between these modes separates prototypes that work in demos from systems that behave predictably in production.

The three modes you actually use

Vercel AI SDK exposes toolChoice as a union type with four values, but three cover 95% of real workloads:

type ToolChoice = 
  | 'auto'                    // model decides (default)
  | 'required'                // model must call something
  | 'none'                    // model cannot call tools
  | { type: 'function'; function: { name: string } }  // force specific function

The fourth option — forcing a named function — is where things get interesting. It lets you bypass the model’s planner entirely and route directly to a known capability.

Comparison at a glance

Dimension auto required Forced function
Model discretion Full — may answer directly or call tools None — must invoke a tool None — calls exactly one named tool
Tool selection Model picks from available set Model picks from available set Fixed at call site
Failure mode Silent fallback to text Hallucinated tool calls if schema mismatched TypeScript error if function missing
Token overhead Lowest (no forced preamble) Moderate (system reminds model to call) Lowest (no planning tokens)
Determinism Low — varies by prompt/context Medium — always calls, but which varies High — same function every time
Testing friendliness Hard to assert tool usage Easy to assert a tool ran Trivial — mock one function
Best for General chat, optional enrichment Mandatory extraction, routing Workflows, evals, deterministic steps

auto — the default you probably shouldn’t keep

auto lets the model decide whether tools are necessary. For open-ended chat this is fine. For anything with a contract — “extract these fields,” “route this ticket,” “validate this input” — it’s a liability.

// Implicit auto
const result = await streamText({
  model: openai('gpt-4o'),
  tools: { getWeather, searchDocs },
  messages,
});

The model might answer from training data, hallucinate a tool call, or pick the wrong function. You get no guarantee the tool runs. In production, this shows up as intermittent missing data, inconsistent formatting, and evals that flake without code changes.

Use auto when:

  • The user genuinely asks open-ended questions
  • Tools are optional enrichment (summarization, formatting)
  • You’re prototyping and haven’t defined the contract yet

required — mandatory tool use, flexible selection

required forces the model to invoke something. It still chooses which function from your provided set. This is the workhorse for extraction, classification, and routing tasks where you need structured output but can tolerate the model picking the right tool.

const result = await streamText({
  model: openai('gpt-4o'),
  tools: { extractInvoice, classifyTicket, summarize },
  toolChoice: 'required',
  messages,
});

The model receives a system-level instruction equivalent to “you must call a function.” It will still plan — sometimes incorrectly. Common failure modes:

  • Picking extractInvoice when the user asked for classification
  • Calling a function with hallucinated arguments because the schema was ambiguous
  • Infinite loops if the tool returns an error and the model retries blindly

Mitigate with strict schemas and toolCallStreaming handlers that validate before execution:

const result = await streamText({
  model: openai('gpt-4o'),
  tools: { 
    extractInvoice: {
      parameters: z.object({ pdfBase64: z.string() }),
      execute: async ({ pdfBase64 }) => { /* ... */ }
    }
  },
  toolChoice: 'required',
  onToolCall: async ({ toolName, args }) => {
    if (toolName === 'extractInvoice') {
      const parsed = invoiceSchema.safeParse(args);
      if (!parsed.success) throw new Error('Invalid invoice args');
    }
  },
  messages,
});

Forced function — deterministic routing

Passing { type: 'function', function: { name: 'myTool' } } tells the SDK: skip planning, call this exact function. The model still generates arguments, but the function identity is fixed at the call site.

const result = await streamText({
  model: openai('gpt-4o'),
  tools: { 
    validateAddress: addressValidator,
    calculateShipping: shippingCalculator,
  },
  toolChoice: { type: 'function', function: { name: 'validateAddress' } },
  messages: [
    { role: 'user', content: 'Ship to 123 Main St, Springfield' }
  ],
});

This mode shines in three scenarios:

1. Multi-step workflows with explicit control

You orchestrate the flow in code, not in the model’s head:

async function processOrder(order: Order) {
  // Step 1: validate address (forced)
  const addressCheck = await streamText({
    model: openai('gpt-4o'),
    tools: { validateAddress },
    toolChoice: { type: 'function', function: { name: 'validateAddress' } },
    messages: [{ role: 'user', content: order.shippingAddress }],
  });
  
  if (!addressCheck.toolResults[0].valid) {
    return { error: 'Invalid address' };
  }

  // Step 2: calculate shipping (forced)
  const shipping = await streamText({
    model: openai('gpt-4o'),
    tools: { calculateShipping },
    toolChoice: { type: 'function', function: { name: 'calculateShipping' } },
    messages: [{ role: 'user', content: JSON.stringify(order.items) }],
  });

  // Step 3: confirm with user (auto)
  return streamText({
    model: openai('gpt-4o'),
    tools: { sendConfirmation },
    toolChoice: 'auto',
    messages: [...],
  });
}

Each step is testable in isolation. You can swap models per step. You can add retries, timeouts, and fallbacks per function without prompt engineering.

2. Evaluation harnesses that don’t flake

When you force a function, your evals assert on arguments, not on whether the model decided to call a tool:

// evals/extract-invoice.test.ts
test('extracts line items from PDF', async () => {
  const result = await streamText({
    model: openai('gpt-4o'),
    tools: { extractInvoice: mockExtractInvoice },
    toolChoice: { type: 'function', function: { name: 'extractInvoice' } },
    messages: [{ role: 'user', content: sampleInvoicePdf }],
  });

  expect(mockExtractInvoice).toHaveBeenCalledWith(
    expect.objectContaining({ pdfBase64: sampleInvoicePdf })
  );
  expect(result.toolResults[0]).toMatchObject(expectedLineItems);
});

No more “sometimes the model answers directly” flakes. The function will be called.

3. Provider-agnostic routing

If you’re building a gateway that routes to different models — some better at planning, some better at execution — forced function lets you use a cheap planner model to pick the tool, then a capable executor model to run it:

// Planner picks the tool (cheap model)
const plan = await streamText({
  model: openai('gpt-4o-mini'),
  tools: { search, calculate, translate },
  toolChoice: 'required',
  messages: userMessages,
});

const chosenTool = plan.toolCalls[0].functionName;

// Executor runs it (capable model)
const result = await streamText({
  model: openai('gpt-4o'),
  tools: { [chosenTool]: tools[chosenTool] },
  toolChoice: { type: 'function', function: { name: chosenTool } },
  messages: userMessages,
});

This pattern works well when you’re already managing multiple models behind a single endpoint — something n4n.ai handles with automatic fallback and per-token metering across 240+ models.

Common pitfalls

Forgetting toolChoice persists across turns

In multi-turn conversations, toolChoice applies to every call unless you override it. A common bug: setting required for an extraction turn, then forgetting to switch back to auto for the follow-up chat.

// Turn 1: extraction
const extraction = await streamText({
  toolChoice: 'required',
  tools: { extract },
  messages: [...userMessage],
});

// Turn 2: user asks a question — but toolChoice is still 'required'!
const chat = await streamText({
  // BUG: missing toolChoice: 'auto'
  tools: { search },
  messages: [...extraction.messages, userFollowup],
});

Fix: pass toolChoice explicitly on every call, or wrap in a helper that resets per turn.

Forcing a function that doesn’t exist

TypeScript catches this if you use the tool object directly, but string literals bypass checking:

// Runtime error if 'validatAddress' typo exists
toolChoice: { type: 'function', function: { name: 'validatAddress' } }

// Type-safe approach
const tools = { validateAddress, calculateShipping } as const;
type ToolName = keyof typeof tools;

function forceTool<T extends ToolName>(name: T) {
  return { type: 'function' as const, function: { name } };
}

// Usage — typo caught at compile time
toolChoice: forceTool('validatAddress') // Error: not assignable

Assuming required prevents hallucinated arguments

required only forces a tool call. The model still generates arguments. If your schema is loose (z.object({}).passthrough()), you’ll get garbage. Always pair required with strict Zod schemas and runtime validation in onToolCall.

When none matters

toolChoice: 'none' disables tools entirely. Use it for:

  • Final answer generation after tool results are in
  • Guardrails: “answer only from context, no external calls”
  • Cost control on high-volume chat where tools aren’t needed
const answer = await streamText({
  model: openai('gpt-4o'),
  tools: { search }, // available but disabled
  toolChoice: 'none',
  messages: [...toolResults, userQuestion],
});

Verdict: which mode for which job

Use case Recommended mode Rationale
Open-ended chat assistant auto User intent unpredictable; tools optional
Structured extraction (invoices, entities) required Must get structured data; model picks right extractor
Classification / routing required Must classify; model chooses classifier
Multi-step workflow orchestration Forced function per step Code controls flow; each step testable
Evaluation / CI pipelines Forced function Deterministic invocation; no flakes
Planner-executor split required (planner) → forced (executor) Cheap model plans, capable model executes
Final answer synthesis none Prevents recursive tool calls after data gathered
User-triggered actions (“book this”) Forced function Intent already known; skip planning

One more thing: streaming changes the calculus

With streamText, toolChoice affects when the first token arrives. auto and forced function can stream text immediately if the model answers directly. required always waits for a tool call — the model cannot emit text until it invokes a function. This adds 200-500ms latency on the first chunk. If you’re building a chat UI with typing indicators, required feels slower even if total time is similar.

// This streams text immediately if model answers directly
const fast = streamText({ toolChoice: 'auto', ... });

// This waits for tool call before any text
const slow = streamText({ toolChoice: 'required', ... });

Plan your UX accordingly. For user-facing chat, consider auto with a follow-up required call only when extraction is actually needed.


The toolChoice parameter is a lever, not a default. Treat auto as a prototype setting. Move to required when you have a contract. Reach for forced function when you need determinism, testability, or explicit workflow control. The SDK gives you all three — use them deliberately.

Tagsvercel-ai-sdktoolchoicetool-callingcomparison

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 →