diff --git a/tests/renderers/test_hf.py b/tests/renderers/test_hf.py index bcdd03c797..736478fbfa 100644 --- a/tests/renderers/test_hf.py +++ b/tests/renderers/test_hf.py @@ -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") diff --git a/vllm/renderers/hf.py b/vllm/renderers/hf.py index 2ceb66ffef..268a9aa463 100644 --- a/vllm/renderers/hf.py +++ b/vllm/renderers/hf.py @@ -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: