Request.ToolChoice constrains whether and how the model may call tools. It is
one of the few parts of skyl where all four adapters support everything.
You will learn
- The four modes and when each is useful
- Why
ToolChoiceRequiredchanges what you must handle - What
Validatechecks - How this interacts with streaming
The four modes#
const (
ToolChoiceAuto ToolChoiceMode = "auto" // the model decides — the default
ToolChoiceNone ToolChoiceMode = "none" // no tool calls this request
ToolChoiceRequired ToolChoiceMode = "required" // at least one call
ToolChoiceSpecific ToolChoiceMode = "tool" // a named tool
)const (
ToolChoiceAuto ToolChoiceMode = "auto" // the model decides — the default
ToolChoiceNone ToolChoiceMode = "none" // no tool calls this request
ToolChoiceRequired ToolChoiceMode = "required" // at least one call
ToolChoiceSpecific ToolChoiceMode = "tool" // a named tool
)All four are ✅ mapped on anthropic, openai, openaicompat and gemini.
Auto#
The default. A nil ToolChoice means auto — you do not have to set it.
req.ToolChoice = nil // or omit the field entirelyreq.ToolChoice = nil // or omit the field entirelyNone#
Offers the tools but forbids calling them this turn. Useful when you want the model to summarise what it has already learned from tool results rather than reaching for another one.
// Final turn: the model has all the data it needs, so make it answer.
req.ToolChoice = &skyl.ToolChoice{Mode: skyl.ToolChoiceNone}
final, err := client.Complete(ctx, req)// Final turn: the model has all the data it needs, so make it answer.
req.ToolChoice = &skyl.ToolChoice{Mode: skyl.ToolChoiceNone}
final, err := client.Complete(ctx, req)This is the cleanest way to terminate a tool loop deliberately rather than hoping the model stops.
Required#
Forces at least one call.
req.ToolChoice = &skyl.ToolChoice{Mode: skyl.ToolChoiceRequired}req.ToolChoice = &skyl.ToolChoice{Mode: skyl.ToolChoiceRequired}Specific#
Forces one named tool.
req.ToolChoice = &skyl.ToolChoice{
Mode: skyl.ToolChoiceSpecific,
Name: "extract_invoice",
}req.ToolChoice = &skyl.ToolChoice{
Mode: skyl.ToolChoiceSpecific,
Name: "extract_invoice",
}Structured output without a schema mode#
ToolChoiceSpecific is the portable way to get structured output. Declare one
tool whose parameters are the shape you want, force it, and read the arguments:
extract := skyl.Tool{
Name: "record_invoice",
Description: "Record the fields extracted from an invoice.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"number": map[string]any{"type": "string"},
"total": map[string]any{"type": "number"},
},
"required": []string{"number", "total"},
},
}
resp, err := client.Complete(ctx, &skyl.Request{
Model: model,
MaxTokens: 512,
Messages: []skyl.Message{skyl.UserText(documentText)},
Tools: []skyl.Tool{extract},
ToolChoice: &skyl.ToolChoice{Mode: skyl.ToolChoiceSpecific, Name: "record_invoice"},
})
if err != nil {
return err
}
calls := resp.ToolCalls()
if len(calls) == 0 {
return errors.New("no extraction produced")
}
var invoice struct {
Number string `json:"number"`
Total float64 `json:"total"`
}
if err := json.Unmarshal(calls[0].Arguments, &invoice); err != nil {
return err
}extract := skyl.Tool{
Name: "record_invoice",
Description: "Record the fields extracted from an invoice.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"number": map[string]any{"type": "string"},
"total": map[string]any{"type": "number"},
},
"required": []string{"number", "total"},
},
}
resp, err := client.Complete(ctx, &skyl.Request{
Model: model,
MaxTokens: 512,
Messages: []skyl.Message{skyl.UserText(documentText)},
Tools: []skyl.Tool{extract},
ToolChoice: &skyl.ToolChoice{Mode: skyl.ToolChoiceSpecific, Name: "record_invoice"},
})
if err != nil {
return err
}
calls := resp.ToolCalls()
if len(calls) == 0 {
return errors.New("no extraction produced")
}
var invoice struct {
Number string `json:"number"`
Total float64 `json:"total"`
}
if err := json.Unmarshal(calls[0].Arguments, &invoice); err != nil {
return err
}Deep diveWhy not model JSON mode or structured outputs directly?
Because the vendors' native structured-output features are genuinely different: different schema dialects, different guarantees about validity, different support across models, and different names. Modelling the intersection would give you a feature that works on some models and silently degrades on others — exactly the failure mode skyl's design principles reject.
A forced tool call gives you the same thing, portably, using machinery every
provider already implements identically. And where you want a vendor's native
mode, ProviderOptions reaches it — response_format on OpenAI,
responseSchema on Gemini.
Streaming#
ToolChoice behaves identically on a stream. With Required, expect at least
one EventToolCall before EventDone.
Recap
- Four modes, all mapped on all four adapters — a rare case of full parity.
- Nil means
Auto; you rarely need to set it. Noneis the clean way to force a final answer and end a tool loop.Requiredshould still be checked, because compatible hosts vary.SpecificneedsName, enforced locally byValidate.- A forced tool call is the portable route to structured output.
Try out some challenges
Each of these is solvable with what this page covered. Run them against the sandbox — no API key needed.
End a tool loop deliberately
Rewrite the bounded tool loop so that on the last round it forces an answer instead of erroring out.
Show hint
ToolChoiceNone on the final iteration.
Show solution
for round := 0; round <= maxToolRounds; round++ {
if round == maxToolRounds {
// Out of rounds: stop offering the option and make it answer with
// what it already has, rather than failing the whole request.
req.ToolChoice = &skyl.ToolChoice{Mode: skyl.ToolChoiceNone}
}
resp, err := client.Complete(ctx, req)
if err != nil {
return nil, err
}
if calls := resp.ToolCalls(); len(calls) == 0 {
return resp, nil
}
// … append and continue
}for round := 0; round <= maxToolRounds; round++ {
if round == maxToolRounds {
// Out of rounds: stop offering the option and make it answer with
// what it already has, rather than failing the whole request.
req.ToolChoice = &skyl.ToolChoice{Mode: skyl.ToolChoiceNone}
}
resp, err := client.Complete(ctx, req)
if err != nil {
return nil, err
}
if calls := resp.ToolCalls(); len(calls) == 0 {
return resp, nil
}
// … append and continue
}This turns a hard failure into a degraded answer, which is usually what a user would prefer — they get a response based on partial data rather than an error. Log the fact that it happened, though, or you will never notice the tool that caused it.