Skip to content

Named tool_choice is rejected as unenforceable, but its constraint is prompt text #223

Description

@MichaelDementii

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

  1. 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.
  2. Should the closer injection be part of that change, so the result is a guarantee rather than a
    strong best effort?
  3. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions