Summary
/v1/chat/completions accepts tool_choice in all four OpenAI shapes but only none changes behaviour. required is validated and then ignored: the model is free to answer in prose and the response carries no tool call. A named function ({"type":"function","function":{"name":"f"}}) still renders every declared tool into the prompt and only filters the parsed calls afterwards, so a model that picks another tool returns an empty tool_calls array with finish_reason: "stop". The change makes required and the named form actually steer generation: the tool list handed to the template is narrowed, an instruction is injected into the prompt, the request is rejected with a 400 when it cannot be satisfied, and on tool-call formats whose wire shape is JSON the call is grammar-forced through the existing structured-output constraint.
Current behavior
-
src/server/routes/chat.rs:1149 (validate_chat_tool_inputs) accepts auto, none, required and the object form, and that is the last time required is looked at.
-
src/server/chat_request.rs:748 (effective_tools) strips tools only for none:
if let Some(ref tc) = request.tool_choice && tc.is_none() { return None; }
request.tools.as_deref()
so a named choice still renders every tool.
-
src/server/tool_calls/mod.rs:101 (build_tool_call_responses) and src/server/routes/chat.rs:1026 (the streaming finish path) drop parsed calls whose name differs from the named function, after generation. Nothing prevents the model from choosing another function or from not calling at all.
-
src/server/types/request.rs:66 (ToolChoice) has no notion of required beyond the string.
-
The Anthropic translator (src/server/anthropic_translator.rs:596, convert_tool_choice) and the Responses translator (src/server/responses_translator.rs:198) both forward into the same field, so they inherit the same gap ({"type":"any"} and {"type":"tool","name":...} are accepted and not enforced).
-
The grammar engine already exists: src/server/structured.rs:535 (build_json_schema_constraint) compiles a JSON schema into an llguidance matcher and ServerGenerateOptions.structured (src/server/config.rs:187) carries it to the scheduler, which applies it per step at src/server/batch/scheduler.rs:3141.
Expected behavior
Semantics, resolved once per request before template rendering:
tool_choice |
tools handed to the template |
prompt instruction |
constraint |
error cases |
absent / "auto" |
all |
none |
none |
none |
"none" |
none (current behaviour) |
none |
none |
none |
"required" |
all |
"You must call one or more of the available functions to answer the user's request. Do not answer directly without calling a function." |
forced tool call when the resolved format is grammar-capable |
400 invalid_request_error when tools is absent or empty |
{"type":"function","function":{"name":"f"}} |
only f |
"You must call the 'f' function to answer the user's request. Do not call any other function and do not answer directly." |
forced call to f when grammar-capable |
400 when tools is empty or no tool named f exists; 400 when type is not function or name is empty |
Instruction placement: appended to the first system message as "\n\n" + instruction when one exists; otherwise appended to the last user message whose content is plain text; otherwise inserted as a new leading system message. The original request object is not mutated for response echoing; the injection happens on the message list handed to the renderer.
Grammar-capable formats are the ones whose emitted call is a JSON object with a fixed wrapper and whose wrapper can be read off the loaded chat template before generation: ToolCallFormat::Hermes when the template text contains <tool_call> (Qwen2.5 / Qwen3 / Hermes templates), ToolCallFormat::MistralNemo when it contains [TOOL_CALLS], ToolCallFormat::Llama3 when it contains <|python_tag|> or renders "parameters":. There is no operator parser flag today (src/server/chat_template.rs:297, resolve_tool_call_format, only infers ATEM from template identity), so this template sniffing is the selection mechanism. For those, when the request carries no response_format, the server builds a constraint from the tool schemas:
schema(f) = {"type":"object",
"properties": {"name": {"const": f.name},
"arguments": f.parameters or {"type":"object"}},
"required": ["name","arguments"],
"additionalProperties": false}
required: anyOf over schema(f) for every declared f
named: schema(f) for the named f only
wrapped in the format's delimiters with an llguidance Lark grammar:
start: "<tool_call>" %json { <schema> } "</tool_call>" # Hermes
start: %json { <schema> } # Llama3
start: "[TOOL_CALLS] [" %json { <schema> } "]" # MistralNemo
(Llama3 uses the key parameters instead of arguments in its JSON; the schema uses the format's key.) Formats without a JSON wire shape (ATEM, Gemma 4, FunctionGemma, Command R, Functionary, Qwen3-Coder XML, MiniMax, Kimi K2, Pythonic) get the instruction and tool narrowing only. When the constrained generation finishes, finish_reason is tool_calls.
A request that combines tool_choice: "required" or a named function with response_format: json_schema is rejected with a 400 (two constraints cannot be composed).
Implementation plan
src/server/types/request.rs: add to impl ToolChoice:
pub fn is_required(&self) -> bool (Mode("required")).
pub fn validate(&self, tools: Option<&[Tool]>) -> Result<(), String> implementing the 400 cases of the table above (unknown mode, required without tools, named function not declared, type != "function", empty name). Move the string-mode check out of validate_chat_tool_inputs into this method so the disaggregated router front (src/server/router_front.rs) and routes/chat.rs share it.
src/server/chat_request.rs:
- Change
effective_tools(request) to return only the named tool for the named form (keep None for none, all tools otherwise). Because chat_carries_loop_amplifier (src/server/request_options.rs:133) reads the same helper, update its doc comment: a single narrowed tool still counts as "tools rendered".
- Add
pub(crate) fn tool_choice_instruction(choice: &ToolChoice) -> Option<String> returning the two instruction strings.
- Add
pub(crate) fn inject_tool_choice_instruction(messages: &mut Vec<Message>, instruction: &str) with the placement rule above (Message.content is MessageContent::Text for the plain-text cases; a Parts user message gets a trailing ContentPart::Text).
- Call both from
prepare_chat_request_with_cache on a cloned message list before render_*, so the prompt-cache key (template_sig / history_prompt) is derived from the prompt that is actually rendered.
src/server/chat_template.rs: add pub fn forced_tool_call_format(&self) -> Option<ToolCallFormat> next to default_tool_call_format (src/server/chat_template.rs:293), returning Hermes / MistralNemo / Llama3 from the template-text markers above and None otherwise (ATEM templates return None here even though default_tool_call_format reports Atem). Cache the answer in the processor at construction, like supports_tools.
src/server/tool_calls/mod.rs: add pub fn tool_choice_grammar(format: ToolCallFormat, tools: &[Tool], choice: &ToolChoice) -> Option<String> that returns the Lark grammar text for the three grammar-capable formats and None otherwise.
src/server/structured.rs: add pub fn build_lark_constraint(tokenizer, lark: &str) -> Result<Arc<Mutex<StructuredOutputConstraint>>, StructuredOutputError> next to build_json_schema_constraint, using llguidance::api::TopLevelGrammar::from_lark(lark) with the same ParserLimits, the same TOK_ENV_CACHE, and the same size cap (MAX_SCHEMA_BYTES) applied to the serialized tool schemas.
src/server/routes/chat.rs: in both handlers (non-streaming at ~line 311 where structured is built, streaming at ~line 741), after validate: if tool_choice is required or named and the resolved format is grammar-capable and request.response_format is absent, build the constraint through build_lark_constraint on the blocking pool exactly like the response_format path and store it in options.structured. Reject the response_format + forced-tool combination with 400 before any work.
src/server/routes/chat.rs finish paths (non-streaming parse_tools block and streaming finish_events block around line 1020): when tool_choice is required or named and the parse produced no call, log at warn with the format name and leave finish_reason as the model produced (the instruction-only formats cannot guarantee a call). Keep the existing name filter for the named form as a last line of defence.
src/server/anthropic_translator.rs::convert_tool_choice: map {"type":"any"} to ToolChoice::Mode("required") and {"type":"tool","name":"f"} to ToolChoice::Specific, so the Anthropic route gets the same enforcement. src/server/responses_translator.rs already forwards tool_choice unchanged.
docs/responses-api.md and the server README section on tool calling: document the four modes, the grammar-capable format list, and the 400 cases.
Validation
(a) Unit tests:
src/server/chat_request_tests.rs: effective_tools_narrows_to_named_function, tool_choice_instruction_appends_to_system_message, tool_choice_instruction_appends_to_last_text_user_message, tool_choice_instruction_inserts_system_when_no_system_and_no_text_user, named_tool_choice_changes_rendered_prompt_and_template_sig.
src/server/types/request.rs tests: tool_choice_required_without_tools_is_rejected, tool_choice_unknown_function_is_rejected, tool_choice_object_without_function_type_is_rejected.
src/server/tool_calls/mod.rs tests: tool_choice_grammar_hermes_required_is_anyof_over_all_tools, tool_choice_grammar_named_pins_name_const, tool_choice_grammar_is_none_for_atem_and_gemma4.
src/server/chat_template.rs tests: forced_tool_call_format_detects_hermes_marker, forced_tool_call_format_is_none_for_atem_template.
src/server/structured_tests.rs: lark_constraint_forces_hermes_wrapper (feed the matcher <tool_call>{"name":"get_weather","arguments":{}}</tool_call> token by token via consume_token, assert every step is allowed by compute_mask and the matcher reports stopped at the end; assert a plain-text first token is masked out).
src/server/anthropic_translator.rs tests: anthropic_tool_choice_any_maps_to_required, anthropic_tool_choice_tool_maps_to_named.
(b) Real checkpoint: mlx-community/Qwen3-4B-4bit (Hermes tool format, grammar-capable).
./target/release/mlxcel-server -m models/qwen3-4b-4bit --port 8080
# required
curl -s localhost:8080/v1/chat/completions -H 'content-type: application/json' -d '{
"model":"q","messages":[{"role":"user","content":"Say hello."}],
"tool_choice":"required",
"tools":[{"type":"function","function":{"name":"get_time","parameters":{"type":"object","properties":{"tz":{"type":"string"}},"required":["tz"]}}}]
}' | jq '.choices[0] | {finish_reason, tool_calls: .message.tool_calls}'
# named, with two tools declared
curl -s localhost:8080/v1/chat/completions -H 'content-type: application/json' -d '{
"model":"q","messages":[{"role":"user","content":"What time is it in Seoul?"}],
"tool_choice":{"type":"function","function":{"name":"get_weather"}},
"tools":[{"type":"function","function":{"name":"get_time","parameters":{"type":"object","properties":{"tz":{"type":"string"}}}}},
{"type":"function","function":{"name":"get_weather","parameters":{"type":"object","properties":{"city":{"type":"string"}}}}}]
}' | jq '.choices[0] | {finish_reason, name: .message.tool_calls[0].function.name}'
Acceptance: the first call returns finish_reason: "tool_calls" with a get_time call on 10 of 10 runs at temperature: 0.7 (the grammar makes this deterministic, not probabilistic); the second returns a get_weather call and never get_time on 10 of 10 runs; tool_choice: "required" with "tools": [] returns HTTP 400. Repeat the required case with stream: true and confirm the tool-call deltas arrive and the final chunk carries finish_reason: "tool_calls". Run the same two requests against mlx-community/Muse-Glimmer-30B-4bit (ATEM, instruction-only path) and confirm the named form yields a get_weather call on at least 9 of 10 runs.
Acceptance criteria
Out of scope
parallel_tool_calls: false enforcement.
- Grammar forcing for XML and ATEM tool formats (would need per-format Lark grammars with string escaping rules).
/v1/completions (no tool concept).
Summary
/v1/chat/completionsacceptstool_choicein all four OpenAI shapes but onlynonechanges behaviour.requiredis validated and then ignored: the model is free to answer in prose and the response carries no tool call. A named function ({"type":"function","function":{"name":"f"}}) still renders every declared tool into the prompt and only filters the parsed calls afterwards, so a model that picks another tool returns an emptytool_callsarray withfinish_reason: "stop". The change makesrequiredand the named form actually steer generation: the tool list handed to the template is narrowed, an instruction is injected into the prompt, the request is rejected with a 400 when it cannot be satisfied, and on tool-call formats whose wire shape is JSON the call is grammar-forced through the existing structured-output constraint.Current behavior
src/server/routes/chat.rs:1149(validate_chat_tool_inputs) acceptsauto,none,requiredand the object form, and that is the last timerequiredis looked at.src/server/chat_request.rs:748(effective_tools) strips tools only fornone:so a named choice still renders every tool.
src/server/tool_calls/mod.rs:101(build_tool_call_responses) andsrc/server/routes/chat.rs:1026(the streaming finish path) drop parsed calls whose name differs from the named function, after generation. Nothing prevents the model from choosing another function or from not calling at all.src/server/types/request.rs:66(ToolChoice) has no notion ofrequiredbeyond the string.The Anthropic translator (
src/server/anthropic_translator.rs:596,convert_tool_choice) and the Responses translator (src/server/responses_translator.rs:198) both forward into the same field, so they inherit the same gap ({"type":"any"}and{"type":"tool","name":...}are accepted and not enforced).The grammar engine already exists:
src/server/structured.rs:535(build_json_schema_constraint) compiles a JSON schema into an llguidance matcher andServerGenerateOptions.structured(src/server/config.rs:187) carries it to the scheduler, which applies it per step atsrc/server/batch/scheduler.rs:3141.Expected behavior
Semantics, resolved once per request before template rendering:
tool_choice"auto""none""required"invalid_request_errorwhentoolsis absent or empty{"type":"function","function":{"name":"f"}}ffwhen grammar-capabletoolsis empty or no tool namedfexists; 400 whentypeis notfunctionornameis emptyInstruction placement: appended to the first
systemmessage as"\n\n" + instructionwhen one exists; otherwise appended to the lastusermessage whose content is plain text; otherwise inserted as a new leadingsystemmessage. The original request object is not mutated for response echoing; the injection happens on the message list handed to the renderer.Grammar-capable formats are the ones whose emitted call is a JSON object with a fixed wrapper and whose wrapper can be read off the loaded chat template before generation:
ToolCallFormat::Hermeswhen the template text contains<tool_call>(Qwen2.5 / Qwen3 / Hermes templates),ToolCallFormat::MistralNemowhen it contains[TOOL_CALLS],ToolCallFormat::Llama3when it contains<|python_tag|>or renders"parameters":. There is no operator parser flag today (src/server/chat_template.rs:297,resolve_tool_call_format, only infers ATEM from template identity), so this template sniffing is the selection mechanism. For those, when the request carries noresponse_format, the server builds a constraint from the tool schemas:wrapped in the format's delimiters with an llguidance Lark grammar:
(Llama3 uses the key
parametersinstead ofargumentsin its JSON; the schema uses the format's key.) Formats without a JSON wire shape (ATEM, Gemma 4, FunctionGemma, Command R, Functionary, Qwen3-Coder XML, MiniMax, Kimi K2, Pythonic) get the instruction and tool narrowing only. When the constrained generation finishes,finish_reasonistool_calls.A request that combines
tool_choice: "required"or a named function withresponse_format: json_schemais rejected with a 400 (two constraints cannot be composed).Implementation plan
src/server/types/request.rs: add toimpl ToolChoice:pub fn is_required(&self) -> bool(Mode("required")).pub fn validate(&self, tools: Option<&[Tool]>) -> Result<(), String>implementing the 400 cases of the table above (unknown mode,requiredwithout tools, named function not declared,type != "function", empty name). Move the string-mode check out ofvalidate_chat_tool_inputsinto this method so the disaggregated router front (src/server/router_front.rs) androutes/chat.rsshare it.src/server/chat_request.rs:effective_tools(request)to return only the named tool for the named form (keepNonefornone, all tools otherwise). Becausechat_carries_loop_amplifier(src/server/request_options.rs:133) reads the same helper, update its doc comment: a single narrowed tool still counts as "tools rendered".pub(crate) fn tool_choice_instruction(choice: &ToolChoice) -> Option<String>returning the two instruction strings.pub(crate) fn inject_tool_choice_instruction(messages: &mut Vec<Message>, instruction: &str)with the placement rule above (Message.contentisMessageContent::Textfor the plain-text cases; aPartsuser message gets a trailingContentPart::Text).prepare_chat_request_with_cacheon a cloned message list beforerender_*, so the prompt-cache key (template_sig/history_prompt) is derived from the prompt that is actually rendered.src/server/chat_template.rs: addpub fn forced_tool_call_format(&self) -> Option<ToolCallFormat>next todefault_tool_call_format(src/server/chat_template.rs:293), returningHermes/MistralNemo/Llama3from the template-text markers above andNoneotherwise (ATEM templates returnNonehere even thoughdefault_tool_call_formatreportsAtem). Cache the answer in the processor at construction, likesupports_tools.src/server/tool_calls/mod.rs: addpub fn tool_choice_grammar(format: ToolCallFormat, tools: &[Tool], choice: &ToolChoice) -> Option<String>that returns the Lark grammar text for the three grammar-capable formats andNoneotherwise.src/server/structured.rs: addpub fn build_lark_constraint(tokenizer, lark: &str) -> Result<Arc<Mutex<StructuredOutputConstraint>>, StructuredOutputError>next tobuild_json_schema_constraint, usingllguidance::api::TopLevelGrammar::from_lark(lark)with the sameParserLimits, the sameTOK_ENV_CACHE, and the same size cap (MAX_SCHEMA_BYTES) applied to the serialized tool schemas.src/server/routes/chat.rs: in both handlers (non-streaming at ~line 311 wherestructuredis built, streaming at ~line 741), aftervalidate: iftool_choiceisrequiredor named and the resolved format is grammar-capable andrequest.response_formatis absent, build the constraint throughbuild_lark_constrainton the blocking pool exactly like theresponse_formatpath and store it inoptions.structured. Reject theresponse_format+ forced-tool combination with 400 before any work.src/server/routes/chat.rsfinish paths (non-streamingparse_toolsblock and streamingfinish_eventsblock around line 1020): whentool_choiceisrequiredor named and the parse produced no call, log atwarnwith the format name and leavefinish_reasonas the model produced (the instruction-only formats cannot guarantee a call). Keep the existing name filter for the named form as a last line of defence.src/server/anthropic_translator.rs::convert_tool_choice: map{"type":"any"}toToolChoice::Mode("required")and{"type":"tool","name":"f"}toToolChoice::Specific, so the Anthropic route gets the same enforcement.src/server/responses_translator.rsalready forwardstool_choiceunchanged.docs/responses-api.mdand the server README section on tool calling: document the four modes, the grammar-capable format list, and the 400 cases.Validation
(a) Unit tests:
src/server/chat_request_tests.rs:effective_tools_narrows_to_named_function,tool_choice_instruction_appends_to_system_message,tool_choice_instruction_appends_to_last_text_user_message,tool_choice_instruction_inserts_system_when_no_system_and_no_text_user,named_tool_choice_changes_rendered_prompt_and_template_sig.src/server/types/request.rstests:tool_choice_required_without_tools_is_rejected,tool_choice_unknown_function_is_rejected,tool_choice_object_without_function_type_is_rejected.src/server/tool_calls/mod.rstests:tool_choice_grammar_hermes_required_is_anyof_over_all_tools,tool_choice_grammar_named_pins_name_const,tool_choice_grammar_is_none_for_atem_and_gemma4.src/server/chat_template.rstests:forced_tool_call_format_detects_hermes_marker,forced_tool_call_format_is_none_for_atem_template.src/server/structured_tests.rs:lark_constraint_forces_hermes_wrapper(feed the matcher<tool_call>{"name":"get_weather","arguments":{}}</tool_call>token by token viaconsume_token, assert every step is allowed bycompute_maskand the matcher reports stopped at the end; assert a plain-text first token is masked out).src/server/anthropic_translator.rstests:anthropic_tool_choice_any_maps_to_required,anthropic_tool_choice_tool_maps_to_named.(b) Real checkpoint:
mlx-community/Qwen3-4B-4bit(Hermes tool format, grammar-capable).Acceptance: the first call returns
finish_reason: "tool_calls"with aget_timecall on 10 of 10 runs attemperature: 0.7(the grammar makes this deterministic, not probabilistic); the second returns aget_weathercall and neverget_timeon 10 of 10 runs;tool_choice: "required"with"tools": []returns HTTP 400. Repeat therequiredcase withstream: trueand confirm the tool-call deltas arrive and the final chunk carriesfinish_reason: "tool_calls". Run the same two requests againstmlx-community/Muse-Glimmer-30B-4bit(ATEM, instruction-only path) and confirm the named form yields aget_weathercall on at least 9 of 10 runs.Acceptance criteria
tool_choice: "required"with no tools returns 400; a named function not intoolsreturns 400.template_sig.<tool_call>,[TOOL_CALLS]or<|python_tag|>grammar-force the call; other templates get instruction and narrowing only;response_formatplus a forced tool is a 400.tool_choice: {"type":"any"}and{"type":"tool"}map to the same enforcement.Out of scope
parallel_tool_calls: falseenforcement./v1/completions(no tool concept).