vllm-tool-parsers — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited vllm-tool-parsers (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
This skill points to the right source file, template, or GH issue. The source code is authoritative — read it. Do not paraphrase from this skill when the actual file is available.
Assume a local vllm-project/vllm checkout is accessible. Every reference below is relative to that repo root.
| Target | Read |
|---|---|
| All tool parsers | vllm/tool_parsers/ (one file per parser). Not vllm/entrypoints/openai/tool_parsers/ — removed at v0.14.0; imports from it fail |
Parser base class + ToolParserManager | vllm/tool_parsers/abstract_tool_parser.py |
Shared helpers (partial_json_loads, find_common_prefix, make_valid_python, partial_tag_overlap, compute_tool_delta, handle_single_tool) | vllm/tool_parsers/utils.py |
| Built-in parser registry | vllm/tool_parsers/__init__.py — _TOOL_PARSERS_TO_REGISTER maps CLI name → module → class |
| Unified parser engine (new) | vllm/parser/ — one class per model (qwen3.py, gemma4.py, deepseek_v4.py, deepseek_v32.py, seed_oss.py, …), abstract_parser.py, and engine/ (parser_engine.py, streaming_parser_engine.py, incremental_lexer.py, token_id_scanner.py) |
| Adapter construction | vllm/parser/engine/adapters.py defines make_adapters(XParser) → (XParserReasoningAdapter, XParserToolAdapter); most pairs are built in vllm/parser/engine/registered_adapters.py, a few in the model's own vllm/parser/<model>.py (ling3). The tool side is then subclassed in vllm/tool_parsers/*.py to attach structural_tag_model |
| CLI flag definitions | vllm/entrypoints/launchers/cli_args.py — grep tool_call_parser, enable_auto_tool_choice, tool_parser_plugin |
| Non-streaming serving invocation | vllm/entrypoints/openai/chat_completion/serving.py — grep parser.parse(; DelegatingParser in vllm/parser/abstract_parser.py calls the tool parser's extract_tool_calls |
| Streaming serving loop | same file — grep parse_delta, tools_streamed |
| Tail flush of unstreamed args | vllm/parser/abstract_parser.py — grep _append_unstreamed_tool_args; reads ToolParser.get_remaining_unstreamed_args() in abstract_tool_parser.py |
| Plugin import wiring | vllm/entrypoints/launchers/api_server/entry.py and vllm/entrypoints/launchers/launcher.py — grep import_tool_parser (vllm/entrypoints/openai/api_server.py is a deprecated re-export shim) |
| Responses API tool handling | vllm/entrypoints/openai/responses/serving.py + vllm/entrypoints/openai/responses/utils.py |
| Per-parser Jinja chat templates | examples/tool_chat_template_<family>.jinja |
| Per-parser tests (executable spec) | tests/tool_parsers/test_<name>_tool_parser.py + tests/tool_parsers/common_tests.py |
| User-facing docs | docs/features/tool_calling.md |
If the operator's question is "what does parser X do" — read `vllm/tool_parsers/X_tool_parser.py`.
Except for the 19 names on the unified-parser path (v0.32.0): there the tool_parsers/ file is a stub of a few lines and the logic lives in vllm/parser/<model>.py. The CLI name → real file map, with the version each name moved, is in references/parser-index.md (rows written a.py → vllm/parser/b.py).
Recent names NOT on this path — dots (DotsToolParser), hy_v4 (HYV4ToolParser), muse_glimmer (MuseGlimmerToolParser) and k2_horizon (K2HorizonToolParser). None imports a *ParserToolAdapter, so each is an ordinary standalone tool parser with no paired reasoning adapter. Also off-path at v0.32.0: qwen3_json (an alias of hermes's Hermes2ProToolParser) and hf (ResponseTemplateToolParser, see the CLI contract). Registry total: 54 names at v0.32.0 (51 at v0.30.0 and v0.31.0).
`_engine_` in the filename is not the marker. glm47_moe_tool_parser.py, kimi_k2_tool_parser.py, minimax_m2_tool_parser.py and mistral_tool_parser.py have ordinary names and are still stubs. The test is whether the file imports an adapter: grep -l "ParserToolAdapter" vllm/tool_parsers/*.py (17 files at v0.32.0, 12 at v0.30.0; ling3_tool_parser.py imports its adapter from vllm.parser.ling3, so grepping registered_adapters misses it). Exception: cohere_command_tool_parser.py (cohere_command3/cohere_command4) is a stub too but imports no adapter — it's composed by name-check in vllm/parser/parser_manager.py instead, so this grep won't find it. Its parsing lives in vllm/parser/cohere_command.py via the external cohere_melody package — not a vLLM runtime dependency: pip install cohere-melody into the image (CI pins 0.14.0), or the parser raises ImportError at load. (kimi_k3 goes through the same name-check but its tool_parsers/ file is the real implementation, not a stub.)
This is the same refactor described in vllm-reasoning-parsers — a single per-model parser now backs both the tool and reasoning adapters. Consequence: a grammar change to vllm/parser/qwen3.py moves tool and reasoning behaviour at once — they are no longer independent surfaces for those models.
Two flags, both required together for auto tool choice:
vllm serve <model> --enable-auto-tool-choice --tool-call-parser <name> [--chat-template <path>]--enable-auto-tool-choice alone → TypeError: --enable-auto-tool-choice requires --tool-call-parser (see cli_args.py).--tool-call-parser alone → legal. Parser still runs for tool_choice="required" and named, and on Responses API.mistral without [TOOL_CALLS] in the vocab) fails startup with TypeError: --tool-call-parser X cannot be used with the tokenizer of '<model>'. Before v0.32.0 the server came up, /health returned 200, and every chat request returned 500.--tool-call-parser hf (v0.32.0+) parses from the checkpoint's response_template in tokenizer_config.json; startup fails without it. Cannot be combined with another tool or reasoning parser; rejects strict tools, required/named tool_choice and parallel_tool_calls=false.--tool-strict-level auto|function|parameter (v0.31.0+, default auto) raises the structural-tag floor server-side: function constrains the call envelope (markup + name) on every request with tools; parameter also pins every tool's argument schema as if strict: true. VLLM_ENFORCE_STRICT_TOOL_CALLING=false overrides it. Read docs/features/tool_calling.md.--tool-parser-plugin file or in a rebuilt image, never by editing site-packages inside running pods: the pod drifts from its image, the edit is lost on restart or reschedule, and there is no rollback.--tool-parser-plugin <path.py> → third-party file that calls @ToolParserManager.register_module("name"). v0.30.0+: the value can also be a dotted module name importable from site-packages (e.g. an installed pip package) — import_plugin() tries importlib.import_module(value) first, falling back to file-path import only on ModuleNotFoundError (vllm/utils/import_utils.py). Same for --reasoning-parser-plugin.--reasoning-parser is independent but several tool parsers assume a </think> has closed — match them (see "Reasoning-parser pairing" below).examples/tool_chat_template_<family>.jinja. Wrong template → model never emits the sentinels the parser expects.Use this to pick the CLI name. Then read the parser file and the matching Jinja for details — the wrapping tokens, streaming strategy, and quirks live there, not here.
--tool-call-parser | Model families | Reference template | ||||||
|---|---|---|---|---|---|---|---|---|
hermes / qwen3_json | Hermes-2/3, Qwen2.5-Instruct, Qwen3-Instruct (text), QwQ (qwen3_json, v0.32.0+, is the same class) | tool_chat_template_hermes.jinja | ||||||
longcat | LongCat-Flash-Chat | (inherits hermes) | ||||||
mistral | Mistral-Instruct (all), Mistral-Large-2506+ (v≥11 format auto-detected) | tool_chat_template_mistral.jinja (also _mistral3.jinja, _mistral_parallel.jinja) | ||||||
llama3_json / llama4_json | Llama 3.1/3.2/3.3/4 (JSON flavor) | tool_chat_template_llama3.1_json.jinja, _llama3.2_json.jinja, _llama4_json.jinja | ||||||
pythonic | Llama-3.2-{1B,3B}, ToolACE-8B, Gemma-3 | tool_chat_template_llama3.2_pythonic.jinja, tool_chat_template_toolace.jinja, tool_chat_template_gemma3_pythonic.jinja | ||||||
llama4_pythonic | Llama-4 Scout/Maverick | tool_chat_template_llama4_pythonic.jinja | ||||||
olmo3 | Olmo-3-7B/32B | (HF default) | ||||||
qwen3_coder / qwen3_xml | Qwen3-Coder-480B/30B, Qwen3-XML family | tool_chat_template_qwen3coder.jinja — both names are one class (Qwen3EngineToolParser) since v0.25.1 (mimo too, through v0.31.0); before that qwen3coder_tool_parser.py and qwen3xml_tool_parser.py were separate implementations. Before v0.25.1, `qwen3_coder` streams all arguments in one final delta (#30439, closed unfixed — not a flag or template problem): upgrade to ≥ v0.25.1 (unified parser); interim, use non-streaming | ||||||
deepseek_v3 / deepseek_v31 / deepseek_v32 / deepseek_v4 | DeepSeek-V3/R1, V3.1, V3.2, V4 | tool_chat_template_deepseekv3.jinja, _deepseekv31.jinja, _deepseekr1.jinja | ||||||
deepseek_v41 | DeepSeek-V4.1-Flash (v0.30.0+; DSML, unified engine) | (HF default) | ||||||
cohere_command3 / cohere_command4 | Command-A, Command-R7B (3); Command-A-Reasoning/Vision (4) | `<\ | START_ACTION\ | >` grammar (HF default) | ||||
apertus | Apertus | tool_chat_template_apertus.jinja — vLLM's docs say use it over the HF one (fixes OpenAI-compatibility issues) | ||||||
lfm2 | LFM2 | (HF default) | ||||||
minicpm5 | MiniCPM-5 | (HF default — no tool_chat_template_minicpm5.jinja ships) | ||||||
poolside_v1 | Poolside (GLM-4-style grammar) | (HF default) | ||||||
hy_v3 | Hunyuan V3 (newer than hunyuan_a13b) | (HF default) | ||||||
glm45 / glm47 | GLM-4.5/4.6, GLM-4.7 | tool_chat_template_glm4.jinja | ||||||
ling3 | Ling 3.0 Flash (v0.29.0+; GLM-4.7 grammar: Ling3Parser subclasses Glm47MoeParser) | (HF default) | ||||||
granite / granite-20b-fc / granite4 | Granite-3.0/3.1, Granite-20B-FC, Granite-4.0 | tool_chat_template_granite.jinja, _granite_20b_fc.jinja | ||||||
phi4_mini_json | Phi-4-mini | tool_chat_template_phi4_mini.jinja | ||||||
jamba | Jamba-1.5 | (HF default, sentinel must be in vocab) | ||||||
internlm | InternLM-2.5 | tool_chat_template_internlm2_tool.jinja | ||||||
kimi_k2 | Kimi-K2 Instruct / Thinking | (HF default) | ||||||
kimi_k3 | Kimi-K3 (XTML `<\ | open\ | >tools<\ | sep\ | >` channels) (v0.27.0+) | (HF default) | ||
inkling | Inkling (v0.27.0+); typed `<\ | content_text\ | >/<\ | content_thinking\ | >/<\ | content_invoke_tool_json\ | >` blocks | (HF default) |
minimax_m2 / minimax_m3 | MiniMax-M2 / M3 | the bare `minimax` name was removed at v0.25.1 — --tool-call-parser minimax no longer resolves | ||||||
step3 / step3p5 | Step-3 VL / Step-3.5-Flash, Step-3.7-Flash | (HF default) | ||||||
mimo | MiMo-V2.6-Pro/Flash-RL (own class v0.32.0+); pair --reasoning-parser mimo | (HF default) | ||||||
plamo3 | PLaMo 3 (v0.32.0+); `<\ | plamo:begin_tool_requests:plamo\ | >` markers | (HF default) | ||||
hf | Any checkpoint shipping a response_template (v0.32.0+); pair --reasoning-parser hf or none | (checkpoint) | ||||||
dots | Dots (v0.29.0+); XML <dots_function_call> blocks | (HF default) | ||||||
k2_horizon | K2 Horizon (v0.30.0+) — `<ifm\ | tool_calls> / <ifm\ | tool_call> wrappers, JSON or XML (<ifm\ | arg_key>) bodies; supports_required_and_named = False` | (HF default) | |||
hy_v4 | Hunyuan V4 / Hy4-preview (v0.29.0+); XML <arg_key> / <arg_value> pairs | (HF default — no bundled template; the hunyuan_a13b one is a different parser) | ||||||
muse_glimmer | Muse Glimmer (v0.29.0+); `<\ | eom\ | > / <\ | eot\ | >` special tokens | tool_chat_template_muse_glimmer.jinja | ||
seed_oss | Seed-OSS | (HF default) | ||||||
hunyuan_a13b | Hunyuan-A13B | (HF default per vLLM docs; tool_chat_template_hunyuan_a13b.jinja also ships) | ||||||
ernie45 | ERNIE-4.5 thinking | (HF default) | ||||||
gemma4 / functiongemma | Gemma-4-IT / FunctionGemma-270m | tool_chat_template_gemma4.jinja, _functiongemma.jinja | ||||||
gigachat3 | GigaChat-3 | (HF default) | ||||||
xlam | Salesforce xLAM Llama & Qwen | tool_chat_template_xlam_llama.jinja, _xlam_qwen.jinja | ||||||
openai | gpt-oss-20b/120b (Harmony channels; response_format constrains the whole generation, v0.27.0+) | (no Jinja — built-in renderer) |
The table is not authoritative — verify against the deployed tree:
grep -oE '^\s+"[a-z0-9_-]+": \(' vllm/tool_parsers/__init__.py | tr -d ' ":(' # CLI names only (54 at v0.32.0)
ls examples/tool_chat_template_*.jinja # lists shipped templates
ls vllm/tool_parsers/*_tool_parser.py # lists source filesToolParser subclass implements extract_tool_calls (non-streaming, stateless) and extract_tool_calls_streaming (stateful, per-delta). See vllm/tool_parsers/abstract_tool_parser.py.current_text == previous_text + delta_textcurrent_token_ids == previous_token_ids + delta_token_idsprev_tool_call_arr: list[dict] — at stream end get_remaining_unstreamed_args() takes the last entry's "arguments", subtracts what streamed_args_for_tool already sent, and appends the rest to the final delta's last tool call — only if that final delta carries tool_calls.current_tool_id: int — starts -1, increments per call.current_tool_name_sent: bool — flip True once name flushed for current tool.streamed_args_for_tool: list[str] — cumulative args already emitted per tool index. Append on every flush: if it is not a prefix of the full args, the tail is silently dropped; if it lags, the tail re-sends text already streamed.finish_reason is tool_calls iff at least one returned DeltaMessage carried tool_calls (serving's tools_streamed[i]), the request did not name a tool, and (v0.31.0+) the engine stopped on stop — a max_tokens-truncated stream keeps length. A parser that only buffers and never emits a DeltaToolCall ends on stop, whatever prev_tool_call_arr holds.adjust_request(request) — set skip_special_tokens=False, inject grammar, etc. supports_required_and_named: bool = True — flip False if the output shape breaks guided JSON; required/named then get no JSON-schema injection and the parser must extract calls from its native format.None = "consumed, nothing to emit"; DeltaMessage(content=...) = pass-through; DeltaMessage(tool_calls=[DeltaToolCall(...)]) = tool progress.Align new parsers with the parse_delta shape (RFC #11522 and its merged follow-on PRs #39446, #39728, #38755) rather than copying older HACKs.
Several tool parsers gate on a reasoning-end sentinel. Mismatched pair = tool parser sees reasoning text as content, misses sentinels, or emits from inside <think>. Table below is a pointer — verify by reading the tool-parser file for the adjust_request/is_reasoning_end interaction.
| Tool parser | Pair with | Why | ||
|---|---|---|---|---|
hermes (Qwen3 thinking) | qwen3 | Gates on </think> | ||
deepseek_v3 (R1) | deepseek_r1 | Gates on </think> | ||
seed_oss | seed_oss | Gates on </seed:think> | ||
hunyuan_a13b | hunyuan_a13b | Excludes <think>…</think> region | ||
minimax_m2, minimax_m3 | same name | Interleaved thinking / exclusion zone | ||
kimi_k2 | kimi_k2 | Implicit end via `<\ | tool_calls_section_begin\ | >` |
ernie45 | ernie45 | Expects </think>\n\n\n<tool_call> | ||
mistral (reasoning variants) | mistral | Tokenized reasoning section |
Sibling skill: vllm-reasoning-parsers — defer there for reasoning-side questions.
Work down this list in order on any broken tool call.
--enable-auto-tool-choice --tool-call-parser <name>.vllm/tool_parsers/__init__.py registry.--tool-call-parser between flavours (llama4_json ↔ llama4_pythonic, JSON ↔ pythonic) without switching --chat-template makes the model emit a shape the parser never matches — calls arrive as plain content. A --chat-template value that cannot be opened fails startup (looks like a file path, but it failed to be opened) — unless it contains {, } or a newline, in which case vLLM uses the string itself as the template — a mistyped path containing { silently becomes a literal template. Check the path is readable inside the container before rollout.<think> / </seed:think> / harmony channels, --reasoning-parser must match. Check vllm/reasoning/ for the registry.extract_tool_calls_streaming returns None unconditionally or raises NotImplementedError, streaming isn't supported (e.g. phi4_mini_json, openai).DeltaMessage with tool_calls during the stream (or the request named a tool, where stop is correct). Compare with "stream": false on the same prompt: tool calls there but not in the stream = a streaming-path bug in that parser./v1/completions (no tool parser) with the chat-rendered prompt — tokenizer.apply_chat_template(messages, tools=tools, tokenize=False, add_generation_prompt=True) with the deployed template. Keep "skip_special_tokens": false: the default true strips special-token sentinels (<|tool_call|>, [TOOL_CALLS], …) from text, which looks exactly like "model never emitted them". Dump raw text and non-ASCII codepoints: curl -sS $VLLM/v1/completions -H 'content-type: application/json' \
-d '{"model":"...","prompt":"...","max_tokens":200,"skip_special_tokens":false}' \
| python3 -c "import sys,json,unicodedata; t=json.load(sys.stdin)['choices'][0]['text']; \
print(repr(t)); [print(hex(ord(ch)), unicodedata.name(ch,'?'), repr(ch)) for ch in t[:2000] if ord(ch)>0x7E]"Read the result with the table below.
examples/tool_chat_template_<family>.jinja. Character-level. Full-width vs ASCII Unicode gotchas bite here.Done when steps 1–7 each have a pass/fail answer backed by evidence (serve args, registry name, template bytes, raw /v1/completions text), and every failing step has a fix named:
| Step 7 raw text shows | Cause | Fix | |
|---|---|---|---|
| no sentinels, or a different shape | template / parser-flavour mismatch, or the model was never trained on that shape | switch --chat-template or --tool-call-parser so the pair matches | |
| the exact sentinels the parser greps for | parser bug | search for an issue/PR (step 8) → upgrade; else ship a --tool-parser-plugin | |
| lookalike sentinels (`\ | where |` is expected) | template bytes differ from the trained ones | step 9 diff, restore the reference bytes |
# Open bugs affecting a specific parser
gh search issues --repo vllm-project/vllm "<parser-name> tool" --state open \
--json title,url,state,updatedAt --limit 20
# Recently merged fix PRs
gh search prs --repo vllm-project/vllm "<parser-name>" --state merged \
--json title,url,mergedAt --limit 20
# Streaming-specific
gh search issues --repo vllm-project/vllm "tool parser streaming <parser-name>" \
--json title,url,state,updatedAt --limit 20
# Broad sweep if parser name unknown
gh search issues --repo vllm-project/vllm "extract_tool_calls_streaming" --limit 30On finding a referenced issue/PR, read it directly (gh issue view N --repo vllm-project/vllm --comments). Never paraphrase from a cached memory — the fix may have landed since.
grep -rn "HACK" vllm/tool_parsers/
grep -rn "TODO" vllm/tool_parsers/
grep -rn "prev_tool_call_arr = \[{\"arguments\": {}}\]" vllm/tool_parsers/The prev_tool_call_arr = [{"arguments": {}}] plant is the old "force finish_reason=tool_calls" workaround; finish_reason no longer reads that field, and where it once forced tool_calls it produced a call with empty arguments — hiding the failed parse instead of fixing it. Do not copy it into a new parser. At v0.32.0 exactly five parsers carry it: pythonic, llama4_pythonic, olmo3, lfm2, minicpm5xml — the pythonic family plus the two that reuse its flush shape. (mistral does not; it populates prev_tool_call_arr properly.) Write a new parser to the parse_delta shape (RFC #11522) instead.
Copy the existing parser closest in format.
# Match the target shape to an existing parser
grep -lE "<your_sentinel>" vllm/tool_parsers/ # sometimes the sentinel itself is in the code
ls vllm/tool_parsers/ # scan names by formatFor the skeleton + detailed checklist see references/custom-parser-plugin.md. Key points:
@ToolParserManager.register_module(["name"]) (decorator path is lazy — plugin loader imports the file).--tool-parser-plugin /abs/path/file.py --tool-call-parser name.vllm/tool_parsers/pythonic_tool_parser.py (kwargs Python syntax), hermes_tool_parser.py (JSON-in-tags), or dots_tool_parser.py (standalone streaming XML <invoke>/<parameter>; step3p5_tool_parser.py is an engine stub at v0.32.0+).vllm/tool_parsers/abstract_tool_parser.py for exact signatures.tests/tool_parsers/common_tests.py — reviewers expect this harness.Before upstreaming, read AGENTS.md at the repo root for the duplicate-work + PR-description policy, and vllm/tool_parsers/AGENTS.md (v0.32.0+): new in-tree models go in vllm/parser/ as a unified Parser plus a thin adapter; ToolParser is kept as legacy for plugins.
| Load | When |
|---|---|
references/parser-index.md | before reading a parser's source — per-name file path (including the → vllm/parser/ real file), wrapping tokens, and the one non-obvious fact (full-width | sentinels, non-streaming only, schema-aware coercion) |
references/streaming-pitfalls.md | a streaming-only failure (playbook steps 5–6): full-width pipes, special-token stripping, empty-args stalls, O(n²) pythonic re-parse, truncated markup |
references/custom-parser-plugin.md | writing or reviewing a --tool-parser-plugin: read order, v0.32.0 import skeleton, ship checklist |
references/sources.md | before citing an issue/PR state or a version gate; re-probe a row whose Freshened: date is >90 days old |
Last verified: 2026-10-18 against vLLM v0.32.0. See references/sources.md for per-reference probe details and timestamps.
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.