A high-throughput and memory-efficient inference and serving engine for LLMs
Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git
Synced Nov 15, 09:45 UTC
[Frontend][Bug Fix] Update llama4 pythonic jinja template and llama4_pythonic parser (#17917)
Signed-off-by: Kai Wu <kaiwu@meta.com>
Kai Wu committed
May 22, 2025 at 16:44 UTC
c91fe7b1b9c4398c6d4c980fc480ada0da8a0b23
6 files changed
+541
-72
docs/source/features/tool_calling.md
+5
-6
@@ -158,13 +158,13 @@ All Llama 3.1, 3.2 and 4 models should be supported.
158
* `meta-llama/Llama-3.2-*`
159
* `meta-llama/Llama-4-*`
160
161
-The tool calling that is supported is the [JSON based tool calling](https://llama.meta.com/docs/model-cards-and-prompt-formats/llama3_1/#json-based-tool-calling). For [pythonic tool calling](https://github.com/meta-llama/llama-models/blob/main/models/llama3_2/text_prompt_format.md#zero-shot-function-calling) introduced by the Llama-3.2 models, see the `pythonic` tool parser below.
161
+The tool calling that is supported is the [JSON based tool calling](https://llama.meta.com/docs/model-cards-and-prompt-formats/llama3_1/#json-based-tool-calling). For [pythonic tool calling](https://github.com/meta-llama/llama-models/blob/main/models/llama3_2/text_prompt_format.md#zero-shot-function-calling) introduced by the Llama-3.2 models, see the `pythonic` tool parser below. As for llama 4 models, it is recommended to use the `llama4_pythonic` tool parser.
162
163
Other tool calling formats like the built in python tool calling or custom tool calling are not supported.
164
165
Known issues:
166
167
-1. Parallel tool calls are not supported.
167
+1. Parallel tool calls are not supported for llama 3, but it is supported in llama 4 models.
168
2. The model can generate parameters with a wrong format, such as generating
169
an array serialized as string instead of an array.
170
@@ -177,11 +177,10 @@ images.
177
178
Recommended flags: `--tool-call-parser llama3_json --chat-template {see_above}`
179
180
-VLLM also provides a JSON based chat template for Llama 4:
181
-* <gh-file:examples/tool_chat_template_llama4_json.jinja> - this is based on the "official" chat template for the Llama 4
182
-models, but tweaked so that it works better with vLLM.
180
+VLLM also provides a pythonic and JSON based chat template for Llama 4, but pythonic tool calling is recommended:
181
+* <gh-file:examples/tool_chat_template_llama4_pythonic.jinja> - this is based on the [official chat template](https://www.llama.com/docs/model-cards-and-prompt-formats/llama4/) for the Llama 4 models.
182
184
-For Llama 4 use `--tool-call-parser llama4_json examples/tool_chat_template_llama4_json.jinja`.
183
+For Llama 4 model, use `--tool-call-parser llama4_pythonic --chat-template examples/tool_chat_template_llama4_pythonic.jinja`.
184
185
#### IBM Granite
186
examples/tool_chat_template_llama4_pythonic.jinja
+36
-64
@@ -1,16 +1,17 @@
1
{{- bos_token }}
2
-{%- if custom_tools is defined %}
2
+{%- if custom_tools is defined and custom_tools%}
3
{%- set tools = custom_tools %}
4
{%- endif %}
5
-{%- if not tools_in_user_message is defined %}
6
- {%- set tools_in_user_message = false %}
7
-{%- endif %}
8
-{%- if not tools is defined %}
5
+{%- if tools is defined and tools %}
6
+ {%- set tool_definition = tool_definition ~ (tools | tojson(indent=4)) %}
7
+{%- else %}
8
{%- set tools = none %}
9
{%- endif %}
10
11
+
12
{#- This block extracts the system message, so we can slot it into the right place. #}
13
{%- if messages[0]['role'] == 'system' %}
14
+ {%- set user_provided_system_message = true %}
15
{%- if messages[0]['content'] is string %}
16
{%- set system_message = messages[0]['content']|trim %}
17
{%- else %}
@@ -18,68 +19,33 @@
19
{%- endif %}
20
{%- set messages = messages[1:] %}
21
{%- else %}
21
- {%- if tools is not none %}
22
- {#- Add default tool system message when tools are provided #}
23
- {%- set system_message = "You are a helpful assistant with tool calling "
24
- "capabilities. Only reply with a tool call if the function exists in the "
25
- "library provided by the user. If it doesn't exist, just reply directly in "
26
- "natural language. When you receive a tool call response, use the output to "
27
- "format an answer to the original user question." %}
22
+ {%- if tools is not none %}
23
+ {#- Since not system_message was provided by user, if tool is provided, system_message is now default tool system message #}
24
+ {#- This system message is from llama website:https://www.llama.com/docs/model-cards-and-prompt-formats/llama4/ #}
25
+ {%- set system_message = "You are a helpful assistant and an expert in function composition. You can answer general questions using your internal knowledge OR invoke functions when necessary. Follow these strict guidelines:\n\n1. FUNCTION CALLS:\n- ONLY use functions that are EXPLICITLY listed in the function list below\n- If NO functions are listed (empty function list []), respond ONLY with internal knowledge or \"I don't have access to [Unavailable service] information\"\n- If a function is not in the list, respond ONLY with internal knowledge or \"I don't have access to [Unavailable service] information\"\n- If ALL required parameters are present AND the query EXACTLY matches a listed function's purpose: output ONLY the function call(s)\n- Use exact format: [func_name1(param1=value1, param2=value2), func_name2(...)]\nExamples:\nCORRECT: [get_weather(location=\"Vancouver\"), calculate_route(start=\"Boston\", end=\"New York\")] <- Only if get_weather and calculate_route are in function list\nINCORRECT: get_weather(location=\"New York\")\nINCORRECT: Let me check the weather: [get_weather(location=\"New York\")]\nINCORRECT: [get_events(location=\"Singapore\")] <- If function not in list\n\n2. RESPONSE RULES:\n- For pure function requests matching a listed function: ONLY output the function call(s)\n- For knowledge questions: ONLY output text\n- For missing parameters: ONLY request the specific missing parameters\n- For unavailable services (not in function list): output ONLY with internal knowledge or \"I don't have access to [Unavailable service] information\". Do NOT execute a function call.\n- If the query asks for information beyond what a listed function provides: output ONLY with internal knowledge about your limitations\n- NEVER combine text and function calls in the same response\n- NEVER suggest alternative functions when the requested service is unavailable\n- NEVER create or invent new functions not listed below\n\n3. STRICT BOUNDARIES:\n- ONLY use functions from the list below - no exceptions\n- NEVER use a function as an alternative to unavailable information\n- NEVER call functions not present in the function list\n- NEVER add explanatory text to function calls\n- NEVER respond with empty brackets\n- Use proper Python/JSON syntax for function calls\n- Check the function list carefully before responding\n\n4. TOOL RESPONSE HANDLING:\n- When receiving tool responses: provide concise, natural language responses\n- Don't repeat tool response verbatim\n- Don't add supplementary information\n\nHere is a list of functions in JSON format that you can invoke:\n" %}
26
{%- else %}
27
{%- set system_message = "" %}
28
{%- endif %}
29
{%- endif %}
32
-
33
-{#- System message if the user supplied one, or if tools are used (default tool system message) #}
30
+{#- Now writing the system message: use the user provided system message if user_provided_system_message, else default tool system message if tools presented #}
31
{%- if system_message %}
32
{#- always use user provided system message to override default tool system message #}
33
{{- "<|header_start|>system<|header_end|>\n\n" }}
34
{{- system_message }}
38
- {%- if tools is not none and not tools_in_user_message %}
39
- {{- "Tools: You have access to the following tools. You might need to use one "
40
- "or more function/tool calls to fulfill the task. \n"
41
- "If none are needed, then proceed to the response.\n\n"
42
- "Tool Call Syntax: You can call tools using the following syntax:\n"
43
- "[func_name1(params_name1=params_value1, params_name2=params_value2, ...), ...]\n"
44
- "Do not include anything else when calling the tools with the syntax above.\n\n"
45
- "Here is a list of functions in JSON format that you can invoke.\n " }}
46
- {%- for t in tools %}
47
- {{- t | tojson(indent=4) }}
48
- {{- "\n\n" }}
49
- {%- endfor %}
35
+ {%- if user_provided_system_message and tools %}
36
+ {{- "\nHere is a list of functions in JSON format that you can invoke. Use exact format: [func_name1(param1=value1, param2=value2), func_name2(...)]\n" }}
37
+ {{- tool_definition -}}
38
+ {%- elif tool_definition %}
39
+ {{- tool_definition -}}
40
{%- endif %}
41
{{- "<|eot|>" }}
42
{%- endif %}
43
54
-{#- Custom tools are passed in a user message with some extra guidance #}
55
-{%- if tools_in_user_message and tools is not none %}
56
- {#- Extract the first user message so we can plug it in here #}
57
- {%- if messages | length != 0 %}
58
- {%- if messages[0]['content'] is string %}
59
- {%- set first_user_message = messages[0]['content']|trim %}
60
- {%- else %}
61
- {%- set first_user_message = messages[0]['content'] | selectattr('type', 'equalto', 'text') | map(attribute='text') | map('trim') | join('\n') %}
62
- {%- endif %}
63
- {%- set messages = messages[1:] %}
64
- {%- else %}
65
- {{- raise_exception("Cannot put tools in the first user message when there's no first user message!") }}
66
- {%- endif %}
67
- {{- '<|header_start|>user<|header_end|>\n\n' -}}
68
- {{- first_user_message}}
69
- {{- "\nHere is a list of functions in JSON format that you can invoke:"}}
70
- {%- for t in tools %}
71
- {{- t | tojson(indent=4) }}
72
- {{- "\n\n" }}
73
- {%- endfor %}
74
- {{- "Should you decide to return the function call(s), put them in the format "
75
- "of [func_name1(params_name1=params_value1, params_name2=params_value2, "
76
- "...), ...]\nDo not include anything else when calling the tools with the "
77
- "syntax above." }}
78
-{%- endif %}
79
-
44
+{#- Now deal with all other messages #}
45
{%- for message in messages %}
81
- {%- if not (message.role == 'ipython' or message.role == 'tool' or 'tool_calls' in message) %}
82
- {{- '<|header_start|>' + message['role'] + '<|header_end|>\n\n' }}
46
+ {#- Base case: messages that are not from tool role and has empty tool_call list #}
47
+ {%- if not (message.role == 'ipython' or message.role == 'tool' or ('tool_calls' in message and message.tool_calls|length != 0 )) %}
48
+ {{- '<|header_start|>' + message['role'] + '<|header_end|>\n\n' }}
49
{%- if message['content'] is string %}
50
{{- message['content'] }}
51
{%- else %}
@@ -91,10 +57,12 @@
57
{%- endif %}
58
{%- endfor %}
59
{%- endif %}
94
- {{- "<|eot|>" }}
95
- {%- elif 'tool_calls' in message and message.tool_calls|length > 0 %}
96
- {%- set tool_call = message.tool_calls[0].function %}
97
- {{- '<|header_start|>assistant<|header_end|>\n\n' -}}
60
+ {{- "<|eot|>" }}
61
+ {#- Tool case: messages has non-empty tool_call list, must from assistant #}
62
+ {%- elif 'tool_calls' in message %}
63
+ {#- assume tool_calls are always coming from assistant #}
64
+ {%- if message.role == 'assistant' %}
65
+ {{- '<|header_start|>assistant<|header_end|>\n\n' -}}
66
{%- if message['content'] is string %}
67
{{- message['content'] }}
68
{%- else %}
@@ -106,32 +74,36 @@
74
{%- endif %}
75
{%- endfor %}
76
{%- endif %}
77
+ {{- "[" }}
78
{%- for tool_call in message.tool_calls %}
79
{%- if tool_call.function is defined %}
80
{%- set tool_call = tool_call.function %}
81
{%- endif %}
113
- {{- tool_call.name + '(' -}}
82
+ {{- tool_call.name + '(' -}}
83
{%- for param in tool_call.arguments %}
115
- {{- param + '=' -}}
84
+ {{- param + '="' -}}
85
{{- "%s" | format(tool_call.arguments[param]) -}}
86
+ {{- '"' -}}
87
{% if not loop.last %}, {% endif %}
88
{%- endfor %}
89
{{- ')' -}}
90
{% if not loop.last %}, {% endif %}
91
{%- endfor %}
122
- {{- "<|eom|>" }}
92
+ {{- "]<|eot|>" }}
93
+{%- endif %}
94
+{#- Tool_response case: messages are from tool_response #}
95
{%- elif message.role == "tool" or message.role == "ipython" %}
96
{{- "<|header_start|>ipython<|header_end|>\n\n" }}
97
{%- if message.content is string %}
126
- {{- message.content | tojson }}
98
+ {{- message.content | tojson }}
99
{%- else %}
100
{%- for content in message['content'] %}
101
{%- if content['type'] == 'text' %}
130
- {{- content['text'] | tojson }}
102
+ {{- content['text'] | tojson }}
103
{%- endif %}
104
{%- endfor %}
105
{%- endif %}
134
- {{- "<|eom|>" }}
106
+ {{- "<|eot|>" }}
107
{%- endif %}
108
{%- endfor %}
109
{%- if add_generation_prompt %}
tests/entrypoints/openai/tool_parsers/test_llama4_pythonic_tool_parser.py
new
+193
@@ -0,0 +1,193 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+
3
+from unittest.mock import MagicMock
4
+
5
+import pytest
6
+
7
+from tests.entrypoints.openai.tool_parsers.utils import (
8
+ run_tool_extraction, run_tool_extraction_streaming)
9
+from vllm.entrypoints.openai.protocol import FunctionCall
10
+from vllm.entrypoints.openai.tool_parsers import ToolParser, ToolParserManager
11
+
12
+# Test cases similar to pythonic parser but with Llama4 specific format
13
+SIMPLE_FUNCTION_OUTPUT = "[get_weather(city='LA', metric='C')]"
14
+SIMPLE_FUNCTION_CALL = FunctionCall(
15
+ name="get_weather",
16
+ arguments='{"city": "LA", "metric": "C"}',
17
+)
18
+MORE_TYPES_FUNCTION_OUTPUT = ("[register_user(name='Doe', "
19
+ "age=9, "
20
+ "address={'city': 'LA', 'state': 'CA'}, "
21
+ "role=None, "
22
+ "passed_test=True, "
23
+ "aliases=['John', 'Johnny'])]")
24
+MORE_TYPES_FUNCTION_CALL = FunctionCall(
25
+ name="register_user",
26
+ arguments='{"name": "Doe", '
27
+ '"age": 9, '
28
+ '"address": {"city": "LA", "state": "CA"}, '
29
+ '"role": null, '
30
+ '"passed_test": true, '
31
+ '"aliases": ["John", "Johnny"]}',
32
+)
33
+PARAMETERLESS_FUNCTION_OUTPUT = "[get_weather()]"
34
+PARAMETERLESS_FUNCTION_CALL = FunctionCall(
35
+ name="get_weather",
36
+ arguments='{}',
37
+)
38
+EMPTY_DICT_FUNCTION_OUTPUT = "[do_something_cool(additional_data={})]"
39
+EMPTY_DICT_FUNCTION_CALL = FunctionCall(
40
+ name="do_something_cool",
41
+ arguments='{"additional_data": {}}',
42
+)
43
+EMPTY_LIST_FUNCTION_OUTPUT = "[do_something_cool(steps=[])]"
44
+EMPTY_LIST_FUNCTION_CALL = FunctionCall(
45
+ name="do_something_cool",
46
+ arguments='{"steps": []}',
47
+)
48
+ESCAPED_STRING_FUNCTION_OUTPUT = (
49
+ r"[get_weather(city='Martha\'s Vineyard', metric='\"cool units\"')]")
50
+ESCAPED_STRING_FUNCTION_CALL = FunctionCall(
51
+ name="get_weather",
52
+ arguments='{"city": "Martha\'s Vineyard", "metric": "\\"cool units\\""}',
53
+)
54
+PYTHON_TAG_FUNCTION_OUTPUT = (
55
+ "<|python_start|>[get_weather(city='LA', metric='C')]<|python_end|>")
56
+
57
+
58
+@pytest.mark.parametrize("streaming", [True, False])
59
+def test_no_tool_call(streaming: bool):
60
+ mock_tokenizer = MagicMock()
61
+ tool_parser: ToolParser = ToolParserManager.get_tool_parser(
62
+ "llama4_pythonic")(mock_tokenizer)
63
+ model_output = "How can I help you today?"
64
+
65
+ content, tool_calls = run_tool_extraction(tool_parser,
66
+ model_output,
67
+ streaming=streaming)
68
+
69
+ assert content == model_output
70
+ assert len(tool_calls) == 0
71
+
72
+
73
+test_str = "<|python_start|>"
74
+test_str += "[get_weather(city='LA', metric='C'),"
75
+test_str += "register_user(name='Doe', age=9)]"
76
+TEST_CASES = [
77
+ pytest.param(True,
78
+ ESCAPED_STRING_FUNCTION_OUTPUT,
79
+ [ESCAPED_STRING_FUNCTION_CALL],
80
+ id="simple_streaming"),
81
+ pytest.param(False,
82
+ SIMPLE_FUNCTION_OUTPUT, [SIMPLE_FUNCTION_CALL],
83
+ id="simple_nonstreaming"),
84
+ pytest.param(True,
85
+ MORE_TYPES_FUNCTION_OUTPUT, [MORE_TYPES_FUNCTION_CALL],
86
+ id="more_types_streaming"),
87
+ pytest.param(False,
88
+ MORE_TYPES_FUNCTION_OUTPUT, [MORE_TYPES_FUNCTION_CALL],
89
+ id="more_types_nonstreaming"),
90
+ pytest.param(True,
91
+ PARAMETERLESS_FUNCTION_OUTPUT, [PARAMETERLESS_FUNCTION_CALL],
92
+ id="parameterless_streaming"),
93
+ pytest.param(False,
94
+ PARAMETERLESS_FUNCTION_OUTPUT, [PARAMETERLESS_FUNCTION_CALL],
95
+ id="parameterless_nonstreaming"),
96
+ pytest.param(True,
97
+ EMPTY_DICT_FUNCTION_OUTPUT, [EMPTY_DICT_FUNCTION_CALL],
98
+ id="empty_dict_streaming"),
99
+ pytest.param(False,
100
+ EMPTY_DICT_FUNCTION_OUTPUT, [EMPTY_DICT_FUNCTION_CALL],
101
+ id="empty_dict_nonstreaming"),
102
+ pytest.param(True,
103
+ EMPTY_LIST_FUNCTION_OUTPUT, [EMPTY_LIST_FUNCTION_CALL],
104
+ id="empty_list_streaming"),
105
+ pytest.param(False,
106
+ EMPTY_LIST_FUNCTION_OUTPUT, [EMPTY_LIST_FUNCTION_CALL],
107
+ id="empty_list_nonstreaming"),
108
+ pytest.param(True,
109
+ ESCAPED_STRING_FUNCTION_OUTPUT,
110
+ [ESCAPED_STRING_FUNCTION_CALL],
111
+ id="escaped_string_streaming"),
112
+ pytest.param(False,
113
+ ESCAPED_STRING_FUNCTION_OUTPUT,
114
+ [ESCAPED_STRING_FUNCTION_CALL],
115
+ id="escaped_string_nonstreaming"),
116
+ pytest.param(
117
+ True,
118
+ "[get_weather(city='LA',metric='C'),register_user(name='Doe',age=9)]",
119
+ [
120
+ SIMPLE_FUNCTION_CALL,
121
+ FunctionCall(name="register_user",
122
+ arguments='{"name": "Doe", "age": 9}')
123
+ ],
124
+ id="parallel_calls_streaming"),
125
+ pytest.param(
126
+ False,
127
+ "[get_weather(city='LA',metric='C'),register_user(name='Doe',age=9)]",
128
+ [
129
+ SIMPLE_FUNCTION_CALL,
130
+ FunctionCall(name="register_user",
131
+ arguments='{"name": "Doe", "age": 9}')
132
+ ],
133
+ id="parallel_calls_nonstreaming"),
134
+ pytest.param(True,
135
+ PYTHON_TAG_FUNCTION_OUTPUT, [SIMPLE_FUNCTION_CALL],
136
+ id="python_tag_streaming"),
137
+ pytest.param(False,
138
+ PYTHON_TAG_FUNCTION_OUTPUT, [SIMPLE_FUNCTION_CALL],
139
+ id="python_tag_nonstreaming"),
140
+ pytest.param(True,
141
+ test_str, [
142
+ SIMPLE_FUNCTION_CALL,
143
+ FunctionCall(name="register_user",
144
+ arguments='{"name": "Doe", "age": 9}')
145
+ ],
146
+ id="parallel_calls_streaming"),
147
+ pytest.param(False,
148
+ "<|python_start|>[get_weather(city='LA', metric='C'), " +
149
+ "register_user(name='Doe', age=9)]", [
150
+ SIMPLE_FUNCTION_CALL,
151
+ FunctionCall(name="register_user",
152
+ arguments='{"name": "Doe", "age": 9}')
153
+ ],
154
+ id="parallel_calls_nonstreaming"),
155
+]
156
+
157
+
158
+@pytest.mark.parametrize("streaming, model_output, expected_tool_calls",
159
+ TEST_CASES)
160
+def test_tool_call(streaming: bool, model_output: str,
161
+ expected_tool_calls: list[FunctionCall]):
162
+ mock_tokenizer = MagicMock()
163
+ tool_parser: ToolParser = ToolParserManager.get_tool_parser(
164
+ "llama4_pythonic")(mock_tokenizer)
165
+
166
+ content, tool_calls = run_tool_extraction(tool_parser,
167
+ model_output,
168
+ streaming=streaming)
169
+
170
+ assert len(tool_calls) == len(expected_tool_calls)
171
+ for actual, expected in zip(tool_calls, expected_tool_calls):
172
+ assert actual.type == "function"
173
+ assert actual.function == expected
174
+
175
+
176
+def test_streaming_tool_call_with_large_steps():
177
+ mock_tokenizer = MagicMock()
178
+ tool_parser: ToolParser = ToolParserManager.get_tool_parser(
179
+ "llama4_pythonic")(mock_tokenizer)
180
+ model_output_deltas = [
181
+ "<|python_start|>[get_weather(city='LA', metric='C'), "
182
+ "get_weather(), "
183
+ "do_something_cool(steps=[])]<|python_end|>",
184
+ ]
185
+
186
+ reconstructor = run_tool_extraction_streaming(
187
+ tool_parser, model_output_deltas, assert_one_tool_per_delta=False)
188
+
189
+ assert reconstructor.other_content == ""
190
+ assert len(reconstructor.tool_calls) == 3
191
+ assert reconstructor.tool_calls[0].function == SIMPLE_FUNCTION_CALL
192
+ assert reconstructor.tool_calls[1].function == PARAMETERLESS_FUNCTION_CALL
193
+ assert reconstructor.tool_calls[2].function == EMPTY_LIST_FUNCTION_CALL
tests/tool_use/utils.py
+1
-1
@@ -88,7 +88,7 @@ CONFIGS: dict[str, ServerConfig] = {
88
"meta-llama/Llama-4-Scout-17B-16E-Instruct",
89
"arguments": [
90
"--enforce-eager", "--no-enable-prefix-caching",
91
- "--tool-call-parser", "pythonic", "--chat-template",
91
+ "--tool-call-parser", "llama4_pythonic", "--chat-template",
92
str(VLLM_PATH /
93
"examples/tool_chat_template_llama4_pythonic.jinja"), "-tp",
94
"4"
vllm/entrypoints/openai/tool_parsers/__init__.py
+3
-1
@@ -7,6 +7,7 @@ from .granite_tool_parser import GraniteToolParser
7
from .hermes_tool_parser import Hermes2ProToolParser
8
from .internlm2_tool_parser import Internlm2ToolParser
9
from .jamba_tool_parser import JambaToolParser
10
+from .llama4_pythonic_tool_parser import Llama4PythonicToolParser
11
from .llama_tool_parser import Llama3JsonToolParser
12
from .mistral_tool_parser import MistralToolParser
13
from .phi4mini_tool_parser import Phi4MiniJsonToolParser
@@ -16,5 +17,6 @@ __all__ = [
17
"ToolParser", "ToolParserManager", "Granite20bFCToolParser",
18
"GraniteToolParser", "Hermes2ProToolParser", "MistralToolParser",
19
"Internlm2ToolParser", "Llama3JsonToolParser", "JambaToolParser",
19
- "PythonicToolParser", "Phi4MiniJsonToolParser", "DeepSeekV3ToolParser"
20
+ "Llama4PythonicToolParser", "PythonicToolParser", "Phi4MiniJsonToolParser",
21
+ "DeepSeekV3ToolParser"
22
]
vllm/entrypoints/openai/tool_parsers/llama4_pythonic_tool_parser.py
new
+303
@@ -0,0 +1,303 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+
3
+import ast
4
+import json
5
+import re
6
+from collections.abc import Sequence
7
+from typing import Any, Union
8
+
9
+from transformers import PreTrainedTokenizerBase
10
+
11
+from vllm.entrypoints.openai.protocol import (ChatCompletionRequest,
12
+ DeltaFunctionCall, DeltaMessage,
13
+ DeltaToolCall,
14
+ ExtractedToolCallInformation,
15
+ FunctionCall, ToolCall)
16
+from vllm.entrypoints.openai.tool_parsers.abstract_tool_parser import (
17
+ ToolParser, ToolParserManager)
18
+from vllm.logger import init_logger
19
+
20
+logger = init_logger(__name__)
21
+
22
+
23
+class _UnexpectedAstError(Exception):
24
+ pass
25
+
26
+
27
+@ToolParserManager.register_module("llama4_pythonic")
28
+class Llama4PythonicToolParser(ToolParser):
29
+ """
30
+ Toolcall parser for Llama4 that produce tool calls in a pythonic style
31
+ Use --enable-auto-tool-choice --tool-call-parser llama4_pythonic
32
+ """
33
+ # TODO(mdepinet): Possible future improvements:
34
+ # 1. Support text + tools separated by either <|python_tag|> or \n\n
35
+ # 2. Support tools outside of a list (or separated by a semicolon).
36
+ # This depends on item 1 for consistent streaming.
37
+ # Neither of these are necessary for e.g. ToolACE, but both would help make
38
+ # Llama3.2 models more reliable.
39
+
40
+ TOOL_CALL_REGEX = re.compile(
41
+ r"\[([a-zA-Z]+\w*\(([a-zA-Z]+\w*=.*,\s*)*([a-zA-Z]+\w*=.*\s)?\),\s*)*([a-zA-Z]+\w*\(([a-zA-Z]+\w*=.*,\s*)*([a-zA-Z]+\w*=.*\s*)?\)\s*)+\]",
42
+ re.DOTALL)
43
+
44
+ def __init__(self, tokenizer: PreTrainedTokenizerBase):
45
+ super().__init__(tokenizer)
46
+
47
+ # Rename for readability. This is NOT a tool id.
48
+ @property
49
+ def current_tool_index(self) -> int:
50
+ return self.current_tool_id
51
+
52
+ @current_tool_index.setter
53
+ def current_tool_index(self, value: int) -> None:
54
+ self.current_tool_id = value
55
+
56
+ def extract_tool_calls(
57
+ self, model_output: str,
58
+ request: ChatCompletionRequest) -> ExtractedToolCallInformation:
59
+ """
60
+ Extract the tool calls from a complete model response.
61
+ """
62
+
63
+ # remove <|python_start|> and <|python_end|>
64
+ # as Llama 4 model sometime will output those tokens
65
+ if model_output.startswith("<|python_start|>"):
66
+ model_output = model_output[len("<|python_start|>"):]
67
+ model_output = model_output.replace("<|python_end|>", "")
68
+ if not (self.TOOL_CALL_REGEX.match(model_output)):
69
+ return ExtractedToolCallInformation(tools_called=False,
70
+ tool_calls=[],
71
+ content=model_output)
72
+
73
+ try:
74
+ module = ast.parse(model_output)
75
+ parsed = getattr(module.body[0], "value", None)
76
+ if isinstance(parsed, ast.List) and all(
77
+ isinstance(e, ast.Call) for e in parsed.elts):
78
+ return ExtractedToolCallInformation(
79
+ tools_called=True,
80
+ tool_calls=[
81
+ _handle_single_tool(e) # type: ignore
82
+ for e in parsed.elts
83
+ ],
84
+ content=None)
85
+ else:
86
+ raise _UnexpectedAstError(
87
+ "Tool output must be a list of function calls")
88
+ except Exception:
89
+ logger.exception("Error in extracting tool call from response.")
90
+ # Treat as regular text
91
+ return ExtractedToolCallInformation(tools_called=False,
92
+ tool_calls=[],
93
+ content=model_output)
94
+
95
+ def extract_tool_calls_streaming(
96
+ self,
97
+ previous_text: str,
98
+ current_text: str,
99
+ delta_text: str,
100
+ previous_token_ids: Sequence[int],
101
+ current_token_ids: Sequence[int],
102
+ delta_token_ids: Sequence[int],
103
+ request: ChatCompletionRequest,
104
+ ) -> Union[DeltaMessage, None]:
105
+
106
+ if not current_text.startswith("[") and not current_text.startswith(
107
+ "<|python_start|>"):
108
+ return DeltaMessage(content=delta_text)
109
+
110
+ try:
111
+ # remove <|python_start|> and <|python_end|>
112
+ if current_text.startswith("<|python_start|>"):
113
+ current_text = current_text[len("<|python_start|>"):]
114
+ if current_text.endswith("<|python_end|>"):
115
+ current_text = current_text[:current_text.
116
+ rfind("<|python_end|>")]
117
+ valid_and_added_text = _make_valid_python(current_text)
118
+ if valid_and_added_text is None:
119
+ return None
120
+ valid_text, added_text = valid_and_added_text
121
+
122
+ module = ast.parse(valid_text)
123
+ parsed = getattr(module.body[0], "value", None)
124
+ if not isinstance(parsed, ast.List) or not all(
125
+ isinstance(e, ast.Call) for e in parsed.elts):
126
+ raise _UnexpectedAstError(
127
+ "Tool output must be a list of function calls")
128
+ tool_calls = [
129
+ _handle_single_tool(e) # type: ignore
130
+ for e in parsed.elts
131
+ ]
132
+
133
+ tool_deltas = []
134
+ for index, new_call in enumerate(tool_calls):
135
+ if index < self.current_tool_index:
136
+ continue
137
+
138
+ self.current_tool_index = index
139
+ if len(self.streamed_args_for_tool) == index:
140
+ self.streamed_args_for_tool.append("")
141
+
142
+ new_call_complete = index < len(
143
+ tool_calls) - 1 or ")]" not in added_text
144
+ if new_call_complete:
145
+ self.current_tool_index += 1
146
+
147
+ withheld_suffix = (added_text[:-2]
148
+ if not new_call_complete else "")
149
+ if not new_call_complete and added_text[-2] == ")":
150
+ # Function call is incomplete. Withhold the closing bracket.
151
+ withheld_suffix = withheld_suffix + "}"
152
+ # Strings get single quotes in the model-produced string.
153
+ # JSON requires double quotes.
154
+ withheld_suffix = withheld_suffix.replace("'", '"')
155
+ delta = _compute_tool_delta(self.streamed_args_for_tool[index],
156
+ new_call, index, withheld_suffix)
157
+
158
+ if delta is not None:
159
+ tool_deltas.append(delta)
160
+ if (delta.function is not None
161
+ and delta.function.arguments is not None):
162
+ self.streamed_args_for_tool[
163
+ index] += delta.function.arguments
164
+
165
+ # HACK: serving_chat.py inspects the internal state of tool parsers
166
+ # when determining it's final streaming delta, automatically
167
+ # adding autocompleted JSON.
168
+ # These two lines avoid that nonsense while ensuring finish_reason
169
+ # is set to tool_calls when at least one tool is called.
170
+ if tool_deltas and not self.prev_tool_call_arr:
171
+ self.prev_tool_call_arr = [{"arguments": {}}]
172
+
173
+ if tool_deltas:
174
+ return DeltaMessage(tool_calls=tool_deltas)
175
+ elif not added_text and self.current_tool_id > 0:
176
+ # Return an empty DeltaMessage once the tool calls are all done
177
+ # so that finish_reason gets set.
178
+ return DeltaMessage(content='')
179
+ else:
180
+ return None
181
+ except Exception:
182
+ logger.exception("Error trying to handle streaming tool call.")
183
+ logger.debug(
184
+ "Skipping chunk as a result of tool streaming extraction "
185
+ "error")
186
+ return None
187
+
188
+
189
+def _get_parameter_value(val: ast.expr) -> Any:
190
+ if isinstance(val, ast.Constant):
191
+ return val.value
192
+ elif isinstance(val, ast.Dict):
193
+ if not all(isinstance(k, ast.Constant) for k in val.keys):
194
+ raise _UnexpectedAstError(
195
+ "Dict tool call arguments must have literal keys")
196
+ return {
197
+ k.value: _get_parameter_value(v) # type: ignore
198
+ for k, v in zip(val.keys, val.values)
199
+ }
200
+ elif isinstance(val, ast.List):
201
+ return [_get_parameter_value(v) for v in val.elts]
202
+ else:
203
+ raise _UnexpectedAstError("Tool call arguments must be literals")
204
+
205
+
206
+def _handle_single_tool(call: ast.Call) -> ToolCall:
207
+ if not isinstance(call.func, ast.Name):
208
+ raise _UnexpectedAstError("Invalid tool call name")
209
+ function_name = call.func.id
210
+ arguments = {}
211
+ for keyword in call.keywords:
212
+ arguments[keyword.arg] = _get_parameter_value(keyword.value)
213
+ return ToolCall(type="function",
214
+ function=FunctionCall(name=function_name,
215
+ arguments=json.dumps(arguments)))
216
+
217
+
218
+def _make_valid_python(text: str) -> Union[tuple[str, str], None]:
219
+ bracket_stack = []
220
+ for index, char in enumerate(text):
221
+ if char in {"[", "(", "{"}:
222
+ bracket_stack.append(char)
223
+ elif char == "]":
224
+ if not bracket_stack or bracket_stack.pop() != "[":
225
+ raise _UnexpectedAstError("Mismatched square brackets")
226
+ elif char == ")":
227
+ if not bracket_stack or bracket_stack.pop() != "(":
228
+ raise _UnexpectedAstError("Mismatched parentheses")
229
+ elif char == "}":
230
+ if not bracket_stack or bracket_stack.pop() != "{":
231
+ raise _UnexpectedAstError("Mismatched curly braces")
232
+ elif char in {"'", '"'}:
233
+ if bracket_stack and bracket_stack[-1] == char:
234
+ if index > 0 and text[index - 1] == "\\":
235
+ # Treat an escaped quote as a regular character
236
+ pass
237
+ else:
238
+ bracket_stack.pop()
239
+ elif bracket_stack and bracket_stack[-1] in {"'", '"'}:
240
+ # Double quote within a single quote string or vice versa.
241
+ pass
242
+ else:
243
+ bracket_stack.append(char)
244
+
245
+ text = text.rstrip()
246
+ if text.endswith("=") or text.endswith(":"):
247
+ # Since we have no type information for this property/parameter value,
248
+ # we can't fill in a valid value.
249
+ return None
250
+ if bracket_stack and bracket_stack[-1] == "{":
251
+ trailing_dict_text = text[:text.rfind("{")]
252
+ num_keys = trailing_dict_text.count(":")
253
+ num_values = trailing_dict_text.count(",")
254
+ if num_keys <= num_values:
255
+ return None # Incomplete property name within parameter value
256
+ if bracket_stack and bracket_stack[-1] == "(":
257
+ trailing_params_text = text[:text.rfind("(")]
258
+ num_full_param_names = trailing_params_text.count("=")
259
+ num_full_param_values = trailing_params_text.count(",")
260
+ if num_full_param_names <= num_full_param_values:
261
+ return None # Incomplete parameter name
262
+ if text.endswith(","):
263
+ text = text[:-1]
264
+ if bracket_stack and bracket_stack[-1] == "[" and not text.endswith(
265
+ "[") and not text.endswith(")"):
266
+ return None # Incomplete function name
267
+
268
+ added_text = ""
269
+ for char in reversed(bracket_stack):
270
+ if char == "[":
271
+ added_text += "]"
272
+ elif char == "(":
273
+ added_text += ")"
274
+ elif char == "{":
275
+ added_text += "}"
276
+ elif char == "'":
277
+ added_text += "'"
278
+ elif char == '"':
279
+ added_text += '"'
280
+
281
+ return text + added_text, added_text
282
+
283
+
284
+def _compute_tool_delta(previously_sent_args: str, new_call: ToolCall,
285
+ index: int,
286
+ withheld_suffix: str) -> Union[DeltaToolCall, None]:
287
+ new_call_args = new_call.function.arguments
288
+ if withheld_suffix:
289
+ assert new_call_args.endswith(withheld_suffix)
290
+ new_call_args = new_call_args[:-len(withheld_suffix)]
291
+ if not previously_sent_args:
292
+ return DeltaToolCall(id=new_call.id,
293
+ type="function",
294
+ index=index,
295
+ function=DeltaFunctionCall(
296
+ name=new_call.function.name,
297
+ arguments=new_call_args,
298
+ ))
299
+
300
+ arg_diff = new_call_args[len(previously_sent_args):]
301
+ return DeltaToolCall(
302
+ id=None, index=index, function=DeltaFunctionCall(
303
+ arguments=arg_diff)) if arg_diff else None