Observation
Every forced form of tool_choice is refused with HTTP 400 on all three APIs:
| request |
error code |
Chat tool_choice: "required" |
tool_choice_not_supported |
Chat tool_choice: {"type":"function","function":{"name":"get_weather"}} |
tool_choice_not_supported |
Chat allowed_tools with mode: "required" |
tool_choice_not_supported |
Responses tool_choice: "required" and named |
tool_choice_not_supported |
Messages tool_choice: {"type":"any"} and {"type":"tool"} |
invalid_request_error |
tool_choice='required' requires at least one tool call, which NInfer cannot guarantee
docs/serving.md states the reason once for the whole group: NInfer does not "enforce JSON Schema
through constrained decoding", so strict:true, required and named tool choice, hosted tools and
custom tools are all rejected together.
Why it matters
auto is the only executable value, and it does not stand in for the others. Three tools
declared, 16 requests of the kind a client sends when its own contract has already decided that a
call must happen:
tool_choice: auto -> a tool call in 1 of 16.
A client that needs a call has no supported way to ask for one. The request fails instead.
What a named choice actually needs
Qwen's call syntax opens with a literal that NInfer itself renders today
(chat_template.cpp, render_tool_call):
<tool_call>
<function=NAME>
For a named choice that literal is the entire constraint, and it is prompt text. Nothing has
to be constrained in the sampler. NInfer already continues a partially written assistant turn
(PromptContinuationMode::ContinueFinalAssistant), so the mechanism can be measured from outside
today, through an Anthropic assistant prefill. Same 16 prompts, each forced to each of the three
tools, 48 requests:
| forced span |
function name |
<tool_call> block closed |
<tool_call>\n<function=NAME>\n (named choice) |
48/48, by construction |
42/48 |
<tool_call>\n<function= (the required analogue) |
13/16 declared |
15/16 |
The two rows point in opposite directions, and that is the point of this report:
required over several tools is genuinely not enforceable this way. The name is still
sampled, and the model produced tool_list, do_nothing and public.get_weather. The current
rejection is correct and should stay.
- A named choice is a different case. The name never reaches the sampler at all. What is left
is whether the model closes a block it has already begun, which is the same risk NInfer already
accepts for an auto call, not a new one.
The documented justification is exact for strict:true and for multi-tool required. For a named
selection it rejects on a capability that case does not use.
The missing half of a hard guarantee
All six failures end after </function> with stop_reason: end_turn: the model ends the turn
inside an open block. That tail is a fixed suffix, and NInfer already injects a fixed token span
into the middle of a generation for the thinking budget
(OutputSession::pending_control_tokens / preview_control). The same facility, triggered on
"the turn ends inside a forced tool call", turns 42/48 into a guarantee with no sampler work.
Questions before any implementation
- Is accepting a named
tool_choice in scope, implemented as (a) the opener rendered into the
generation prompt and (b) the same literal seeded into ToolCallOutputDecoder so the parse sees
a complete call? required and any would be accepted only when exactly one tool is callable,
which is the same span; multi-tool required would keep today's rejection.
- Should the closer injection be part of that change, so the result is a guarantee rather than a
strong best effort?
- A forced opener has no place in a reasoning-enabled prompt, which ends at
<think>. Restrict a
first change to requests with reasoning disabled, or reach the answer position through the
control-span path in the same change?
Out of scope either way: strict:true and JSON Schema enforcement, parallel_tool_calls:false,
hosted and custom tools.
Environment
Commit b88c0f6f (master), built clean; artifact qwen3_6_35b_a3b.ninfer
(qwen3.6-35b-a3b, groupwise-int); RTX 5090 32 GiB, driver 591.86, CUDA 13.1, Ubuntu 26.04 under
WSL2; server ninfer-serve <artifact> --port 18080 --max-context 32768 --greedy; every request
sent with reasoning disabled. Greedy sampling, so each row above is a single deterministic run,
not an average.
Reproduction
curl -s localhost:18080/v1/chat/completions -H 'content-type: application/json' -d '{
"model":"qwen3.6-35b-a3b",
"messages":[{"role":"user","content":"Hello!"}],
"tools":[{"type":"function","function":{"name":"get_weather",
"parameters":{"type":"object","properties":{"city":{"type":"string"}}}}}],
"tool_choice":"required"}'
The three counts come from one script over the same 16 prompts and 3 tools; I can attach it or
fold it into a test if that is useful.
Checks not performed
Reasoning-enabled prompts were not measured: assistant continuation refuses to start in thinking
mode, so the external emulation cannot reach that path. Only the 35B-A3B artifact was used. No
other model family and no vision path was exercised.
Observation
Every forced form of
tool_choiceis refused with HTTP 400 on all three APIs:tool_choice: "required"tool_choice_not_supportedtool_choice: {"type":"function","function":{"name":"get_weather"}}tool_choice_not_supportedallowed_toolswithmode: "required"tool_choice_not_supportedtool_choice: "required"and namedtool_choice_not_supportedtool_choice: {"type":"any"}and{"type":"tool"}invalid_request_errordocs/serving.mdstates the reason once for the whole group: NInfer does not "enforce JSON Schemathrough constrained decoding", so
strict:true, required and named tool choice, hosted tools andcustom tools are all rejected together.
Why it matters
autois the only executable value, and it does not stand in for the others. Three toolsdeclared, 16 requests of the kind a client sends when its own contract has already decided that a
call must happen:
tool_choice: auto-> a tool call in 1 of 16.A client that needs a call has no supported way to ask for one. The request fails instead.
What a named choice actually needs
Qwen's call syntax opens with a literal that NInfer itself renders today
(
chat_template.cpp,render_tool_call):For a named choice that literal is the entire constraint, and it is prompt text. Nothing has
to be constrained in the sampler. NInfer already continues a partially written assistant turn
(
PromptContinuationMode::ContinueFinalAssistant), so the mechanism can be measured from outsidetoday, through an Anthropic assistant prefill. Same 16 prompts, each forced to each of the three
tools, 48 requests:
<tool_call>block closed<tool_call>\n<function=NAME>\n(named choice)<tool_call>\n<function=(therequiredanalogue)The two rows point in opposite directions, and that is the point of this report:
requiredover several tools is genuinely not enforceable this way. The name is stillsampled, and the model produced
tool_list,do_nothingandpublic.get_weather. The currentrejection is correct and should stay.
is whether the model closes a block it has already begun, which is the same risk NInfer already
accepts for an
autocall, not a new one.The documented justification is exact for
strict:trueand for multi-toolrequired. For a namedselection it rejects on a capability that case does not use.
The missing half of a hard guarantee
All six failures end after
</function>withstop_reason: end_turn: the model ends the turninside an open block. That tail is a fixed suffix, and NInfer already injects a fixed token span
into the middle of a generation for the thinking budget
(
OutputSession::pending_control_tokens/preview_control). The same facility, triggered on"the turn ends inside a forced tool call", turns 42/48 into a guarantee with no sampler work.
Questions before any implementation
tool_choicein scope, implemented as (a) the opener rendered into thegeneration prompt and (b) the same literal seeded into
ToolCallOutputDecoderso the parse seesa complete call?
requiredandanywould be accepted only when exactly one tool is callable,which is the same span; multi-tool
requiredwould keep today's rejection.strong best effort?
<think>. Restrict afirst change to requests with reasoning disabled, or reach the answer position through the
control-span path in the same change?
Out of scope either way:
strict:trueand JSON Schema enforcement,parallel_tool_calls:false,hosted and custom tools.
Environment
Commit
b88c0f6f(master), built clean; artifactqwen3_6_35b_a3b.ninfer(
qwen3.6-35b-a3b, groupwise-int); RTX 5090 32 GiB, driver 591.86, CUDA 13.1, Ubuntu 26.04 underWSL2; server
ninfer-serve <artifact> --port 18080 --max-context 32768 --greedy; every requestsent with reasoning disabled. Greedy sampling, so each row above is a single deterministic run,
not an average.
Reproduction
The three counts come from one script over the same 16 prompts and 3 tools; I can attach it or
fold it into a test if that is useful.
Checks not performed
Reasoning-enabled prompts were not measured: assistant continuation refuses to start in thinking
mode, so the external emulation cannot reach that path. Only the 35B-A3B artifact was used. No
other model family and no vision path was exercised.