[Bugfix] Detect OpenAI content format when message.content is passed through macro parameters (#53824)

Signed-off-by: wuhangxian <1391938827@qq.com>
Signed-off-by: wuhangxian <wuhangxian@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: wuhangxian <wuhangxian@users.noreply.github.com>
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
This commit is contained in:
wuhangxian
2026-09-09 20:10:28 +00:00
committed by GitHub
co-authored by Claude Opus 4.8 wuhangxian mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
parent cce50657b7
commit 56d001faf0
2 changed files with 118 additions and 3 deletions
+63
View File
@@ -11,6 +11,7 @@ from vllm.exceptions import VLLMValidationError
from vllm.renderers.hf import (
_consolidate_system_messages,
_convert_developer_to_system,
_detect_content_format,
_detect_developer_role_support,
_get_hf_base_chat_template_params,
_template_error_reason,
@@ -361,6 +362,66 @@ def test_resolve_chat_template_kwargs_with_template_name():
assert "unknown_param" not in resolved
@pytest.mark.parametrize(
("chat_template", "expected_format"),
[
(
"""
{% macro render_message(role, message_content) %}
{% for item in message_content %}{{ item['type'] }}{% endfor %}
{% endmacro %}
{% for message in messages %}
{{ render_message(message['role'], message['content']) }}
{% endfor %}
""",
"openai",
),
(
"""
{% macro render_message(role, message_content) %}
{% for item in message_content %}{{ item['type'] }}{% endfor %}
{% endmacro %}
{% for message in messages %}
{{ render_message(
role=message['role'],
message_content=message['content'],
) }}
{% endfor %}
""",
"openai",
),
(
"""
{% for message in messages %}
{% for item in message['content'] %}{{ item['type'] }}{% endfor %}
{% endfor %}
""",
"openai",
),
(
"""
{% macro render_message(content) %}
{% for item in content %}{{ item['type'] }}{% endfor %}
{% endmacro %}
{% for message in messages %}
{{ render_message(content=['text only']) }}
{% endfor %}
""",
"string",
),
(
"""
{% for message in messages %}{{ message['content'] }}{% endfor %}
""",
"string",
),
],
)
def test_detect_content_format(chat_template, expected_format):
"""Detect content format when content is passed through a macro."""
assert _detect_content_format(chat_template, default="string") == expected_format
# NOTE: Qwen2-Audio default chat template is specially defined inside
# processor class instead of using `tokenizer_config.json`
@pytest.mark.parametrize(
@@ -373,9 +434,11 @@ def test_resolve_chat_template_kwargs_with_template_name():
("fixie-ai/ultravox-v0_5-llama-3_2-1b", "string"),
("Qwen/Qwen2-Audio-7B-Instruct", "openai"),
("meta-llama/Llama-Guard-3-1B", "openai"),
("XiaomiMiMo/MiMo-V2.5-Pro", "openai"),
],
)
def test_resolve_content_format_hf_defined(model, expected_format):
"""Detect the chat template content format for built-in HF models."""
model_info = HF_EXAMPLE_MODELS.find_hf_info(model)
model_info.check_available_online(on_fail="skip")
+55 -3
View File
@@ -397,10 +397,46 @@ def _iter_nodes_assign_messages_item(root: jinja2.nodes.Node):
def _iter_nodes_assign_content_item(root: jinja2.nodes.Node):
"""Yield loops that iterate over message content or macro-bound content."""
message_varnames = [
varname for _, varname in _iter_nodes_assign_messages_item(root)
]
# Track macro parameters that receive message.content as an argument.
# Some templates pass message.content through a macro parameter whose
# name is not literally "content".
macro_content_params_by_loop: dict[int, set[str]] = {}
loops_in_macros: set[int] = set()
for macro_node in root.find_all(jinja2.nodes.Macro):
macro_param_names = {arg.name for arg in macro_node.args}
macro_content_params: set[str] = set()
for call_node in root.find_all(jinja2.nodes.Call):
if (
isinstance(call_node.node, jinja2.nodes.Name)
and call_node.node.name == macro_node.name
):
for i, arg in enumerate(call_node.args):
if i < len(macro_node.args) and any(
_is_var_or_elems_access(arg, varname, "content")
for varname in message_varnames
):
macro_content_params.add(macro_node.args[i].name)
for kwarg in call_node.kwargs:
if (
isinstance(kwarg, jinja2.nodes.Keyword)
and kwarg.key in macro_param_names
and any(
_is_var_or_elems_access(kwarg.value, varname, "content")
for varname in message_varnames
)
):
macro_content_params.add(kwarg.key)
for loop_ast in macro_node.find_all(jinja2.nodes.For):
loops_in_macros.add(id(loop_ast))
if macro_content_params:
macro_content_params_by_loop[id(loop_ast)] = macro_content_params
# Search for {%- for content in message['content'] -%} loops
# or {%- for item in content -%} loops
for loop_ast in root.find_all(jinja2.nodes.For):
@@ -412,10 +448,26 @@ def _iter_nodes_assign_content_item(root: jinja2.nodes.Node):
assert isinstance(loop_target, jinja2.nodes.Name)
yield loop_ast, loop_target.name
break
else:
macro_content_params_for_loop = macro_content_params_by_loop.get(
id(loop_ast)
)
if (
isinstance(loop_iter, jinja2.nodes.Name)
and macro_content_params_for_loop is not None
and loop_iter.name in macro_content_params_for_loop
):
assert isinstance(loop_target, jinja2.nodes.Name)
yield loop_ast, loop_target.name
continue
if isinstance(loop_iter, jinja2.nodes.Name) and loop_iter.name == "content":
assert isinstance(loop_target, jinja2.nodes.Name)
yield loop_ast, loop_target.name
if (
id(loop_ast) not in loops_in_macros
and isinstance(loop_iter, jinja2.nodes.Name)
and loop_iter.name == "content"
):
assert isinstance(loop_target, jinja2.nodes.Name)
yield loop_ast, loop_target.name
def _try_extract_ast(chat_template: str) -> jinja2.nodes.Template | None: