Skip to content

[Bug][Structured Outputs] Parser._apply_structural_tag overrides a request's response_format when tools are present — schema not enforced (GLM-5.3) #62483

Open

Description

@gringus

Environment

  • vLLM: nightly 77154ca61 (0.32.0rc1.dev100) and 39983a5e7 (0.32.0rc1.dev329) — both reproduce
  • Model: GLM-5.3-Flash (reasoning model; reasoning parser glm45, tool parser glm47, tools supplied in the request)
  • Structured outputs: xgrammar 0.2.8, backend auto
  • Speculative decoding: DFlash2 probabilistic, k=7 (the repro does not depend on spec — it fires with spec on; we have not isolated spec-off)
  • Hardware: 2× NVIDIA DGX Spark (GB10), TP=2 — likely irrelevant
  • The failure is deterministic, single-turn, and does not involve the reasoning parser path

Observed failure

A request carrying both tools and response_format (json_schema) does not enforce the schema. The model free-flows into tool calls on the schema'd turn:

finish_reason = "tool_calls", content = null
tool_calls = [{"function": {"name": "calculator", "arguments": "{\"expression\": \"28\"}"}}]

while the same request without the tools array enforces the schema correctly (a const-forcing schema produces exactly the forced string, finish_reason = "stop"). Deterministic on every attempt.

In multi-turn agentic workloads this compounds: the model re-issues tool calls on schema'd turns, the tool-call structure never completes under the composed grammar, and generation runs until the token budget is exhausted (we observed a single request produce ~130K tokens across repeated 4096-token turns).

Root cause

Parser._apply_structural_tag (vllm/parser/abstract_parser.py, ~lines 505-603 at 77154ca61): when the tool parser supports structural tags and the request carries tools, it composes

OrFormat([tool_call_tag, output_format])

from the request's own response_format, assigns it to request.structured_outputs, and nulls request.response_format. Under the Or, the tool-call branch is legal — so on a schema'd turn the model's preferred continuation (another tool call) passes the grammar and the user's schema is never enforced.

Why this is new (and why 0.31.0 was unaffected)

The composition code exists in 0.31.0 (db9527a468) as well, but it never armed for GLM: structural_tag_registry had no glm_4_7 builder and xgrammar 0.2.7 lacked the builtin, so tools_structural_tag was None and the function returned early with response_format intact — schema-only grammar. The 0.32 nightly added the _glm_4_7_tool_tag builder (abstract_parser.py:1047) together with xgrammar 0.2.8, which armed the composition for GLM.

Minimal reproduction

Server: any GLM-5.3 checkpoint served with --enable-auto-tool-choice --tool-call-parser glm47. Client (single turn, temperature 0):

import json, urllib.request

schema = {
    "type": "object",
    "properties": {"answer": {"type": "string", "const": "GRAMMAR_WORKS"}},
    "required": ["answer"],
    "additionalProperties": False,
}
tools = [{
    "type": "function",
    "function": {
        "name": "calculator",
        "description": "Evaluate an arithmetic expression.",
        "parameters": {"type": "object",
                       "properties": {"expression": {"type": "string"}},
                       "required": ["expression"]},
    },
}]

def run(with_tools):
    body = {
        "model": "<model>",
        "messages": [{"role": "user", "content": "What is 2+2? Answer with the required field."}],
        "max_tokens": 512, "temperature": 0,
        "response_format": {"type": "json_schema", "json_schema": {"name": "t", "schema": schema}},
    }
    if with_tools:
        body["tools"] = tools
        body["tool_choice"] = "auto"
    req = urllib.request.Request("<base-url>/v1/chat/completions",
                                 data=json.dumps(body).encode(),
                                 headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=180) as r:
        out = json.loads(r.read())
    c = out["choices"][0]
    print("tools =", with_tools,
          "| finish =", c["finish_reason"],
          "| content =", repr(c["message"]["content"]),
          "| tool_calls =", repr(c["message"]["tool_calls"])[:80])

run(with_tools=False)  # schema enforced: content == '{"answer": "GRAMMAR_WORKS"}', finish=stop
run(with_tools=True)   # schema ignored:  finish=tool_calls, content=None

Related issues

Workaround (validated)

With tool_choice=auto, keep the user's schema as the only grammar — do not compose the tool-call branch (this restores the 0.31.0 behavior). required/named tool_choice still compose, since a forced call is the user's explicit intent. With this change, all four previously-failing structured-output scenarios in our eval suite recover to full score, and eval parity with 0.31.0 is restored.

Question for maintainers

Should tool_choice=auto widen the grammar at all when the user explicitly requested a response_format schema? If composition stays for auto, should the tool branch be gated per the strict-thinking direction proposed in #60722?

Activity

  1. he-yufeng commented on Oct 19, 2026

    @he-yufeng
    Contributor

    Read the composition and its history end to end. The facts that matter for the maintainer question:

    1. The Or composition is deliberate, not an accident: it came in with PR [Bugfix] Support repsonse_format + tool_choice=auto #56086 (arpera) and is pinned by tests/parser/test_abstract_parser.py::test_auto_with_strict_tools, which asserts that under tool_choice=auto + response_format + a structural-tag-capable parser the grammar MUST still accept a tool call. So flipping auto to schema-only contradicts a test that encodes intended behavior, not just the code.

    2. But there is an internal contradiction suggesting the composition outgrew its author's mental model: abstract_parser.py's parallel_tool_calls=False branch skips the strict-level bump because its comment claims "Auto with structured outputs keeps its format-only grammar" — which is false the moment the Or composition arms (the grammar includes the tool envelope). The code only stays consistent because limit_to_single_tool_call happens to run later regardless. So the "schema'd turn can't call tools" premise existed in the author's head while the code did the opposite.

    3. On compat anchors: pre-structural-tag vllm (0.31) indeed enforced the schema alone (the tag builder returned None for GLM, early return with response_format intact), so this is a silent behavior change for exactly the parsers that gained structural-tag support in 0.32 — your eval parity argument is real. The counterweight: OpenAI's own semantics allow a tool call under response_format + tools + auto (the schema binds the message content, the tool schemas bind the arguments), so the composition is the more OpenAI-faithful reading, and your requested behavior is the stricter one.

    Whichever way the ruling goes, the implementation is small and I have it ready: schema-only for auto + explicit response_format (your validated workaround, restoring 0.31 behavior for this combination) with the pinned test updated to match, or keeping the composition and fixing the stale single_call comment/premise. Happy to open the PR either way.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions