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
extractInvoicewhen 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.