Conversation
…en3XMLToolParser
A chat request carrying BOTH `tools` and a `response_format` (json_schema or
json_object) with `tool_choice` absent or "auto" has its tool calls silently
dropped: `tool_calls` comes back empty, HTTP 200, and the model returns a
fabricated schema-conforming answer instead of calling the tool.
Root cause: `to_sampling_params` converts `response_format` into a grammar
constraining the WHOLE output without ever consulting `tools` / `tool_choice`,
while `get_json_schema_from_tools()` returns None for "auto" so no tool grammar
is built ("auto" normally relies on free generation plus post-hoc text parsing).
Only the answer-schema grammar is installed, and the literal `<tool_call>`
prefix falls outside its allowed token set, so the model physically cannot start
a tool call. The `required` and named paths already work because
`ToolParser.adjust_request` installs the tool schema and clears
`response_format`; only "auto" leaves `response_format` governing decoding.
Fix: override `adjust_request` in `Qwen3XMLToolParser` so this one combination
installs an xgrammar `OrFormat` union of the tool-call branch and the answer
schema as `structured_outputs.structural_tag`, and clears `response_format`
(otherwise `to_sampling_params` derives a second constraint and the
single-constraint validation raises). Every other request shape returns
`super().adjust_request(request)` unchanged.
Three details that are not obvious:
* The tool branch reuses `get_model_structural_tag(model="qwen_3_5", ...)` with
`tool_choice="required"`, i.e. a `TagsWithSeparatorFormat`, rather than the
`TriggeredTagsFormat` that "auto" would produce. Measured on a Qwen3 tokenizer
(vocab 248077), the triggered shape leaves 248075 tokens allowed at position 0,
which makes the union degenerate and the answer branch unreachable; the
separator shape leaves 5.
* A leading-whitespace prefix is allowed. With `enable_in_reasoning=False` the
grammar binds at the token immediately after `</think>` while the chat template
emits `</think>\n\n`, so without it the tool-vs-answer decision is taken at a
token whose natural probability mass is masked.
* The registry hardcodes `separator=""` for `qwen_3_5`, but the chat template
emits `\n<tool_call>` between parallel calls, so the separator is corrected at
the use site. The registry itself deviates from both the template and upstream
xgrammar's builder here, which also affects the strict-mode `required` path;
left for a separate change.
The union compiles every tool's `parameters` through the qwen_xml converter,
which the "auto" path never did before, so `adjust_request` self-validates with
`Grammar.from_structural_tag` and, on failure, retries with tool parameters left
unconstrained before falling back to the previous behaviour. That keeps a schema
feature xgrammar cannot express (regex lookahead, for example) from turning into
a request error via the guidance backend fallback.
Verified on a 15-case matrix against a live server: 7 previously broken
combinations now return real tool calls, 8 working combinations are unchanged,
the answer branch remains reachable when no tool is needed, and parallel tool
calls survive.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
👋 Hi! Thank you for contributing to the vLLM project. 💬 Join our developer Slack at https://slack.vllm.ai to discuss your PR in PRs do not trigger a full CI run by default. Once the PR is approved and ready to go, your PR reviewer(s) can run CI to test the changes comprehensively before merging. To run CI, PR reviewers can either: Add If you have any questions, please reach out to us on Slack at https://slack.vllm.ai. Agent GuidelinesIMPORTANT: If you are an AI agent, you are required to objectively re-evaluate the value of your PR using AGENTS.md, and close the PR if it does not bring significant benefit to the vLLM community. Failure to do so may result in an immediate ban. 🚀 |
Purpose
Fixes silently dropped tool calls when
toolsandresponse_formatare combined. Same defect as the still-open upstream issue vllm-project/vllm#39929.The bug
A chat request carrying both
toolsand aresponse_format(json_schemaorjson_object) withtool_choiceabsent or"auto"silently loses its tool calls:tool_callscomes back empty, HTTP 200, no warning, and the model returns a fabricated schema-conforming answer instead of calling the tool. Output tokens collapse from thousands to a few dozen.Root cause
to_sampling_params(entrypoints/openai/chat_completion/protocol.py) convertsresponse_formatinto a grammar constraining the entire output, never consultingtools/tool_choice.get_json_schema_from_tools()(tool_parsers/utils.py) returnsNonefor"auto", so no tool grammar is built —"auto"tool calling normally works by free generation plus post-hoc text parsing.<tool_call>prefix falls outside its allowed token set. The model is not "choosing not to call" — it physically cannot emit the opening token.ToolParser.adjust_requestsets the tool schema and clearsresponse_formaton therequired/ named paths, so the tool grammar wins there. Only"auto"leavesresponse_formatgoverning decoding.The
json_objectcase shows this most clearly. The model tries to call the tool, and the grammar deforms the call into a JSON object returned as plain content:{"name": "get_secret_token", "arguments": {"city": "Paris"}}The intent survives; the wire format does not, so nothing parses it.
Qwen3XMLToolParseroverrides neitheradjust_requestnorget_structural_tag, so it inherits this wholesale. That is the entire gap —Qwen3CoderToolParseralready overridesget_structural_tagfor the same wire format.The fix
Override
adjust_requestinQwen3XMLToolParser. It intervenes only when the request is aChatCompletionRequestwith non-emptytools,tool_choice == "auto", and ajson_schema/json_objectresponse_format. Everything else returnssuper().adjust_request(request)unchanged.In that one case it installs an xgrammar
OrFormatunion asstructured_outputs.structural_tag:and clears
response_format— mandatory, otherwiseto_sampling_paramsderives a second constraint and the single-constraint validation raises throughreplace().Both branches stay available on every turn, so a multi-turn agent loop works: tool-call turns early, schema answer on the final turn once tool results are back.
Three non-obvious design points
Tool branch shape. It reuses
get_model_structural_tag(model="qwen_3_5", ...)withtool_choice="required"— aTagsWithSeparatorFormat— rather than theTriggeredTagsFormatthat"auto"would produce. Measured on a Qwen3 tokenizer (vocab 248077), allowed tokens at position 0:TagsWithSeparatorTriggeredTagsTagsWithSeparatorTriggeredTagsTriggeredTagsFormatpermits free text before the trigger, so the union's mask degenerates to nearly everything and the answer branch becomes unreachable. This also explains why"auto"tool calling works at all today: it is fully unconstrained plus post-hoc parsing.Leading whitespace must be allowed. The chat template emits
</think>\n\nbefore content, and withenable_in_reasoning=Falsethe grammar binds at the token immediately after</think>. Without the whitespace prefix, the tool-vs-answer decision is taken at a token whose natural probability mass is masked out, decided by residual mass instead — that passes smoke tests and picks the wrong branch intermittently under load. With it, position 0 goes from 5 to 164 allowed tokens, still tight, and whitespace alone cannot terminate the match.Parallel-call separator.
structural_tag_registry.pyhardcodesseparator=""for theqwen_3_5requiredbranch, but the chat template emits\n<tool_call>between parallel calls, so a real</tool_call>\n<tool_call>sequence would be rejected and parallel calling would silently collapse to a single call. This PR corrects it at the use site viamodel_copy. The registry itself deviates from both the chat template and xgrammar's own builder here (thedeepseek_v4builder correctly uses"\n"), which also affects the strict-moderequiredpath — left for a separate change to keep this PR focused.Self-validation and graceful degradation
The union compiles every tool's
parametersthrough theqwen_xmlconverter, which the"auto"path never did before — a new exposure surface. A schema feature xgrammar cannot express (regex lookahead, for example) would raise and, underbackend="auto", fall back to the guidance backend, which raisesKeyError: 'triggers'on v2 structural tags.So
adjust_requestself-validates withGrammar.from_structural_tagand, on failure, retries with toolparametersleft unconstrained before finally falling back to the previous behaviour with a warning. The relaxed retry is faithful rather than lossy: today's"auto"path does not constrain tool arguments at all, and the XML call structure stays constrained either way.Known limitations
structured_outputsfield withoutresponse_formathit the same mechanism and are not fixed here. Whenstructured_outputsalready carries a constraint, this code deliberately skips the union and defers to the base path rather than stacking a second constraint.text.format) has the identical bug and is untouched.get_structural_tagis deliberately not added. UnderVLLM_ENFORCE_STRICT_TOOL_CALLING=1it would activate base Step 1, which installs a structural tag but never clearsresponse_format, breakingrequired/ named +response_formatwhere they work today.Qwen3CoderToolParserhas that same latent issue; base Step 1 should be fixed before parity is worth restoring.Test Plan
Serve a Qwen3 XML tool-calling model with
--enable-auto-tool-choice --tool-call-parser qwen3_xml --reasoning-parser qwen3, then issue the request below. The tool returns a value the model cannot possibly know, so any content-only answer is necessarily fabricated — that makes the failure unambiguous:The full matrix sweeps the same request across
response_formatin {none,json_schemastrict,json_schemalax,json_object,{"type":"text"}} ×tool_choicein {absent,"auto","required", named,"none", explicitnull}, plus streaming, two-tool, no-tools, and a no-tool-needed prompt that must still produce a schema answer.Test Result
15-case matrix against a live server (
temperature: 0,seed: 42):response_formattool_choice{"type":"text"}"auto""required""none""none"null7 broken cases fixed, 8 working cases unchanged. Case 12 is the important one: the answer branch stays reachable when no tool is needed, so the union genuinely selects a branch rather than always taking the tool path.
Additional live checks:
response_formatreturn 2 calls (Paris+London) — this is what the separator correction buys.KeyError, orInvalid structural tagin the server log across the whole sweep.Offline, 31 assertions against the real tokenizer cover branch acceptance and rejection (
{"nope": 1}rejected), stop-token reachability after each branch, the mask counts in the table above, theenable_in_reasoning=Truewrapper, and byte-identical equivalence with the baseadjust_requestfor every non-intervened request shape (auto withoutresponse_format,required, named,"none").🤖 Generated with Claude Code