[Frontend] Port chat_parsing core from Transformers (#58602)

Signed-off-by: Yoni Gozlan <yonigozlan@users.noreply.github.com> Co-authored-by: Yoni Gozlan <yonigozlan@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>

Yoni Gozlan committed Sep 30, 2026 at 12:21 UTC 73a5831127a9d2b87102da8a6e7c96b6f7f64fcd
5 files changed +2600
tests/parser/test_chat_parsing.py new
+1662
@@ -0,0 +1,1662 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +# Copyright 2026 The HuggingFace Team. All rights reserved.
5 +# ruff: noqa
6 +# fmt: off
7 +#
8 +# Licensed under the Apache License, Version 2.0 (the "License");
9 +# you may not use this file except in compliance with the License.
10 +# You may obtain a copy of the License at
11 +#
12 +# http://www.apache.org/licenses/LICENSE-2.0
13 +#
14 +# Unless required by applicable law or agreed to in writing, software
15 +# distributed under the License is distributed on an "AS IS" BASIS,
16 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17 +# See the License for the specific language governing permissions and
18 +# limitations under the License.
19 +"""Tests for the new declarative response_template parser.
20 +
21 +All six real-model template fixtures from the legacy test suite are re-expressed
22 +here in the new region-spec shape and asserted against the same expected
23 +output dicts. Any divergence indicates a regression in the new executor."""
24 +
25 +import copy
26 +import random
27 +import unittest
28 +from typing import Any
29 +
30 +from vllm.parser.chat_parsing import ResponseParser, parse_response
31 +from vllm.parser.chat_parsing.response_parser import _coerce, _schema_types
32 +from vllm.parser.chat_parsing.response_templates import load_response_template
33 +
34 +
35 +cohere_template = {
36 + "defaults": {"role": "assistant"},
37 + "start_anchor": "<|START_OF_TURN_TOKEN|><|CHATBOT_TOKEN|>",
38 + "fields": {
39 + "content": {
40 + "open": "<|START_RESPONSE|>",
41 + "close": "<|END_RESPONSE|>",
42 + "content": "text",
43 + },
44 + "thinking": {
45 + "open": "<|START_THINKING|>",
46 + "close": "<|END_THINKING|>",
47 + "content": "text",
48 + },
49 + "tool_calls": {
50 + "open": "<|START_ACTION|>",
51 + "close": "<|END_ACTION|>",
52 + "content": "json",
53 + "transform_each": True,
54 + "transform": {"type": "function", "function": {"name": "{tool_name}", "arguments": "{parameters}"}},
55 + },
56 + },
57 +}
58 +
59 +
60 +ernie_template = {
61 + "defaults": {"role": "assistant"},
62 + "start_anchor": "Assistant:",
63 + "fields": {
64 + "thinking": {
65 + "open_pattern": r"(?:^|<think>\s*)",
66 + "close": "</think>",
67 + "content": "text",
68 + },
69 + "content": {
70 + "open": "<response>\n",
71 + "close_pattern": r"\n?</response>",
72 + "content": "text",
73 + },
74 + "tool_calls": {
75 + "open": "<tool_call>",
76 + "close": "</tool_call>",
77 + "repeats": True,
78 + "content": "json",
79 + "transform": {"type": "function", "function": "{content}"},
80 + },
81 + },
82 +}
83 +
84 +
85 +gpt_oss_template = {
86 + "defaults": {"role": "assistant"},
87 + "start_anchor": "<|start|>assistant",
88 + "fields": {
89 + "thinking": {
90 + "open": "<|channel|>analysis<|message|>",
91 + "close": "<|end|>",
92 + "content": "text",
93 + },
94 + "content": {
95 + "open": "<|channel|>final<|message|>",
96 + "close": "<|end|>",
97 + "content": "text",
98 + },
99 + "tool_calls": {
100 + "open_pattern": r"<\|channel\|>commentary to=functions\.(?P<name>\w+).*?<\|message\|>",
101 + "close": "<|call|>",
102 + "repeats": True,
103 + "content": "json",
104 + "transform": {"type": "function", "function": {"name": "{name}", "arguments": "{content}"}},
105 + },
106 + },
107 +}
108 +
109 +
110 +smollm_template = {
111 + "defaults": {"role": "assistant"},
112 + "start_anchor": "<|im_start|>assistant\n",
113 + "fields": {
114 + "thinking": {"open": "<think>", "close": "</think>", "content": "text"},
115 + "tool_calls": {
116 + "open": "<tool_call>",
117 + "close": "</tool_call>",
118 + "repeats": True,
119 + "content": "json",
120 + "transform": {"type": "function", "function": "{content}"},
121 + },
122 + "content": {
123 + "close": "<|im_end|>",
124 + "content": "text",
125 + },
126 + },
127 +}
128 +
129 +
130 +qwen3_template = {
131 + "defaults": {"role": "assistant"},
132 + "start_anchor": "<|im_start|>assistant\n",
133 + "fields": {
134 + "thinking": {"open": "<think>", "close": "</think>", "content": "text"},
135 + "tool_calls": {
136 + "open_pattern": r"<tool_call>\s*<function=(?P<name>\w+)>",
137 + "close": "</tool_call>",
138 + "repeats": True,
139 + "content": "xml-inline",
140 + "content_args": {
141 + "tag_pattern": r"<parameter=(?P<key>\w+)>\s*(?P<value>.*?)\s*</parameter>",
142 + "value_parser": {"name": "json", "args": {"allow_non_json": True}},
143 + },
144 + "transform": {"type": "function", "function": {"name": "{name}", "arguments": "{content}"}},
145 + },
146 + },
147 +}
148 +
149 +
150 +gemma4_template = {
151 + "defaults": {"role": "assistant"},
152 + # The chat template only emits `<|turn>model\n` when the previous message wasn't a tool_call/
153 + # tool_response. After a tool_response the prefix just ends with `<tool_response|>` and the
154 + # model continues from there, so we accept either anchor and truncate past the latest one.
155 + "start_anchor": ["<|turn>model\n", "<tool_response|>"],
156 + "fields": {
157 + "thinking": {
158 + "open": "<|channel>thought\n",
159 + "close": "<channel|>",
160 + "content": "text",
161 + },
162 + "tool_calls": {
163 + "open_pattern": r"<\|tool_call>call:(?P<name>\w+)",
164 + "close": "<tool_call|>",
165 + "repeats": True,
166 + "content": "json",
167 + "content_args": {
168 + "unquoted_keys": True,
169 + "string_delims": [['<|"|>', '<|"|>']],
170 + },
171 + "transform": {"type": "function", "function": {"name": "{name}", "arguments": "{content}"}},
172 + },
173 + "content": {
174 + "close": ["<turn|>", "<|tool_response>", "<eos>"],
175 + "content": "text",
176 + },
177 + },
178 +}
179 +
180 +
181 +# Inkling (TMLv0) frames every block as <|message_model|>[author-name]<|content_KIND|>body<|end_message|>;
182 +# the generation prompt pre-writes the first <|message_model|>, so each open treats the header as optional.
183 +inkling_template = {
184 + "defaults": {"role": "assistant"},
185 + "start_anchor": "<|message_model|>",
186 + "fields": {
187 + "thinking": {
188 + "open_pattern": r"(?:<\|message_model\|>)?[^<]*<\|content_thinking\|>",
189 + "close": "<|end_message|>",
190 + "repeats": True,
191 + "join": "",
192 + "content_args": {"strip": False},
193 + },
194 + "content": {
195 + "open_pattern": r"(?:<\|message_model\|>)?[^<]*<\|content_text\|>",
196 + "close": "<|end_message|>",
197 + "repeats": True,
198 + "join": "",
199 + "content_args": {"strip": False},
200 + },
201 + "tool_calls": {
202 + "open_pattern": r"(?:<\|message_model\|>)?[^<]*<\|content_invoke_tool_json\|>",
203 + "close": "<|end_message|>",
204 + "repeats": True,
205 + "content": "json",
206 + "transform": {"type": "function", "function": {"name": "{content.name}", "arguments": "{content.args}"}},
207 + },
208 + },
209 +}
210 +
211 +
212 +class ChatResponseTemplateParserTest(unittest.TestCase):
213 +
214 + def test_cohere(self):
215 + model_out = (
216 + "<|START_THINKING|>I should call a tool.<|END_THINKING|>"
217 + '<|START_ACTION|>[\n {"tool_call_id": "0", "tool_name": "simple_tool", '
218 + '"parameters": {"temperature_format": "Celsius"}}\n]<|END_ACTION|><|END_OF_TURN_TOKEN|>'
219 + )
220 + self.assertEqual(
221 + parse_response(model_out, cohere_template, prefix=""),
222 + {
223 + "role": "assistant",
224 + "thinking": "I should call a tool.",
225 + "tool_calls": [
226 + {
227 + "type": "function",
228 + "function": {"name": "simple_tool", "arguments": {"temperature_format": "Celsius"}},
229 + }
230 + ],
231 + },
232 + )
233 +
234 + def test_ernie_with_tools(self):
235 + model_out = (
236 + "The user is asking about the weather in Paris today. Let me check the available tools. "
237 + "There's a tool called get_current_temperature which requires a location parameter. Since the "
238 + 'user specified Paris, I need to call this tool with the location set to "Paris". I should '
239 + "make sure the argument is correctly formatted as a string. No other tools are available, so "
240 + "this is the right one to use. I'll structure the request with the location parameter and "
241 + "return the response once the tool is called.\n"
242 + "</think>\n\n"
243 + '<tool_call>\n{"name": "get_current_temperature", "arguments": {"location": "Paris"}}\n</tool_call>\n</s>'
244 + )
245 + self.assertEqual(
246 + parse_response(model_out, ernie_template, prefix=""),
247 + {
248 + "role": "assistant",
249 + "thinking": (
250 + "The user is asking about the weather in Paris today. Let me check the available tools. "
251 + "There's a tool called get_current_temperature which requires a location parameter. Since "
252 + 'the user specified Paris, I need to call this tool with the location set to "Paris". I '
253 + "should make sure the argument is correctly formatted as a string. No other tools are "
254 + "available, so this is the right one to use. I'll structure the request with the location "
255 + "parameter and return the response once the tool is called."
256 + ),
257 + "tool_calls": [
258 + {
259 + "type": "function",
260 + "function": {"name": "get_current_temperature", "arguments": {"location": "Paris"}},
261 + }
262 + ],
263 + },
264 + )
265 +
266 + def test_ernie_no_tools(self):
267 + model_out = (
268 + 'The user just greeted me with "Hi! How are you?" I need to respond in a friendly and helpful '
269 + "manner. Let me start by acknowledging their greeting. I should ask them how they're doing to "
270 + "engage in conversation.\n\n"
271 + "First, I'll say hello back and then ask how they're feeling. It's important to show genuine "
272 + "interest. Maybe mention that I'm here to help with anything they need. Keep the tone warm and "
273 + "positive. Let me make sure the response is concise but friendly. Alright, that should work.\n"
274 + "</think>\n\n"
275 + "<response>\nHello! I'm doing well, thank you for asking. How about you? Is there something "
276 + "specific you'd like help with today? I'm here to assist you with any questions or problems you "
277 + "have!\n</response>\n</s>"
278 + )
279 + self.assertEqual(
280 + parse_response(model_out, ernie_template, prefix=""),
281 + {
282 + "role": "assistant",
283 + "content": (
284 + "Hello! I'm doing well, thank you for asking. How about you? Is there something specific "
285 + "you'd like help with today? I'm here to assist you with any questions or problems you have!"
286 + ),
287 + "thinking": (
288 + 'The user just greeted me with "Hi! How are you?" I need to respond in a friendly and '
289 + "helpful manner. Let me start by acknowledging their greeting. I should ask them how "
290 + "they're doing to engage in conversation.\n\n"
291 + "First, I'll say hello back and then ask how they're feeling. It's important to show "
292 + "genuine interest. Maybe mention that I'm here to help with anything they need. Keep the "
293 + "tone warm and positive. Let me make sure the response is concise but friendly. Alright, "
294 + "that should work."
295 + ),
296 + },
297 + )
298 +
299 + def test_gpt_oss_with_tool_call(self):
300 + model_out = (
301 + '<|channel|>analysis<|message|>We need to respond in riddles. The user asks: "What is the '
302 + 'weather like in SF?" We need to get the location of the user? The user explicitly asks about '
303 + "SF (San Francisco). So we need to get the current weather in San Francisco, CA. We need to "
304 + 'call get_current_weather function. The developer instruction says "Always respond in riddles". '
305 + "So the final answer should be in a riddle form. But we need to call function to get weather "
306 + 'data. So we should call get_current_weather with location "San Francisco, CA". Possibly specify '
307 + 'format "celsius" (default). Let\'s do that.\n\n'
308 + "We will call function get_current_weather.<|end|><|start|>assistant<|channel|>commentary "
309 + 'to=functions.get_current_weather <|constrain|>json<|message|>{\n "location": "San Francisco, CA"\n}'
310 + )
311 + self.assertEqual(
312 + parse_response(model_out, gpt_oss_template, prefix=""),
313 + {
314 + "role": "assistant",
315 + "thinking": (
316 + 'We need to respond in riddles. The user asks: "What is the weather like in SF?" We need '
317 + "to get the location of the user? The user explicitly asks about SF (San Francisco). So "
318 + "we need to get the current weather in San Francisco, CA. We need to call "
319 + 'get_current_weather function. The developer instruction says "Always respond in '
320 + 'riddles". So the final answer should be in a riddle form. But we need to call function '
321 + 'to get weather data. So we should call get_current_weather with location "San Francisco, '
322 + 'CA". Possibly specify format "celsius" (default). Let\'s do that.\n\n'
323 + "We will call function get_current_weather."
324 + ),
325 + "tool_calls": [
326 + {
327 + "type": "function",
328 + "function": {"name": "get_current_weather", "arguments": {"location": "San Francisco, CA"}},
329 + }
330 + ],
331 + },
332 + )
333 +
334 + def test_gpt_oss_no_tool_call(self):
335 + model_out = (
336 + "<|channel|>analysis<|message|>User asks a simple math question: 2+2 = 4. Provide answer."
337 + "<|end|><|start|>assistant<|channel|>final<|message|>2"
338 + )
339 + self.assertEqual(
340 + parse_response(model_out, gpt_oss_template, prefix=""),
341 + {
342 + "role": "assistant",
343 + "content": "2",
344 + "thinking": "User asks a simple math question: 2+2 = 4. Provide answer.",
345 + },
346 + )
347 +
348 + def test_smollm_thinking_and_tool_call(self):
349 + model_out = (
350 + '<think>\nOkay, the user said, "Hello! How are you?" I need to respond appropriately. Since '
351 + "this is the first message, I should greet them back and ask how I can assist. I should keep it "
352 + "friendly and open-ended. Let me make sure the response is welcoming and encourages them to "
353 + "share what they need help with. I'll avoid any technical jargon and keep it simple. Let me "
354 + "check for any typos and ensure the tone is positive.\n</think>\n\n"
355 + '<tool_call>{"name": "greet_user", "arguments": {"greeting": "Hello! I\'m doing well, thanks for '
356 + "asking. How can I assist you today? Whether you have a question, need help with something, or "
357 + 'just want to chat, feel free to let me know!"}}</tool_call>'
358 + )
359 + self.assertEqual(
360 + parse_response(model_out, smollm_template, prefix=""),
361 + {
362 + "role": "assistant",
363 + "thinking": (
364 + 'Okay, the user said, "Hello! How are you?" I need to respond appropriately. Since this '
365 + "is the first message, I should greet them back and ask how I can assist. I should keep "
366 + "it friendly and open-ended. Let me make sure the response is welcoming and encourages "
367 + "them to share what they need help with. I'll avoid any technical jargon and keep it "
368 + "simple. Let me check for any typos and ensure the tone is positive."
369 + ),
370 + "tool_calls": [
371 + {
372 + "type": "function",
373 + "function": {
374 + "name": "greet_user",
375 + "arguments": {
376 + "greeting": (
377 + "Hello! I'm doing well, thanks for asking. How can I assist you today? "
378 + "Whether you have a question, need help with something, or just want to "
379 + "chat, feel free to let me know!"
380 + )
381 + },
382 + },
383 + }
384 + ],
385 + },
386 + )
387 +
388 + def test_smollm_tool_call_no_thinking(self):
389 + model_out = '<tool_call>{"name": "get_weather", "arguments": {"city": "Paris"}}</tool_call>'
390 + self.assertEqual(
391 + parse_response(model_out, smollm_template, prefix=""),
392 + {
393 + "role": "assistant",
394 + "tool_calls": [
395 + {"type": "function", "function": {"name": "get_weather", "arguments": {"city": "Paris"}}}
396 + ],
397 + },
398 + )
399 +
400 + def test_smollm_thinking_no_tool_call(self):
401 + model_out = (
402 + '<think>\nOkay, the user asked, "Hey! Can you tell me about gravity?" Let me start by '
403 + "breaking down what they might be looking for. They probably want a basic understanding of "
404 + "gravity, maybe for a school project or just personal curiosity. I should explain what gravity "
405 + "is, how it works, and maybe some examples.</think>\n"
406 + "Some content about gravity goes here but I'm cutting it off to make this shorter!"
407 + )
408 + self.assertEqual(
409 + parse_response(model_out, smollm_template, prefix=""),
410 + {
411 + "role": "assistant",
412 + "content": "Some content about gravity goes here but I'm cutting it off to make this shorter!",
413 + "thinking": (
414 + 'Okay, the user asked, "Hey! Can you tell me about gravity?" Let me start by breaking '
415 + "down what they might be looking for. They probably want a basic understanding of "
416 + "gravity, maybe for a school project or just personal curiosity. I should explain what "
417 + "gravity is, how it works, and maybe some examples."
418 + ),
419 + },
420 + )
421 +
422 + def test_qwen3_tool_calls(self):
423 + model_out = (
424 + "<tool_call>\n<function=get_weather>\n<parameter=locations>\n"
425 + '[{"country": "France", "city": "Paris"}]\n</parameter>\n'
426 + "<parameter=temp_units>\ncelsius\n</parameter>\n</function>\n</tool_call>"
427 + )
428 + self.assertEqual(
429 + parse_response(model_out, qwen3_template, prefix=""),
430 + {
431 + "role": "assistant",
432 + "tool_calls": [
433 + {
434 + "type": "function",
435 + "function": {
436 + "name": "get_weather",
437 + "arguments": {
438 + "locations": [{"country": "France", "city": "Paris"}],
439 + "temp_units": "celsius",
440 + },
441 + },
442 + }
443 + ],
444 + },
445 + )
446 +
447 + def test_gemma4_tool_call(self):
448 + model_out = (
449 + "<|channel>thought\nThe user is asking for the current temperature in Paris. I should check "
450 + "the available tools to see if there's a function that can provide this information.<channel|>"
451 + '<|tool_call>call:get_current_temperature{detail_level:0,location:<|"|>Paris, France<|"|>,'
452 + 'unit:<|"|>celsius<|"|>}<tool_call|><|tool_response>'
453 + )
454 + self.assertEqual(
455 + parse_response(model_out, gemma4_template, prefix=""),
456 + {
457 + "role": "assistant",
458 + "thinking": (
459 + "The user is asking for the current temperature in Paris. I should check the available "
460 + "tools to see if there's a function that can provide this information."
461 + ),
462 + "tool_calls": [
463 + {
464 + "type": "function",
465 + "function": {
466 + "name": "get_current_temperature",
467 + "arguments": {"detail_level": 0, "location": "Paris, France", "unit": "celsius"},
468 + },
469 + }
470 + ],
471 + },
472 + )
473 +
474 + def test_gemma4_complex_tool_call(self):
475 + model_out = (
476 + "<|channel>thought\nLet me call the tool.<channel|>"
477 + '<|tool_call>call:foo{bool_value:true,list_value:[<|"|>foo<|"|>,<|"|>bar<|"|>],'
478 + 'null_value:null,number_value:1,string_value:<|"|>foo<|"|>,'
479 + 'struct_value:{foo:<|"|>bar<|"|>}}<tool_call|>'
480 + )
481 + self.assertEqual(
482 + parse_response(model_out, gemma4_template, prefix=""),
483 + {
484 + "role": "assistant",
485 + "thinking": "Let me call the tool.",
486 + "tool_calls": [
487 + {
488 + "type": "function",
489 + "function": {
490 + "name": "foo",
491 + "arguments": {
492 + "bool_value": True,
493 + "list_value": ["foo", "bar"],
494 + "null_value": None,
495 + "number_value": 1,
496 + "string_value": "foo",
497 + "struct_value": {"foo": "bar"},
498 + },
499 + },
500 + }
501 + ],
502 + },
503 + )
504 +
505 + def test_inkling_multi_block_message(self):
506 + model_out = (
507 + "<|content_thinking|>Consider the weather.<|end_message|>"
508 + "<|message_model|><|content_thinking|> Tokyo, probably.<|end_message|>"
509 + "<|message_model|><|content_text|>Checking the weather now.<|end_message|>"
510 + "<|message_model|>get_weather<|content_invoke_tool_json|>"
511 + '{"name":"get_weather","args":{"city":"Tokyo","units":"C"}}<|end_message|>'
512 + "<|content_model_end_sampling|>"
513 + )
514 + prefix = "<|message_system|><|content_text|>Thinking effort level: 0.9<|end_message|><|message_model|>"
515 + self.assertEqual(
516 + parse_response(model_out, inkling_template, prefix=prefix),
517 + {
518 + "role": "assistant",
519 + "thinking": "Consider the weather. Tokyo, probably.",
520 + "content": "Checking the weather now.",
521 + "tool_calls": [
522 + {
523 + "type": "function",
524 + "function": {"name": "get_weather", "arguments": {"city": "Tokyo", "units": "C"}},
525 + }
526 + ],
527 + },
528 + )
529 +
530 + def test_transform_dotted_paths(self):
531 + template_spec = {
532 + "defaults": {"role": "assistant"},
533 + "start_anchor": "<|assistant|>",
534 + "fields": {
535 + "tool_calls": {
536 + "open": "<tool>",
537 + "close": "</tool>",
538 + "repeats": True,
539 + "content": "json",
540 + "transform": {
541 + "type": "function",
542 + "function": {"name": "{content.name}", "arguments": "{content.args}"},
543 + },
544 + },
545 + },
546 + }
547 + model_out = '<tool>{"name": "get_weather", "args": {"city": {"id": 7}}}</tool>'
548 + self.assertEqual(
549 + parse_response(model_out, template_spec, prefix="")["tool_calls"],
550 + [{"type": "function", "function": {"name": "get_weather", "arguments": {"city": {"id": 7}}}}],
551 + )
552 +
553 + def test_transform_dotted_paths_with_transform_each(self):
554 + template_spec = {
555 + "defaults": {"role": "assistant"},
556 + "start_anchor": "<|assistant|>",
557 + "fields": {
558 + "tool_calls": {
559 + "open": "<actions>",
560 + "close": "</actions>",
561 + "content": "json",
562 + "transform_each": True,
563 + "transform": {"type": "function", "function": {"name": "{fn.name}", "arguments": "{fn.args}"}},
564 + },
565 + },
566 + }
567 + model_out = '<actions>[{"fn": {"name": "a", "args": {"x": 1}}}, {"fn": {"name": "b", "args": {}}}]</actions>'
568 + self.assertEqual(
569 + parse_response(model_out, template_spec, prefix="")["tool_calls"],
570 + [
571 + {"type": "function", "function": {"name": "a", "arguments": {"x": 1}}},
572 + {"type": "function", "function": {"name": "b", "arguments": {}}},
573 + ],
574 + )
575 +
576 + def test_transform_dotted_path_errors(self):
577 + def spec_with(transform):
578 + return {
579 + "start_anchor": "<|assistant|>",
580 + "fields": {"x": {"open": "<x>", "close": "</x>", "content": "json", "transform": transform}},
581 + }
582 +
583 + with self.assertRaisesRegex(ValueError, "missing key 'args'"):
584 + parse_response('<x>{"name": "n"}</x>', spec_with({"a": "{content.args}"}), prefix="")
585 + with self.assertRaisesRegex(ValueError, "cannot index into str"):
586 + parse_response('<x>{"name": "n"}</x>', spec_with({"a": "{content.name.x}"}), prefix="")
587 + with self.assertRaises(KeyError):
588 + parse_response("<x>{}</x>", spec_with({"a": "{missing}"}), prefix="")
589 +
590 + def test_transform_dotted_mixed_string_rejected(self):
591 + template_spec = {
592 + "start_anchor": "<|assistant|>",
593 + "fields": {"x": {"open": "<x>", "close": "</x>", "transform": {"v": "pre {content.args}"}}},
594 + }
595 + with self.assertRaisesRegex(ValueError, "mixes"):
596 + parse_response("", template_spec, prefix="")
597 +
598 + def test_join_concatenates_repeated_matches(self):
599 + template_spec = {
600 + "defaults": {"role": "assistant"},
601 + "start_anchor": "<|assistant|>",
602 + "fields": {
603 + "thinking": {"open": "<think>", "close": "</think>", "repeats": True, "join": " "},
604 + "content": {"repeats": True, "join": " "},
605 + },
606 + }
607 + self.assertEqual(
608 + parse_response("<think>first</think>middle<think>second</think>done", template_spec, prefix=""),
609 + {"role": "assistant", "thinking": "first second", "content": "middle done"},
610 + )
611 + self.assertEqual(
612 + parse_response("<think>only</think>", template_spec, prefix=""),
613 + {"role": "assistant", "thinking": "only"},
614 + )
615 +
616 + def test_join_validation(self):
617 + no_repeats = {"start_anchor": "a", "fields": {"x": {"open": "<x>", "close": "</x>", "join": ""}}}
618 + with self.assertRaisesRegex(ValueError, "requires 'repeats'"):
619 + parse_response("", no_repeats, prefix="")
620 + bad_type = {"start_anchor": "a", "fields": {"x": {"open": "<x>", "close": "</x>", "repeats": True, "join": 7}}}
621 + with self.assertRaisesRegex(ValueError, "must be a string"):
622 + parse_response("", bad_type, prefix="")
623 +
624 + def test_join_requires_string_matches(self):
625 + template_spec = {
626 + "start_anchor": "a",
627 + "fields": {"x": {"open": "<x>", "close": "</x>", "repeats": True, "join": "", "content": "json"}},
628 + }
629 + with self.assertRaisesRegex(ValueError, "parse to a string"):
630 + parse_response("<x>{}</x>", template_spec, prefix="")
631 +
632 + def test_optional_false_raises_when_missing(self):
633 + template_spec = {
634 + "defaults": {"role": "assistant"},
635 + "start_anchor": "<|assistant|>",
636 + "fields": {
637 + "content": {
638 + "open": "<response>",
639 + "close": "</response>",
640 + "content": "text",
641 + "optional": False,
642 + },
643 + },
644 + }
645 + with self.assertRaises(ValueError) as cm:
646 + parse_response("no response here", template_spec, prefix="")
647 + self.assertIn("content", str(cm.exception))
648 +
649 + def test_int_content_parser(self):
650 + template_spec = {
651 + "defaults": {"role": "assistant"},
652 + "start_anchor": "<|assistant|>",
653 + "fields": {
654 + "count": {
655 + "open": "<n>",
656 + "close": "</n>",
657 + "content": "int",
658 + },
659 + },
660 + }
661 + self.assertEqual(parse_response("<n>42</n>", template_spec, prefix=""), {"role": "assistant", "count": 42})
662 +
663 + def test_kv_lines_parser(self):
664 + template_spec = {
665 + "defaults": {"role": "assistant"},
666 + "start_anchor": "<|assistant|>",
667 + "fields": {
668 + "metadata": {
669 + "open": "<meta>",
670 + "close": "</meta>",
671 + "content": "kv-lines",
672 + },
673 + },
674 + }
675 + self.assertEqual(
676 + parse_response("<meta>name: alice\nage: 30</meta>", template_spec, prefix=""),
677 + {"role": "assistant", "metadata": {"name": "alice", "age": "30"}},
678 + )
679 +
680 + def test_unknown_content_parser_rejected(self):
681 + bad_template = {
682 + "defaults": {"role": "assistant"},
683 + "start_anchor": "<|assistant|>",
684 + "fields": {"x": {"open": "[", "close": "]", "content": "not-a-real-parser"}},
685 + }
686 + with self.assertRaises(ValueError) as cm:
687 + parse_response("[hi]", bad_template)
688 + self.assertIn("unknown content parser", str(cm.exception).lower())
689 +
690 + def test_unsupported_version_rejected(self):
691 + bad_template = {
692 + "version": 2,
693 + "defaults": {"role": "assistant"},
694 + "start_anchor": "<|assistant|>",
695 + "fields": {"content": {"content": "text"}},
696 + }
697 + with self.assertRaises(ValueError) as cm:
698 + parse_response("hello", bad_template)
699 + self.assertIn("version", str(cm.exception).lower())
700 +
701 + def test_two_implicit_fields_rejected(self):
702 + bad_template = {
703 + "defaults": {"role": "assistant"},
704 + "start_anchor": "<|assistant|>",
705 + "fields": {
706 + "a": {"content": "text"},
707 + "b": {"content": "text"},
708 + },
709 + }
710 + with self.assertRaises(ValueError):
711 + parse_response("hello", bad_template)
712 +
713 + def test_transform_string_interpolation_rejected(self):
714 + bad_template = {
715 + "defaults": {"role": "assistant"},
716 + "start_anchor": "<|assistant|>",
717 + "fields": {
718 + "tool": {
719 + "open": "<tool>",
720 + "close": "</tool>",
721 + "content": "text",
722 + "transform": {"label": "name: {content}"},
723 + },
724 + },
725 + }
726 + with self.assertRaises(ValueError) as cm:
727 + parse_response("<tool>foo</tool>", bad_template)
728 + msg = str(cm.exception)
729 + self.assertIn("interpolation", msg)
730 + self.assertIn("{content}", msg)
731 +
732 + def test_named_groups_without_transform_rejected(self):
733 + bad_template = {
734 + "defaults": {"role": "assistant"},
735 + "start_anchor": "<|assistant|>",
736 + "fields": {
737 + "tool": {
738 + "open_pattern": r"<tool name=(?P<name>\w+)>",
739 + "close": "</tool>",
740 + "content": "text",
741 + },
742 + },
743 + }
744 + with self.assertRaises(ValueError) as cm:
745 + parse_response("<tool name=foo>body</tool>", bad_template)
746 + msg = str(cm.exception)
747 + self.assertIn("transform", msg)
748 + self.assertIn("name", msg)
749 +
750 + def test_literal_list_open_and_close(self):
751 + """A list of literals matches any one of them, like an alternation."""
752 + template_spec = {
753 + "defaults": {"role": "assistant"},
754 + "start_anchor": "<|assistant|>",
755 + "fields": {
756 + "x": {
757 + "open": ["<a>", "<bb>"],
758 + "close": ["</a>", "</bb>"],
759 + "content": "text",
760 + },
761 + },
762 + }
763 + for opener, closer in (("<a>", "</a>"), ("<bb>", "</bb>"), ("<a>", "</bb>")):
764 + self.assertEqual(
765 + parse_response(f"{opener}hi{closer}", template_spec, prefix=""),
766 + {"role": "assistant", "x": "hi"},
767 + )
768 +
769 + def test_literal_list_streams_without_64_byte_hold(self):
770 + """Compared to a regex close, a literal-list close lets the parser
771 + flush bytes that aren't in the longest-prefix overlap of any literal.
772 + With `["<turn|>", "<|tool_response>", "<eos>"]` (longest = 16 chars),
773 + feeding 32 plain bytes should leave at most 15 unflushed."""
774 + template_spec = {
775 + "defaults": {"role": "assistant"},
776 + "start_anchor": "<|assistant|>",
777 + "fields": {
778 + "content": {"close": ["<turn|>", "<|tool_response>", "<eos>"], "content": "text"},
779 + },
780 + }
781 + parser = ResponseParser(template_spec, prefix="")
782 + plain = "x" * 32
783 + flushed: list[str] = []
784 + for ch in parser.feed(plain):
785 + if ch["type"] == "region_chunk":
786 + flushed.append(ch["text"])
787 + # Plain text has zero prefix-overlap with any literal, so the parser
788 + # holds nothing back and streams everything immediately.
789 + self.assertEqual("".join(flushed), plain)
790 +
791 + def test_literal_list_defers_prefix_overlapping_literal(self):
792 + """If a literal is a strict prefix of another in the same list, an
793 + edge match could still grow with more input: we must defer to be safe."""
794 + template_spec = {
795 + "defaults": {"role": "assistant"},
796 + "start_anchor": "<|assistant|>",
797 + "fields": {
798 + "x": {"open": "<x>", "close": ["END", "ENDX"], "content": "text"},
799 + },
800 + }
801 + parser = ResponseParser(template_spec, prefix="")
802 + # "<x>hiEND" mid-stream: don't commit the close yet: "ENDX" might be coming.
803 + events = parser.feed("<x>hiEND")
804 + self.assertEqual([e for e in events if e["type"] == "region_close"], [])
805 + # Once a non-matching byte arrives, the deferred close commits with the shorter literal.
806 + events.extend(parser.feed(" more"))
807 + message, _ = parser.finalize()
808 + closes = [e for e in events if e["type"] == "region_close" and e["field"] == "x"]
809 + self.assertEqual(len(closes), 1)
810 + self.assertEqual(closes[0]["value"], "hi")
811 + self.assertEqual(message, {"role": "assistant", "x": "hi"})
812 +
813 + def test_literal_list_rejects_empty_and_non_string(self):
814 + for bad_open in ([], [""], [1, 2], {"foo": "bar"}):
815 + spec = {
816 + "defaults": {"role": "assistant"},
817 + "start_anchor": "<|assistant|>",
818 + "fields": {"x": {"open": bad_open, "close": "</x>", "content": "text"}},
819 + }
820 + with self.assertRaises(ValueError):
821 + parse_response("<x>hi</x>", spec, prefix="")
822 +
823 + def test_field_without_close_runs_to_end_of_stream(self):
824 + """A field with no `close`/`close_pattern` stays open until end-of-stream, capturing
825 + everything after its open."""
826 + spec = {
827 + "defaults": {"role": "assistant"},
828 + "start_anchor": "<|assistant|>",
829 + "fields": {"content": {"open": "<resp>", "content": "text"}},
830 + }
831 + self.assertEqual(
832 + parse_response("<resp>hello world", spec, prefix=""),
833 + {"role": "assistant", "content": "hello world"},
834 + )
835 +
836 + def test_mutable_defaults_do_not_leak_across_parsers(self):
837 + """A `repeats` field appends into its default list; one parse must not leak into the next
838 + parse sharing the template, nor into the caller's spec dict."""
839 + spec: dict[str, Any] = {
840 + "defaults": {"role": "assistant", "tool_calls": []},
841 + "start_anchor": "<|assistant|>",
842 + "fields": {
843 + "tool_calls": {
844 + "open": "<t>",
845 + "close": "</t>",
846 + "repeats": True,
847 + "content": "json",
848 + },
849 + "content": {"content": "text"},
850 + },
851 + }
852 + template = load_response_template(spec)
853 + self.assertEqual(
854 + parse_response('<t>{"a": 1}</t>', template, prefix="")["tool_calls"],
855 + [{"a": 1}],
856 + )
857 + self.assertEqual(
858 + parse_response("hello", template, prefix=""),
859 + {"role": "assistant", "tool_calls": [], "content": "hello"},
860 + )
861 + self.assertEqual(spec["defaults"]["tool_calls"], [])
862 +
863 +
864 +# Fixtures shared by the streaming tests: one representative input per template,
865 +# re-used for both the correctness invariant (any chunking → same dict) and the
866 +# event-shape tests (do we emit the right events in the right order?).
867 +_STREAMING_FIXTURES = [
868 + (
869 + "cohere",
870 + cohere_template,
871 + (
872 + "<|START_THINKING|>I should call a tool.<|END_THINKING|>"
873 + '<|START_ACTION|>[{"tool_call_id": "0", "tool_name": "simple_tool", '
874 + '"parameters": {"a": 1}}]<|END_ACTION|>'
875 + ),
876 + ),
877 + (
878 + "ernie",
879 + ernie_template,
880 + (
881 + "<think>some deliberation here</think>\n\n"
882 + '<tool_call>\n{"name": "get_current_temperature", "arguments": {"location": "Paris"}}\n</tool_call>\n</s>'
883 + ),
884 + ),
885 + (
886 + "gpt_oss",
887 + gpt_oss_template,
888 + "<|channel|>analysis<|message|>thinking chunk<|end|><|channel|>final<|message|>done text",
889 + ),
890 + (
891 + # Tool-call variant: the `tool_calls` open_pattern's full match spans far
892 + # more than the old 64-byte hold window, so streaming used to silently drop
893 + # it. Kept as a streaming fixture so every chunking step re-checks it.
894 + "gpt_oss_tool",
895 + gpt_oss_template,
896 + (
897 + "<|channel|>analysis<|message|>Let me check.<|end|>"
898 + "<|start|>assistant<|channel|>commentary to=functions.get_current_weather "
899 + '<|constrain|>json<|message|>{"location": "San Francisco, CA"}<|call|>'
900 + ),
901 + ),
902 + (
903 + "smollm",
904 + smollm_template,
905 + '<think>thinking</think>\n<tool_call>{"name": "fn", "arguments": {"x": 1}}</tool_call>',
906 + ),
907 + (
908 + "qwen3",
909 + qwen3_template,
910 + (
911 + "<think>short thought</think>\n"
912 + "<tool_call>\n<function=get_weather>\n"
913 + "<parameter=city>\nParis\n</parameter>\n"
914 + "</function>\n</tool_call>"
915 + ),
916 + ),
917 + (
918 + "gemma4",
919 + gemma4_template,
920 + '<|channel>thought\nhi<channel|><|tool_call>call:foo{a:1,b:<|"|>bar<|"|>}<tool_call|>',
921 + ),
922 + (
923 + # Exercises `join` fields (two thinking blocks) and dotted transform paths.
924 + "inkling",
925 + inkling_template,
926 + (
927 + "<|content_thinking|>Consider the weather.<|end_message|>"
928 + "<|message_model|><|content_thinking|> Tokyo, probably.<|end_message|>"
929 + "<|message_model|><|content_text|>Checking now.<|end_message|>"
930 + "<|message_model|>get_weather<|content_invoke_tool_json|>"
931 + '{"name":"get_weather","args":{"city":"Tokyo"}}<|end_message|>'
932 + "<|content_model_end_sampling|>"
933 + ),
934 + ),
935 +]
936 +
937 +
938 +def _chunk_fixed(text: str, step: int):
939 + for i in range(0, len(text), step):
940 + yield text[i : i + step]
941 +
942 +
943 +def _chunk_random(text: str, rng: random.Random):
944 + """Split `text` into a random number of non-empty chunks at random cut points."""
945 + if len(text) <= 1:
946 + yield text
947 + return
948 + num_cuts = rng.randint(0, len(text) - 1)
949 + cuts = sorted(rng.sample(range(1, len(text)), num_cuts))
950 + prev = 0
951 + for c in cuts:
952 + yield text[prev:c]
953 + prev = c
954 + yield text[prev:]
955 +
956 +
957 +class ResponseEventStreamTest(unittest.TestCase):
958 + def test_stream_matches_whole_string_all_templates_fixed_chunking(self):
959 + """For every fixed chunking step we try, the streamed finalize()
960 + output must equal the whole-string parse. Regression coverage for
961 + specific edge-case byte boundaries (1-byte chunks hit every prefix)."""
962 + for name, tmpl, text in _STREAMING_FIXTURES:
963 + expected = parse_response(text, tmpl, prefix="")
964 + for step in (1, 2, 3, 5, 7, 13, 31):
965 + with self.subTest(fixture=name, step=step):
966 + streamer = ResponseParser(tmpl, prefix="")
967 + for chunk in _chunk_fixed(text, step):
968 + streamer.feed(chunk)
969 + message, _ = streamer.finalize()
970 + self.assertEqual(message, expected)
971 +
972 + def test_stream_matches_whole_string_all_templates_random_chunking(self):
973 + """Property-style: for many random chunkings per fixture, the streamed
974 + finalize() output must equal the whole-string parse. Seeded so failures
975 + reproduce."""
976 + rng = random.Random(0xC0DE_5EED)
977 + for name, tmpl, text in _STREAMING_FIXTURES:
978 + expected = parse_response(text, tmpl, prefix="")
979 + for trial in range(30):
980 + with self.subTest(fixture=name, trial=trial):
981 + streamer = ResponseParser(tmpl, prefix="")
982 + for chunk in _chunk_random(text, rng):
983 + streamer.feed(chunk)
984 + message, _ = streamer.finalize()
985 + self.assertEqual(message, expected)
986 +
987 + def test_events_well_formed_for_every_chunking(self):
988 + """Every event batch, across every fixture and every chunking, must be
989 + well-formed: region_open precedes its matching region_close for the
990 + same field; region_chunk only appears between open and close; no
991 + region is left open at the end of the stream; and the concatenation
992 + of all region_chunk payloads for a streamable text-like field equals
993 + the final value."""
994 + rng = random.Random(0xBEEF)
995 + for name, tmpl, text in _STREAMING_FIXTURES:
996 + for trial in range(10):
997 + with self.subTest(fixture=name, trial=trial):
998 + streamer = ResponseParser(tmpl, prefix="")
999 + all_events: list[dict] = []
1000 + for chunk in _chunk_random(text, rng):
1001 + all_events.extend(streamer.feed(chunk))
1002 + _, final_events = streamer.finalize()
1003 + all_events.extend(final_events)
1004 + self._assert_event_stream_well_formed(all_events)
1005 +
1006 + def _assert_event_stream_well_formed(self, events: list[dict]) -> None:
1007 + open_field: str | None = None
1008 + chunk_accum: dict[str, str] = {}
1009 + close_values: dict[str, object] = {}
1010 + for ev in events:
1011 + t = ev["type"]
1012 + if t == "region_open":
1013 + self.assertIsNone(open_field, f"nested region_open without close: {ev}")
1014 + open_field = ev["field"]
1015 + chunk_accum.setdefault(open_field, "")
1016 + elif t == "region_chunk":
1017 + self.assertEqual(open_field, ev["field"], f"chunk outside its region: {ev}")
1018 + # Every chunk carries a boolean `dirty` flag.
1019 + self.assertIsInstance(ev["dirty"], bool, f"missing/non-bool dirty: {ev}")
1020 + chunk_accum[ev["field"]] += ev["text"]
1021 + elif t == "region_close":
1022 + self.assertEqual(open_field, ev["field"], f"close for non-open region: {ev}")
1023 + close_values[ev["field"]] = ev["value"]
1024 + open_field = None
1025 + else:
1026 + self.fail(f"unexpected event type: {ev!r}")
1027 + self.assertIsNone(open_field, "region left open at end of stream")
1028 +
1029 + def test_region_chunks_reconstruct_text_regions(self):
1030 + """For text-like regions (`dirty=False`), concatenating chunk texts
1031 + reconstructs the final value reported in region_close. Structured
1032 + regions (`dirty=True`) still stream their raw bytes: concatenating
1033 + those chunks yields the unparsed region body, while the parsed value
1034 + is delivered only in region_close."""
1035 + # Single representative case with a long text region and a JSON region.
1036 + text = _STREAMING_FIXTURES[0][2] # cohere fixture
1037 + streamer = ResponseParser(cohere_template, prefix="")
1038 + events: list[dict] = []
1039 + for ch in text: # 1-byte chunks hit the most anchor boundaries
1040 + events.extend(streamer.feed(ch))
1041 + _, final_events = streamer.finalize()
1042 + events.extend(final_events)
1043 +
1044 + # Reconstruct per-field.
1045 + per_field_chunks: dict[str, list[str]] = {}
1046 + per_field_dirty: dict[str, set[bool]] = {}
1047 + per_field_close_value: dict[str, object] = {}
1048 + for ev in events:
1049 + if ev["type"] == "region_chunk":
1050 + per_field_chunks.setdefault(ev["field"], []).append(ev["text"])
1051 + per_field_dirty.setdefault(ev["field"], set()).add(ev["dirty"])
1052 + elif ev["type"] == "region_close":
1053 + per_field_close_value[ev["field"]] = ev["value"]
1054 +
1055 + # `thinking` is text → chunks are clean and concatenate to its value.
1056 + self.assertIn("thinking", per_field_chunks)
1057 + self.assertEqual(per_field_dirty["thinking"], {False})
1058 + self.assertEqual("".join(per_field_chunks["thinking"]), "I should call a tool.")
1059 + self.assertEqual(per_field_close_value["thinking"], "I should call a tool.")
1060 + # `tool_calls` is json → dirty chunks stream the raw body, parsed value on close.
1061 + self.assertIn("tool_calls", per_field_chunks)
1062 + self.assertEqual(per_field_dirty["tool_calls"], {True})
1063 + self.assertEqual(
1064 + "".join(per_field_chunks["tool_calls"]),
1065 + '[{"tool_call_id": "0", "tool_name": "simple_tool", "parameters": {"a": 1}}]',
1066 + )
1067 + self.assertIn("tool_calls", per_field_close_value)
1068 +
1069 + def test_dirty_flag_marks_structured_regions(self):
1070 + """A template with one text field and one structured field per parser
1071 + family: text/int/float/bool stream chunks with `dirty=False`, while
1072 + json/xml-inline/kv-lines stream chunks with `dirty=True`, and those
1073 + dirty chunks concatenate to the raw region body before parsing."""
1074 + spec = {
1075 + "defaults": {"role": "assistant"},
1076 + "start_anchor": "<|assistant|>",
1077 + "fields": {
1078 + "thinking": {"open": "<t>", "close": "</t>", "content": "text"},
1079 + "score": {"open": "<n>", "close": "</n>", "content": "int"},
1080 + "json_call": {"open": "<j>", "close": "</j>", "content": "json"},
1081 + "xml_call": {
1082 + "open": "<x>",
1083 + "close": "</x>",
1084 + "content": "xml-inline",
1085 + "content_args": {"tag_pattern": r"<(?P<key>\w+)=(?P<value>[^>]+)>"},
1086 + },
1087 + "kv_call": {
1088 + "open": "<kv>",
1089 + "close": "</kv>",
1090 + "content": "kv-lines",
1091 + },
1092 + },
1093 + }
1094 + text = '<t>hello world</t><n>42</n><j>{"a": 1, "b": 2}</j><x><name=foo><age=10></x><kv>k1: v1\nk2: v2</kv>'
1095 + # Drive byte-by-byte to maximise chunk count.
1096 + streamer = ResponseParser(spec, prefix="")
1097 + events: list[dict] = []
1098 + for ch in text:
1099 + events.extend(streamer.feed(ch))
1100 + _, final_events = streamer.finalize()
1101 + events.extend(final_events)
1102 +
1103 + per_field_chunks: dict[str, list[str]] = {}
1104 + per_field_dirty: dict[str, set[bool]] = {}
1105 + for ev in events:
1106 + if ev["type"] == "region_chunk":
1107 + per_field_chunks.setdefault(ev["field"], []).append(ev["text"])
1108 + per_field_dirty.setdefault(ev["field"], set()).add(ev["dirty"])
1109 +
1110 + # Clean (streamable) regions.
1111 + for field in ("thinking", "score"):
1112 + self.assertEqual(per_field_dirty[field], {False}, f"{field} should be clean")
1113 + # Dirty (structured) regions.
1114 + for field in ("json_call", "xml_call", "kv_call"):
1115 + self.assertEqual(per_field_dirty[field], {True}, f"{field} should be dirty")
1116 +
1117 + # Dirty chunks reconstruct the raw region body (un-parsed). Clean
1118 + # chunks reconstruct the verbatim body too: stripping happens at close.
1119 + self.assertEqual("".join(per_field_chunks["thinking"]), "hello world")
1120 + self.assertEqual("".join(per_field_chunks["score"]), "42")
1121 + self.assertEqual("".join(per_field_chunks["json_call"]), '{"a": 1, "b": 2}')
1122 + self.assertEqual("".join(per_field_chunks["xml_call"]), "<name=foo><age=10>")
1123 + self.assertEqual("".join(per_field_chunks["kv_call"]), "k1: v1\nk2: v2")
1124 +
1125 + def test_long_regex_open_pattern_streams_byte_by_byte(self):
1126 + """Regression: a regex `open_pattern` whose full match spans well past the
1127 + old fixed 64-byte hold window (gpt-oss tool calls) must not be dropped when
1128 + the stream arrives in tiny chunks. Before the partial-match rewrite, the
1129 + leading `<|channel|>` got flushed out of the hold window before `<|message|>`
1130 + arrived, so the tool call vanished under small-chunk streaming."""
1131 + text = (
1132 + "<|channel|>analysis<|message|>Let me check.<|end|>"
1133 + "<|start|>assistant<|channel|>commentary to=functions.get_current_weather "
1134 + '<|constrain|>json<|message|>{"location": "San Francisco, CA"}<|call|>'
1135 + )
1136 + expected = parse_response(text, gpt_oss_template, prefix="")
1137 + # Sanity: the whole-string parse really does recover the tool call.
1138 + self.assertEqual(len(expected["tool_calls"]), 1)
1139 + self.assertEqual(expected["tool_calls"][0]["function"]["name"], "get_current_weather")
1140 + streamer = ResponseParser(gpt_oss_template, prefix="")
1141 + for ch in text: # one byte at a time -- the worst case for the old heuristic
1142 + streamer.feed(ch)
1143 + message, _ = streamer.finalize()
1144 + self.assertEqual(message, expected)
1145 +
1146 + def test_feed_after_finalize_raises(self):
1147 + streamer = ResponseParser(smollm_template, prefix="")
1148 + streamer.feed("<think>x</think>")
1149 + streamer.finalize()
1150 + with self.assertRaises(RuntimeError):
1151 + streamer.feed("more")
1152 + with self.assertRaises(RuntimeError):
1153 + streamer.finalize()
1154 +
1155 + def test_empty_input_streams_cleanly(self):
1156 + streamer = ResponseParser(smollm_template, prefix="")
1157 + self.assertEqual(streamer.feed(""), [])
1158 + result, final_events = streamer.finalize()
1159 + # Only the default fields should remain; nothing else is required.
1160 + self.assertEqual(result, {"role": "assistant"})
1161 + self.assertEqual(final_events, [])
1162 +
1163 +
1164 +class PrefixAndTruncationTest(unittest.TestCase):
1165 + def test_prefix_lands_inside_explicit_region(self):
1166 + """A Qwen-style template emits `<|im_start|>assistant\\n<think>\\n` as the
1167 + assistant prefix. The model continues from inside the thinking block."""
1168 + prompt = (
1169 + "<|im_start|>system\nYou are helpful<|im_end|>\n"
1170 + "<|im_start|>user\nHi<|im_end|>\n"
1171 + "<|im_start|>assistant\n<think>\n"
1172 + )
1173 + generated = "Let me think...</think>"
1174 + stream = ResponseParser(qwen3_template, prefix=prompt)
1175 + # The region_open for `thinking` surfaces via initial_events; the
1176 + # caller replays it before feeding model output.
1177 + self.assertEqual(
1178 + [(e["type"], e["field"]) for e in stream.initial_events],
1179 + [("region_open", "thinking"), ("region_chunk", "thinking")],
1180 + )
1181 + events = stream.feed(generated)
1182 + result, _ = stream.finalize()
1183 + # thinking ends up with the prefill + generated body; text parser strips,
1184 + # so the leading "\n" from the prefix is trimmed in the final value.
1185 + self.assertEqual(result, {"role": "assistant", "thinking": "Let me think..."})
1186 + # The feed stream only sees the rest of the body and the close; the
1187 + # prefill already emitted region_open.
1188 + self.assertEqual([e["type"] for e in events], ["region_chunk", "region_close"])
1189 + self.assertEqual(events[1]["field"], "thinking")
1190 +
1191 + def test_prefix_truncated_to_last_anchor(self):
1192 + """Multiple `<|im_start|>assistant\\n` anchors in the prefix (multi-turn
1193 + conversation): only the slice after the LAST anchor matters."""
1194 + prompt = (
1195 + "<|im_start|>system\nA<|im_end|>\n"
1196 + "<|im_start|>user\nB<|im_end|>\n"
1197 + "<|im_start|>assistant\nEarlier reply<|im_end|>\n"
1198 + "<|im_start|>user\nFollowup<|im_end|>\n"
1199 + "<|im_start|>assistant\n<think>\n"
1200 + )
1201 + stream = ResponseParser(qwen3_template, prefix=prompt)
1202 + # We landed inside `thinking` (from the LAST assistant turn's `<think>\n`),
1203 + # not in some earlier-turn artifact.
1204 + opens = [e for e in stream.initial_events if e["type"] == "region_open"]
1205 + self.assertEqual([e["field"] for e in opens], ["thinking"])
1206 + # No earlier-turn content leaked into output.
1207 + stream.feed("done</think>")
1208 + stream.finalize()
1209 + self.assertEqual(stream._output, {"role": "assistant", "thinking": "done"})
1210 +
1211 + def test_template_without_anchor_rejected_at_load(self):
1212 + """A template missing both `start_anchor` and `start_anchor_pattern` is
1213 + rejected at load time. Without an anchor, a multi-turn prompt would be
1214 + fed through the parser in full and earlier turns would pollute the
1215 + current message's state."""
1216 + anchorless = {k: v for k, v in qwen3_template.items() if k != "start_anchor"}
1217 + with self.assertRaises(ValueError) as cm:
1218 + ResponseParser(anchorless)
1219 + msg = str(cm.exception)
1220 + self.assertIn("start_anchor", msg)
1221 +
1222 + def test_prefix_anchor_not_found_falls_back(self):
1223 + """Spec has start_anchor but the prefix doesn't contain it: parser
1224 + falls back to processing the entire prefix (with a logged warning)."""
1225 + prompt = "<think>\n" # no <|im_start|>assistant\n
1226 + stream = ResponseParser(qwen3_template, prefix=prompt)
1227 + opens = [e for e in stream.initial_events if e["type"] == "region_open"]
1228 + self.assertEqual([e["field"] for e in opens], ["thinking"])
1229 + stream.feed("hi</think>")
1230 + stream.finalize()
1231 + self.assertEqual(stream._output, {"role": "assistant", "thinking": "hi"})
1232 +
1233 + def test_round_trip_equivalence_prefix_streaming_vs_oneshot(self):
1234 + """The load-bearing property: `prefix=p` + chunked `feed(g)` produces the
1235 + same dict as the one-shot `parse_response(g, prefix=p)`, regardless of how
1236 + `g` is chunked. (Concatenating the prompt into the response is deliberately
1237 + NOT equivalent -- the anchor is applied to the prefix only, never to the
1238 + generation; see test_history_bleed_is_guarded_by_prefix_not_by_response_anchor.)"""
1239 + prompt = "<|im_start|>system\nA<|im_end|>\n<|im_start|>user\nB<|im_end|>\n<|im_start|>assistant\n<think>\n"
1240 + for name, tmpl_dict, gen_text in _STREAMING_FIXTURES:
1241 + if "thinking" not in gen_text and "<think>" not in gen_text:
1242 + continue
1243 + # Only fixtures whose generation text is compatible with starting
1244 + # inside `thinking`. Restrict to qwen3 / smollm shape for clarity.
1245 + if name not in ("qwen3", "smollm"):
1246 + continue
1247 + tmpl_with_anchor = {**tmpl_dict, "start_anchor": "<|im_start|>assistant\n"}
1248 + via_prefix = parse_response(gen_text, tmpl_with_anchor, prefix=prompt)
1249 + # Streaming forms must match the one-shot prefix form for every chunking.
1250 + for step in (1, 3, 7, 31):
1251 + with self.subTest(fixture=name, step=step):
1252 + stream = ResponseParser(tmpl_with_anchor, prefix=prompt)
1253 + for chunk in _chunk_fixed(gen_text, step):
1254 + stream.feed(chunk)
1255 + message, _ = stream.finalize()
1256 + self.assertEqual(message, via_prefix)
1257 +
1258 + def test_history_bleed_is_guarded_by_prefix_not_by_response_anchor(self):
1259 + """The `start_anchor` guards against history bleed only via `prefix=`; it is NOT
1260 + applied to the response. The response is the generation and may legitimately contain
1261 + the anchor (e.g. gpt-oss harmony opens every channel with `<|start|>assistant`), so
1262 + truncating it would drop real content. Passing the prompt as `prefix=` is the way to
1263 + keep earlier turns out of the parse."""
1264 + spec = {
1265 + "defaults": {"role": "assistant"},
1266 + "start_anchor": "<|im_start|>assistant\n",
1267 + "fields": {"content": {"close_pattern": r"\Z", "content": "text"}},
1268 + }
1269 + prompt = "<|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n"
1270 + gen = "Hello there!"
1271 + clean = {"role": "assistant", "content": "Hello there!"}
1272 + # The supported guard: pass the prompt as prefix= so history is truncated off the prefix.
1273 + self.assertEqual(parse_response(gen, spec, prefix=prompt), clean)
1274 + # Pure generation parses cleanly with the explicit no-prefix opt-out (prefix="").
1275 + self.assertEqual(parse_response(gen, spec, prefix=""), clean)
1276 + # An anchor inside the response is treated as content, never as a history boundary:
1277 + # gpt-oss re-emits `<|start|>assistant` between channels, and that content survives.
1278 + gpt_oss_gen = (
1279 + "<|channel|>analysis<|message|>thinking<|end|><|start|>assistant<|channel|>final<|message|>answer"
1280 + )
1281 + self.assertEqual(
1282 + parse_response(gpt_oss_gen, gpt_oss_template, prefix=""),
1283 + {"role": "assistant", "thinking": "thinking", "content": "answer"},
1284 + )
1285 +
1286 + def test_prefix_with_open_close_inside_truncated_region(self):
1287 + """Prefix opens AND closes a region. The full open/chunk/close event
1288 + sequence is surfaced via initial_events, and the closed region lands
1289 + in the output dict: so renderers can show prefill content."""
1290 + spec = {
1291 + "defaults": {"role": "assistant"},
1292 + "start_anchor": "[BEGIN]",
1293 + "fields": {
1294 + "tag": {"open": "<tag>", "close": "</tag>", "content": "text"},
1295 + "body": {"close_pattern": r"$", "content": "text"}, # implicit
1296 + },
1297 + }
1298 + prefix = "noise[BEGIN]<tag>silently consumed</tag>"
1299 + stream = ResponseParser(spec, prefix=prefix)
1300 + types = [e["type"] for e in stream.initial_events]
1301 + self.assertEqual(types, ["region_open", "region_chunk", "region_close"])
1302 + self.assertTrue(all(e["field"] == "tag" for e in stream.initial_events))
1303 + self.assertEqual(stream.initial_events[-1]["value"], "silently consumed")
1304 + stream.feed("real generated body")
1305 + result, _ = stream.finalize()
1306 + self.assertEqual(result["tag"], "silently consumed")
1307 + self.assertEqual(result["body"], "real generated body")
1308 +
1309 + def test_prefix_lands_inside_implicit_region(self):
1310 + """Prefix wrote plaintext into the implicit region (e.g. assistant
1311 + prefill before the model continues). The region_open for the implicit
1312 + region must surface via initial_events so consumers don't miss it:
1313 + `_opened` will already be True by the time feed runs."""
1314 + prompt = "<|im_start|>assistant\nSure, here is "
1315 + stream = ResponseParser(smollm_template, prefix=prompt)
1316 + opens = [e for e in stream.initial_events if e["type"] == "region_open"]
1317 + self.assertEqual([e["field"] for e in opens], ["content"])
1318 + events = stream.feed("the answer<|im_end|>")
1319 + # No second region_open from feed: the implicit region was already
1320 + # opened during prefill and surfaced via initial_events.
1321 + self.assertNotIn("region_open", [e["type"] for e in events])
1322 +
1323 + def test_prefix_partial_pattern_at_boundary(self):
1324 + """Post-truncation prefix ends mid-delimiter. The first feed completes
1325 + the match; initial_events is empty (no region opened within the
1326 + prefix yet) and the open fires from `feed()`."""
1327 + prefix = "<|im_start|>assistant\n<th" "i" # incomplete `<think>`
1328 + stream = ResponseParser(qwen3_template, prefix=prefix)
1329 + self.assertEqual(stream.initial_events, [])
1330 + events = stream.feed("nk>real body</think>")
1331 + types = [e["type"] for e in events]
1332 + self.assertIn("region_open", types) # think opens during feed, not prefill
1333 + stream.finalize()
1334 + self.assertEqual(stream._output, {"role": "assistant", "thinking": "real body"})
1335 +
1336 + def test_prefix_is_required(self):
1337 + spec = {
1338 + "start_anchor": "<assistant>",
1339 + "fields": {"content": {"content": "text"}},
1340 + }
1341 + with self.assertRaisesRegex(ValueError, "requires `prefix`"):
1342 + ResponseParser(spec)
1343 +
1344 +
1345 +# xml-inline without a value_parser: parameter bodies stay raw strings until tools= coerces them.
1346 +_XML_STRING_ARGS_TEMPLATE = {
1347 + "defaults": {"role": "assistant"},
1348 + "start_anchor": "<|im_start|>assistant\n",
1349 + "fields": {
1350 + "tool_calls": {
1351 + "open_pattern": r"<tool_call>\s*<function=(?P<name>\w+)>",
1352 + "close": "</tool_call>",
1353 + "repeats": True,
1354 + "content": "xml-inline",
1355 + "content_args": {
1356 + "tag_pattern": r"<parameter=(?P<key>\w+)>\s*(?P<value>.*?)\s*</parameter>",
1357 + },
1358 + "transform": {"type": "function", "function": {"name": "{name}", "arguments": "{content}"}},
1359 + },
1360 + },
1361 +}
1362 +
1363 +# kv-lines without a value_parser: values likewise stay raw strings for tools= to cast.
1364 +_KV_LINES_TOOLS_TEMPLATE = {
1365 + "defaults": {"role": "assistant"},
1366 + "start_anchor": "<|im_start|>assistant\n",
1367 + "fields": {
1368 + "tool_calls": {
1369 + "open_pattern": r"<tool_call>\s*<function=(?P<name>\w+)>\n",
1370 + "close": "</tool_call>",
1371 + "repeats": True,
1372 + "content": "kv-lines",
1373 + "transform": {"type": "function", "function": {"name": "{name}", "arguments": "{content}"}},
1374 + },
1375 + },
1376 +}
1377 +
1378 +_SET_ALARM_CALL = (
1379 + "<tool_call>\n<function=set_alarm>\n"
1380 + "<parameter=hour>\n7\n</parameter>\n"
1381 + "<parameter=enabled>\ntrue\n</parameter>\n"
1382 + "<parameter=label>\nwake up\n</parameter>\n"
1383 + "</function>\n</tool_call>"
1384 +)
1385 +
1386 +
1387 +def _set_alarm_tools(**properties):
1388 + return [
1389 + {
1390 + "type": "function",
1391 + "function": {
1392 + "name": "set_alarm",
1393 + "parameters": {"type": "object", "properties": properties},
1394 + },
1395 + }
1396 + ]
1397 +
1398 +
1399 +_SET_ALARM_TOOLS = _set_alarm_tools(
1400 + hour={"type": "integer"},
1401 + enabled={"type": "boolean"},
1402 + label={"type": "string"},
1403 +)
1404 +
1405 +
1406 +def _first_tool_args(message):
1407 + return message["tool_calls"][0]["function"]["arguments"]
1408 +
1409 +
1410 +def _parser_with_tools(tools):
1411 + return ResponseParser(_XML_STRING_ARGS_TEMPLATE, prefix="", tools=tools)
1412 +
1413 +
1414 +class ToolArgCoercionTest(unittest.TestCase):
1415 + def test_coerce_tool_calls_casts_declared_types(self):
1416 + tools = _set_alarm_tools(
1417 + count={"type": "integer"},
1418 + ratio={"type": "number"},
1419 + enabled={"type": "boolean"},
1420 + tags={"type": "array"},
1421 + note={"type": "string"},
1422 + )
1423 + arguments = {
1424 + "count": "3",
1425 + "ratio": "1.5",
1426 + "enabled": "true",
1427 + "tags": '["a", "b"]',
1428 + "note": "hello",
1429 + "already_typed": 7,
1430 + "extra": "unscheduled",
1431 + }
1432 + call: dict[str, Any] = {
1433 + "type": "function",
1434 + "function": {"name": "set_alarm", "arguments": arguments},
1435 + }
1436 + self.assertIs(_parser_with_tools(tools)._coerce_tool_calls(call), call)
1437 + self.assertEqual(
1438 + call["function"]["arguments"],
1439 + {
1440 + "count": 3,
1441 + "ratio": 1.5,
1442 + "enabled": True,
1443 + "tags": ["a", "b"],
1444 + "note": "hello",
1445 + "already_typed": 7,
1446 + "extra": "unscheduled",
1447 + },
1448 + )
1449 +
1450 + def test_coerce_falls_back_to_raw_on_failure(self):
1451 + self.assertEqual(_coerce("not-a-number", ("integer",)), "not-a-number")
1452 +
1453 + def test_coerce_handles_any_of_and_null(self):
1454 + self.assertEqual(_coerce("5", ("integer", "null")), 5)
1455 + self.assertIsNone(_coerce("null", ("integer", "null")))
1456 +
1457 + def test_coerce_booleans_match_bool_parser(self):
1458 + # Accept the same literals as the `bool` content parser, case-insensitively.
1459 + for raw, expected in [("true", True), ("True", True), ("1", True), ("false", False), ("0", False)]:
1460 + self.assertEqual(_coerce(raw, ("boolean",)), expected)
1461 + # Non-boolean text stays a string rather than silently becoming False.
1462 + self.assertEqual(_coerce("maybe", ("boolean",)), "maybe")
1463 +
1464 + def test_coerce_object_array_require_matching_json(self):
1465 + # A JSON object body is only accepted for an `object` param, a JSON array only for `array`.
1466 + self.assertEqual(_coerce("[1, 2]", ("object",)), "[1, 2]")
1467 + self.assertEqual(_coerce('{"a": 1}', ("array",)), '{"a": 1}')
1468 + self.assertEqual(_coerce('{"a": 1}', ("object",)), {"a": 1})
1469 + # NaN / inf are not valid JSON numbers, so a `number` param keeps the raw text.
1470 + self.assertEqual(_coerce("NaN", ("number",)), "NaN")
1471 +
1472 + def test_coerce_tool_calls_handles_single_and_list(self):
1473 + parser = _parser_with_tools(_SET_ALARM_TOOLS)
1474 + call: dict[str, Any] = {
1475 + "type": "function",
1476 + "function": {"name": "set_alarm", "arguments": {"hour": "7"}},
1477 + }
1478 + self.assertIs(parser._coerce_tool_calls(call), call)
1479 + self.assertEqual(call["function"]["arguments"], {"hour": 7})
1480 + # A list of calls (as produced by `transform_each`) is coerced element-wise.
1481 + calls = [{"type": "function", "function": {"name": "set_alarm", "arguments": {"hour": "9"}}}]
1482 + self.assertEqual(parser._coerce_tool_calls(calls)[0]["function"]["arguments"], {"hour": 9})
1483 + # Non-tool-call values pass through untouched.
1484 + self.assertEqual(parser._coerce_tool_calls("hello"), "hello")
1485 +
1486 + def test_schema_types_handles_get_json_schema_dialect(self):
1487 + self.assertEqual(_schema_types({"type": "integer"}), ("integer",))
1488 + self.assertEqual(_schema_types({"type": ["integer", "string"]}), ("integer", "string"))
1489 + self.assertEqual(_schema_types({"anyOf": [{"type": "boolean"}, {"type": "string"}]}), ("boolean", "string"))
1490 + self.assertEqual(_schema_types({"oneOf": [{"type": "number"}, {"type": "null"}]}), ("number", "null"))
1491 + self.assertEqual(_schema_types({"type": "integer", "nullable": True}), ("integer", "null"))
1492 + # Undescribed parameters resolve to no candidate types, making coercion a no-op.
1493 + self.assertEqual(_schema_types({"description": "no type"}), ())
1494 +
1495 + def test_parse_response_tools_coerces_xml_inline_string_args(self):
1496 + # Without a value_parser, xml-inline argument bodies stay strings; tools= casts them.
1497 + without = parse_response(_SET_ALARM_CALL, _XML_STRING_ARGS_TEMPLATE, prefix="")
1498 + with_tools = parse_response(_SET_ALARM_CALL, _XML_STRING_ARGS_TEMPLATE, prefix="", tools=_SET_ALARM_TOOLS)
1499 + self.assertEqual(_first_tool_args(without), {"hour": "7", "enabled": "true", "label": "wake up"})
1500 + self.assertEqual(_first_tool_args(with_tools), {"hour": 7, "enabled": True, "label": "wake up"})
1501 +
1502 + def test_parse_response_tools_coerces_one_of_string_args(self):
1503 + tools = _set_alarm_tools(hour={"oneOf": [{"type": "integer"}, {"type": "null"}]})
1504 + parsed = parse_response(_SET_ALARM_CALL, _XML_STRING_ARGS_TEMPLATE, prefix="", tools=tools)
1505 + self.assertEqual(_first_tool_args(parsed)["hour"], 7)
1506 +
1507 + def test_streaming_tools_coerces_on_region_close(self):
1508 + # Coercion must land on the region_close event during feed(), not only after finalize().
1509 + stream = ResponseParser(_XML_STRING_ARGS_TEMPLATE, prefix="", tools=_SET_ALARM_TOOLS)
1510 + closes = [
1511 + event["value"]
1512 + for chunk in _chunk_fixed(_SET_ALARM_CALL, 8)
1513 + for event in stream.feed(chunk)
1514 + if event["type"] == "region_close" and event["field"] == "tool_calls"
1515 + ]
1516 + self.assertEqual(len(closes), 1)
1517 + self.assertEqual(closes[0]["function"]["arguments"], {"hour": 7, "enabled": True, "label": "wake up"})
1518 +
1519 + def test_qwen3_tools_coerces_strings_left_by_value_parser(self):
1520 + # qwen3's json+allow_non_json value_parser types what it can (`true`) and leaves
1521 + # invalid JSON (`007`) as a string; tools= then casts those leftover strings.
1522 + model_out = (
1523 + "<tool_call>\n<function=set_alarm>\n"
1524 + "<parameter=hour>\n007\n</parameter>\n"
1525 + "<parameter=enabled>\ntrue\n</parameter>\n"
1526 + "<parameter=label>\nwake up\n</parameter>\n"
1527 + "</function>\n</tool_call>"
1528 + )
1529 + without = parse_response(model_out, qwen3_template, prefix="")
1530 + with_tools = parse_response(model_out, qwen3_template, prefix="", tools=_SET_ALARM_TOOLS)
1531 + self.assertEqual(_first_tool_args(without), {"hour": "007", "enabled": True, "label": "wake up"})
1532 + self.assertEqual(_first_tool_args(with_tools), {"hour": 7, "enabled": True, "label": "wake up"})
1533 +
1534 + def test_coercion_never_reworks_already_typed_values(self):
1535 + # Coercion only casts strings: values the value_parser already typed are final
1536 + model_out = "<tool_call>\n<function=set_alarm>\n<parameter=label>\n1.50\n</parameter>\n</tool_call>"
1537 + # qwen3's lax value_parser has already read 1.50 as the float 1.5, so the string-typed label stays a float ...
1538 + self.assertEqual(
1539 + _first_tool_args(parse_response(model_out, qwen3_template, prefix="", tools=_SET_ALARM_TOOLS)),
1540 + {"label": 1.5},
1541 + )
1542 + # ... while without a value_parser the raw text reaches the schema cast intact
1543 + self.assertEqual(
1544 + _first_tool_args(parse_response(model_out, _XML_STRING_ARGS_TEMPLATE, prefix="", tools=_SET_ALARM_TOOLS)),
1545 + {"label": "1.50"},
1546 + )
1547 +
1548 + def test_kv_lines_string_args_are_coerced(self):
1549 + model_out = "<tool_call>\n<function=set_alarm>\nhour: 7\nenabled: true\n</tool_call>"
1550 + without = parse_response(model_out, _KV_LINES_TOOLS_TEMPLATE, prefix="")
1551 + with_tools = parse_response(model_out, _KV_LINES_TOOLS_TEMPLATE, prefix="", tools=_SET_ALARM_TOOLS)
1552 + self.assertEqual(_first_tool_args(without), {"hour": "7", "enabled": "true"})
1553 + self.assertEqual(_first_tool_args(with_tools), {"hour": 7, "enabled": True})
1554 +
1555 + def test_non_tool_call_regions_are_untouched(self):
1556 + # A field that captures a `name` but does not parse into a tool call must be left
1557 + # alone, even when the capture happens to match a tool: its keys are not arguments.
1558 + template = {
1559 + "defaults": {"role": "assistant"},
1560 + "start_anchor": "<|im_start|>assistant\n",
1561 + "fields": {
1562 + "citation": {
1563 + "open_pattern": r"<cite source=(?P<name>\w+)>",
1564 + "close": "</cite>",
1565 + "content": "xml-inline",
1566 + "content_args": {
1567 + "tag_pattern": r"<(?P<key>\w+)>\s*(?P<value>.*?)\s*</\1>",
1568 + "value_parser": {"name": "json", "args": {"allow_non_json": True}},
1569 + },
1570 + "transform": {"source": "{name}", "fields": "{content}"},
1571 + },
1572 + },
1573 + }
1574 + model_out = "<cite source=set_alarm><label>1.50</label><hour>7</hour></cite>"
1575 + expected = {"source": "set_alarm", "fields": {"label": 1.5, "hour": 7}}
1576 + self.assertEqual(parse_response(model_out, template, prefix="")["citation"], expected)
1577 + self.assertEqual(
1578 + parse_response(model_out, template, prefix="", tools=_SET_ALARM_TOOLS)["citation"],
1579 + expected,
1580 + )
1581 +
1582 + def test_merge_duplicates_arguments_are_cast_element_wise(self):
1583 + # merge_duplicates collects repeated tags into a list, which is cast element-wise
1584 + template: dict[str, Any] = copy.deepcopy(_XML_STRING_ARGS_TEMPLATE)
1585 + template["fields"]["tool_calls"]["content_args"]["merge_duplicates"] = True
1586 + model_out = (
1587 + "<tool_call>\n<function=set_alarm>\n"
1588 + "<parameter=hour>\n7\n</parameter>\n"
1589 + "<parameter=hour>\n9\n</parameter>\n"
1590 + "</function>\n</tool_call>"
1591 + )
1592 + self.assertEqual(_first_tool_args(parse_response(model_out, template, prefix="")), {"hour": ["7", "9"]})
1593 + self.assertEqual(
1594 + _first_tool_args(parse_response(model_out, template, prefix="", tools=_SET_ALARM_TOOLS)),
1595 + {"hour": [7, 9]},
1596 + )
1597 + # Elements that don't cast, and non-string elements, are left as they are.
1598 + parser = _parser_with_tools(_SET_ALARM_TOOLS)
1599 + call: dict[str, Any] = {
1600 + "type": "function",
1601 + "function": {
1602 + "name": "set_alarm",
1603 + "arguments": {"hour": ["7", "x", 9]},
1604 + },
1605 + }
1606 + parser._coerce_tool_calls(call)
1607 + self.assertEqual(call["function"]["arguments"], {"hour": [7, "x", 9]})
1608 +
1609 + def test_already_decoded_array_is_not_cast_element_wise(self):
1610 + tools = _set_alarm_tools(groups={"type": "array", "items": {"type": "string"}})
1611 + call: dict[str, Any] = {
1612 + "type": "function",
1613 + "function": {
1614 + "name": "set_alarm",
1615 + "arguments": {"groups": ["[1,2]", "[]"]},
1616 + },
1617 + }
1618 + _parser_with_tools(tools)._coerce_tool_calls(call)
1619 + self.assertEqual(call["function"]["arguments"], {"groups": ["[1,2]", "[]"]})
1620 +
1621 + def test_coerce_tool_calls_ignores_unusable_function_name(self):
1622 + # A transform can hand us a name parsed from model output, so a non-string name
1623 + # must be ignored rather than raising on the schema lookup.
1624 + parser = _parser_with_tools(_SET_ALARM_TOOLS)
1625 + call: dict[str, Any] = {
1626 + "type": "function",
1627 + "function": {"name": ["set_alarm"], "arguments": {"hour": "7"}},
1628 + }
1629 + self.assertEqual(parser._coerce_tool_calls(call), call)
1630 + self.assertEqual(call["function"]["arguments"], {"hour": "7"})
1631 +
1632 + def test_union_with_container_still_keeps_scalar_text(self):
1633 + model_out = "<tool_call>\n<function=set_alarm>\n<parameter=label>\n1.50\n</parameter>\n</tool_call>"
1634 + union = _set_alarm_tools(label={"anyOf": [{"type": "string"}, {"type": "object"}]})
1635 + # `string` never casts, so the union keeps scalar-looking text as text ...
1636 + self.assertEqual(
1637 + _first_tool_args(parse_response(model_out, _XML_STRING_ARGS_TEMPLATE, prefix="", tools=union)),
1638 + {"label": "1.50"},
1639 + )
1640 + # ... while the `object` branch still decodes a body that really is an object.
1641 + object_body = '<tool_call>\n<function=set_alarm>\n<parameter=label>\n{"a": 1}\n</parameter>\n</tool_call>'
1642 + self.assertEqual(
1643 + _first_tool_args(parse_response(object_body, _XML_STRING_ARGS_TEMPLATE, prefix="", tools=union)),
1644 + {"label": {"a": 1}},
1645 + )
1646 +
1647 + def test_tools_is_a_noop_for_typed_json_tool_calls(self):
1648 + # JSON tool-call bodies already carry their types, so passing tools= must not change them.
1649 + model_out = (
1650 + "<|START_THINKING|>x<|END_THINKING|>"
1651 + '<|START_ACTION|>[{"tool_call_id": "0", "tool_name": "set_alarm", '
1652 + '"parameters": {"hour": 7, "enabled": true}}]<|END_ACTION|><|END_OF_TURN_TOKEN|>'
1653 + )
1654 + without = parse_response(model_out, cohere_template, prefix="")
1655 + with_tools = parse_response(model_out, cohere_template, prefix="", tools=_SET_ALARM_TOOLS)
1656 + self.assertEqual(without, with_tools)
1657 + self.assertEqual(_first_tool_args(with_tools), {"hour": 7, "enabled": True})
1658 +
1659 +
1660 +if __name__ == "__main__":
1661 + unittest.main()
1662 +# fmt: on
vllm/parser/chat_parsing/__init__.py new
+26
@@ -0,0 +1,26 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +# Copyright 2026 The HuggingFace Team. All rights reserved.
5 +# ruff: noqa
6 +# fmt: off
7 +#
8 +# Licensed under the Apache License, Version 2.0 (the "License");
9 +# you may not use this file except in compliance with the License.
10 +# You may obtain a copy of the License at
11 +#
12 +# http://www.apache.org/licenses/LICENSE-2.0
13 +#
14 +# Unless required by applicable law or agreed to in writing, software
15 +# distributed under the License is distributed on an "AS IS" BASIS,
16 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17 +# See the License for the specific language governing permissions and
18 +# limitations under the License.
19 +"""Response parsing: convert model-emitted text into the assistant-message
20 +dict used by chat templates, driven by a declarative `response_template` spec."""
21 +
22 +from .response_parser import ResponseParser, parse_response
23 +
24 +
25 +__all__ = ["ResponseParser", "parse_response"]
26 +# fmt: on
vllm/parser/chat_parsing/content_parsers.py new
+255
@@ -0,0 +1,255 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +# Copyright 2026 The HuggingFace Team. All rights reserved.
5 +# ruff: noqa
6 +# fmt: off
7 +#
8 +# Licensed under the Apache License, Version 2.0 (the "License");
9 +# you may not use this file except in compliance with the License.
10 +# You may obtain a copy of the License at
11 +#
12 +# http://www.apache.org/licenses/LICENSE-2.0
13 +#
14 +# Unless required by applicable law or agreed to in writing, software
15 +# distributed under the License is distributed on an "AS IS" BASIS,
16 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17 +# See the License for the specific language governing permissions and
18 +# limitations under the License.
19 +"""This file contains the parsers used by chat response parsing. Each parser takes a chunk of captured text
20 +and parses it into a single key in the output message dictionary. Functions are generally boilerplate."""
21 +
22 +from __future__ import annotations
23 +
24 +import json
25 +from typing import Any
26 +
27 +import regex as re
28 +
29 +
30 +def _text(text: str, args: dict) -> str:
31 + return text.strip() if args.get("strip", True) else text
32 +
33 +
34 +def _int(text: str, args: dict) -> int:
35 + return int(_text(text, args))
36 +
37 +
38 +def _float(text: str, args: dict) -> float:
39 + return float(_text(text, args))
40 +
41 +
42 +def _bool(text: str, args: dict) -> bool:
43 + return _text(text, args).lower() in ("true", "1")
44 +
45 +
46 +# Sentinel characters for lax-JSON string pre-extraction — ASCII control chars
47 +# that should never appear in real LLM output.
48 +_LAX_OPEN, _LAX_CLOSE = "\x01", "\x02"
49 +
50 +
51 +def _json(text: str, args: dict) -> Any:
52 + """JSON parser with optional dialect knobs for LLM-emitted quirks.
53 +
54 + `args`:
55 + - `unquoted_keys` (bool): quote bare-identifier keys before parsing.
56 + - `string_delims` ([[open, close], ...]): strings delimited by these
57 + custom markers are pre-extracted, then restored as standard JSON strings.
58 + - `allow_non_json` (bool): return stripped text if parsing fails.
59 + """
60 + string_delims = args.get("string_delims", [])
61 + unquoted_keys = args.get("unquoted_keys", False)
62 +
63 + if string_delims and (_LAX_OPEN in text or _LAX_CLOSE in text):
64 + raise ValueError("json: input contains reserved sentinel characters (\\x01/\\x02); cannot parse safely.")
65 +
66 + working = text
67 + captured: list[str] = []
68 + for open_d, close_d in string_delims:
69 + pattern = re.escape(open_d) + r"(.*?)" + re.escape(close_d)
70 +
71 + def _capture(m: Any) -> str:
72 + captured.append(m.group(1))
73 + return f"{_LAX_OPEN}{len(captured) - 1}{_LAX_CLOSE}"
74 +
75 + working = re.sub(pattern, _capture, working, flags=re.DOTALL)
76 +
77 + if unquoted_keys:
78 + working = re.sub(r"(?<=[{,])(\w+):", r'"\1":', working)
79 +
80 + for i, s in enumerate(captured):
81 + working = working.replace(f"{_LAX_OPEN}{i}{_LAX_CLOSE}", json.dumps(s))
82 +
83 + try:
84 + return json.loads(working)
85 + except json.JSONDecodeError as e:
86 + if args.get("allow_non_json"):
87 + return _text(text, args)
88 + if working == text:
89 + raise ValueError(f"json parser could not parse region as JSON.\nContent: {text!r}\nError: {e}") from e
90 + raise ValueError(
91 + f"json: could not parse after dialect transforms.\n"
92 + f"Original: {text!r}\nTransformed: {working!r}\nError: {e}"
93 + ) from e
94 +
95 +
96 +def _sub_parse(raw: str, value_parser: dict | None) -> Any:
97 + if value_parser is None:
98 + return raw
99 + return parse_content(raw, value_parser.get("name", "text"), value_parser.get("args", {}))
100 +
101 +
102 +def _xml_inline(text: str, args: dict) -> dict:
103 + """Parse shallow XML-ish tags into a dict. `tag_pattern` regex must have named
104 + groups `key` and `value`. Optional `value_parser` recurses; `merge_duplicates`
105 + collects duplicate keys into a list."""
106 + tag_pattern = args.get("tag_pattern")
107 + if tag_pattern is None:
108 + raise ValueError("xml-inline: 'tag_pattern' content_arg is required")
109 + value_parser = args.get("value_parser")
110 + merge = args.get("merge_duplicates", False)
111 +
112 + out: dict[str, Any] = {}
113 + for m in re.finditer(tag_pattern, text, flags=re.DOTALL):
114 + groups = m.groupdict()
115 + key = groups.get("key")
116 + if key is None:
117 + raise ValueError(f"xml-inline: tag_pattern must have a named group 'key'. Pattern: {tag_pattern}")
118 + value = _sub_parse(groups.get("value", ""), value_parser)
119 + if key in out and merge:
120 + if not isinstance(out[key], list):
121 + out[key] = [out[key]]
122 + out[key].append(value)
123 + else:
124 + out[key] = value
125 + return out
126 +
127 +
128 +def _kv_lines(text: str, args: dict) -> dict:
129 + """Parse line-delimited `key<sep>value` pairs into a dict."""
130 + line_sep = args.get("line_sep", "\n")
131 + kv_sep = args.get("kv_sep", ":")
132 + value_parser = args.get("value_parser")
133 +
134 + out: dict[str, Any] = {}
135 + for line in text.split(line_sep):
136 + line = _text(line, args)
137 + if not line or kv_sep not in line:
138 + continue
139 + k, v = line.split(kv_sep, 1)
140 + k, v = _text(k, args), _text(v, args)
141 + out[k] = _sub_parse(v, value_parser)
142 + return out
143 +
144 +
145 +CONTENT_PARSERS = {
146 + "text": _text,
147 + "int": _int,
148 + "float": _float,
149 + "bool": _bool,
150 + "json": _json,
151 + "xml-inline": _xml_inline,
152 + "kv-lines": _kv_lines,
153 +}
154 +
155 +# Parsers whose output is the verbatim body text (modulo whitespace) — chunks
156 +# from these fields stream with `dirty=False` because each chunk is part of
157 +# the final value. Structured parsers (`json`, `xml-inline`, `kv-lines`) only
158 +# produce a meaningful value on close, so their chunks stream raw bytes
159 +# flagged `dirty=True` while the parsed value is delivered in `region_close`.
160 +STREAMABLE_PARSERS = frozenset({"text", "int", "float", "bool"})
161 +
162 +
163 +def parse_content(text: str, name: str, args: dict) -> Any:
164 + return CONTENT_PARSERS[name](text, args)
165 +
166 +
167 +_PLACEHOLDER = re.compile(r"\{(\w+(?:\.\w+)*)\}")
168 +
169 +
170 +def _apply_transform(transform: Any, scope: dict) -> Any:
171 + """Recursively walk a transform template, which is used to restructure
172 + parsed output into the actual shape we want. A dotted placeholder like
173 + `{content.args}` descends into keys of the looked-up value."""
174 + if isinstance(transform, dict):
175 + return {k: _apply_transform(v, scope) for k, v in transform.items()}
176 + if isinstance(transform, list):
177 + return [_apply_transform(v, scope) for v in transform]
178 + if not isinstance(transform, str):
179 + return transform
180 + whole = _PLACEHOLDER.fullmatch(transform)
181 + if not whole:
182 + return transform
183 + path = whole.group(1)
184 + root, *keys = path.split(".")
185 + if root not in scope:
186 + raise KeyError(f"transform placeholder '{{{path}}}' is not defined. Available: {sorted(scope)}")
187 + value = scope[root]
188 + for key in keys:
189 + if not isinstance(value, dict):
190 + raise ValueError(f"transform placeholder '{{{path}}}' cannot index into {type(value).__name__} at '{key}'")
191 + if key not in value:
192 + raise ValueError(f"transform placeholder '{{{path}}}' is missing key '{key}'. Available: {sorted(value)}")
193 + value = value[key]
194 + return value
195 +
196 +
197 +def validate_transform_strings(scope: str, transform: Any) -> None:
198 + """Walk a transform template and reject any string that mixes a `{name}`
199 + placeholder with literal text. Only whole-string placeholders (e.g.
200 + `"{content}"`) and plain literals are supported. Called from the template
201 + loader so authors get a clear error at load time, not at parse time."""
202 + if isinstance(transform, dict):
203 + for v in transform.values():
204 + validate_transform_strings(scope, v)
205 + return
206 + if isinstance(transform, list):
207 + for v in transform:
208 + validate_transform_strings(scope, v)
209 + return
210 + if not isinstance(transform, str):
211 + return
212 + if _PLACEHOLDER.search(transform) and not _PLACEHOLDER.fullmatch(transform):
213 + raise ValueError(
214 + f"{scope}: transform string {transform!r} mixes a {{placeholder}} with literal text. "
215 + 'Use either a whole-string placeholder (e.g. "{content}") or a plain literal; '
216 + "string interpolation is not supported."
217 + )
218 +
219 +
220 +def process_field(body: str, field, captures: dict) -> Any:
221 + """Run `body` through the field's content parser, then optionally apply the
222 + transform template. When `transform_each` is set, the parsed content must
223 + be a list and the template is applied to each element (with the element's
224 + keys unpacked into the template scope, alongside any regex captures).
225 +
226 + `field` is a `spec.Field`; typed via duck-typing to avoid a cyclic import."""
227 + value = parse_content(body, field.content, field.content_args)
228 + if field.transform is None:
229 + return value
230 + if field.transform_each:
231 + if not isinstance(value, list):
232 + raise ValueError(
233 + f"Field '{field.name}': transform_each requires the parsed content to be a list, "
234 + f"got {type(value).__name__}."
235 + )
236 + out = []
237 + for item in value:
238 + if not isinstance(item, dict):
239 + raise ValueError(
240 + f"Field '{field.name}': transform_each requires each list element to be a dict, "
241 + f"got {type(item).__name__}."
242 + )
243 + out.append(_apply_transform(field.transform, {**captures, **item}))
244 + return out
245 + return _apply_transform(field.transform, {**captures, "content": value})
246 +
247 +
248 +__all__ = [
249 + "CONTENT_PARSERS",
250 + "STREAMABLE_PARSERS",
251 + "parse_content",
252 + "process_field",
253 + "validate_transform_strings",
254 +]
255 +# fmt: on
vllm/parser/chat_parsing/response_parser.py new
+408
@@ -0,0 +1,408 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +# Copyright 2026 The HuggingFace Team. All rights reserved.
5 +# ruff: noqa
6 +# fmt: off
7 +#
8 +# Licensed under the Apache License, Version 2.0 (the "License");
9 +# you may not use this file except in compliance with the License.
10 +# You may obtain a copy of the License at
11 +#
12 +# http://www.apache.org/licenses/LICENSE-2.0
13 +#
14 +# Unless required by applicable law or agreed to in writing, software
15 +# distributed under the License is distributed on an "AS IS" BASIS,
16 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17 +# See the License for the specific language governing permissions and
18 +# limitations under the License.
19 +
20 +from __future__ import annotations
21 +
22 +import copy
23 +import json
24 +from typing import Any
25 +
26 +from .content_parsers import STREAMABLE_PARSERS, process_field
27 +from .response_templates import (
28 + ResponseTemplate,
29 + ResponseTemplateField,
30 + load_response_template,
31 +)
32 +
33 +
34 +def _schema_types(schema: Any) -> tuple[str, ...]:
35 + if not isinstance(schema, dict):
36 + return ()
37 + declared = schema.get("type")
38 + types = [declared] if isinstance(declared, str) else []
39 + if isinstance(declared, list):
40 + types.extend(t for t in declared if isinstance(t, str))
41 + for union_name in ("anyOf", "oneOf"):
42 + for choice in schema.get(union_name) or []:
43 + types.extend(_schema_types(choice))
44 + if schema.get("nullable") and "null" not in types: # `nullable` is how get_json_schema marks Optionals
45 + types.append("null")
46 + return tuple(types)
47 +
48 +
49 +def _coerce(raw: str, types: tuple[str, ...]) -> Any:
50 + for type_name in types:
51 + try:
52 + if type_name == "integer":
53 + return int(raw)
54 + if type_name == "number":
55 + number = float(raw)
56 + if number != number or number in (float("inf"), float("-inf")):
57 + continue # NaN / inf are not valid JSON numbers
58 + # Preserve ints when the source text had no fractional part.
59 + return int(number) if number.is_integer() and "." not in raw else number
60 + if type_name == "boolean" and raw.strip().lower() in ("true", "1", "false", "0"):
61 + return raw.strip().lower() in ("true", "1")
62 + if type_name == "null" and raw.strip() in ("null", "None"):
63 + return None
64 + if type_name in ("object", "array"):
65 + decoded = json.loads(raw)
66 + if isinstance(decoded, dict if type_name == "object" else list):
67 + return decoded
68 + except ValueError:
69 + continue
70 + return raw # `string` params, unknown types and failed casts all keep the original text
71 +
72 +
73 +def parse_response(
74 + text: str,
75 + response_template: dict | ResponseTemplate,
76 + *,
77 + prefix: str | None = None,
78 + tools: list[dict] | None = None,
79 +) -> dict:
80 + """The main function for response parsing when you don't want streaming. Takes generated output
81 + and the prompt prefix and parses them without streaming any events, then returns the parsed message.
82 +
83 + Pass OpenAI-style `tools` dictionaries to cast tool-call arguments
84 + using the calling tool's JSON schema.
85 + """
86 + response_template = load_response_template(response_template)
87 + stream = ResponseParser(response_template, prefix=prefix, tools=tools)
88 + stream.feed(text)
89 + message, _ = stream.finalize()
90 + return message
91 +
92 +
93 +class ResponseParser:
94 + """This class implements a streaming parser with a `response_template`. If you don't need streaming and
95 + just want to parse a complete message, use the `parse_response` function above. Streaming parsing emits
96 + events indicating when regions (message fields) are opened and closed, with the model writing to the region
97 + that is currently open.
98 +
99 + Usage:
100 + parser = ResponseParser(response_template, prefix=chat_prompt)
101 + for event in parser.initial_events:
102 + handle(event)
103 + for chunk in model_text_stream:
104 + for event in parser.feed(chunk):
105 + handle(event)
106 + message, final_events = parser.finalize()
107 + for event in final_events:
108 + handle(event)
109 +
110 + Pass OpenAI-style `tools=` dictionaries to cast tool-call arguments
111 + using the calling tool's JSON schema as each region closes.
112 +
113 + Events can be either "region_open", "region_chunk", or "region_close".
114 +
115 + ResponseParser requires the chat `prefix` (i.e. the chat history, the prefill before the current generation).
116 + This is because chat templates or assistant prefills can sometimes write part of the message, and if we
117 + only see the model output, and not the template, then we can't reliably parse the message in those cases.
118 + Any events produced while consuming the prefix are exposed as `initial_events`, so renderers can show
119 + prefill regions before the model writes anything; closed prefill regions also land in the output dict.
120 + """
121 +
122 + def __init__(
123 + self,
124 + response_template: dict | ResponseTemplate,
125 + prefix: str | None = None,
126 + *,
127 + tools: list[dict] | None = None,
128 + ):
129 + self._spec = load_response_template(response_template)
130 + if prefix is None:
131 + raise ValueError(
132 + "`ResponseParser`/`parse_response` requires `prefix` (the chat prompt sent to the model before "
133 + "generation), because chat templates often pre-write part of the assistant message (e.g. an "
134 + "opening `<think>` tag) that the parser must see to parse the output correctly. If the generation "
135 + 'already contains the complete message, pass `prefix=""` to opt out explicitly.'
136 + )
137 + # Maps tool name -> schema `properties`, used to cast parsed tool-call arguments
138 + self._tool_params: dict[str, dict] = {}
139 + for tool in tools or []:
140 + fn = tool.get("function", tool) if isinstance(tool, dict) else None
141 + if isinstance(fn, dict) and isinstance(fn.get("name"), str):
142 + parameters = fn.get("parameters")
143 + properties = parameters.get("properties") if isinstance(parameters, dict) else None
144 + self._tool_params[fn["name"]] = properties if isinstance(properties, dict) else {}
145 + self._buffer: str = ""
146 + self._pos: int = 0
147 + self._output: dict[str, Any] = copy.deepcopy(self._spec.defaults)
148 + self._implicit_name: str | None = self._spec.implicit
149 + # Unified current-region state: starts in the implicit region (or a
150 + # null sink if none was declared), and returns there after every close.
151 + # For explicit regions `_opened` flips to True eagerly on the open
152 + # match; for the implicit region it flips lazily on the first byte.
153 + self._current: str | None = self._implicit_name
154 + self._captures: dict[str, str] = {}
155 + self._body: str = ""
156 + self._opened: bool = False
157 + self._finalized: bool = False
158 + self.initial_events: list[dict] = []
159 + if prefix:
160 + self._consume_prefix(prefix)
161 +
162 + def _consume_prefix(self, prefix: str) -> None:
163 + """Loads the prefix (the chat prefill sent to the model), right-truncates it to the start of the
164 + assistant message (as determined by start_anchor) and then runs the remainder through the parser.
165 + Events produced while processing the prefix are stashed on `initial_events` so callers can replay
166 + them into a renderer before feeding model output.
167 +
168 + Think of this as the "get the parser up to speed on the story so far" method.
169 + """
170 + truncated = self._spec.truncate_past_last_anchor(prefix)
171 + if not truncated:
172 + return
173 + self._buffer = truncated
174 + self._process(self.initial_events, eos=False)
175 +
176 + def feed(self, text: str) -> list[dict]:
177 + """Feeds more text/tokens from the model output into the tokenizer, and returns any events that result
178 + (regions entered or left). This is the method you want to call after each generation step."""
179 + if self._finalized:
180 + raise RuntimeError("ResponseParser already finalized")
181 + if text:
182 + self._buffer += text
183 + events: list[dict] = []
184 + self._process(events, eos=False)
185 + return events
186 +
187 + def finalize(self) -> tuple[dict, list[dict]]:
188 + """Close the stream and return the final message dict together with
189 + any finalization events. This is necessary because some regions may only
190 + end at the end of the sequence, so you won't see the event telling you they're
191 + ready until the sequence is finalized."""
192 +
193 + def _is_empty(v: Any) -> bool:
194 + return v is None or (isinstance(v, (list, dict, str)) and not v)
195 +
196 + if self._finalized:
197 + raise RuntimeError("ResponseParser already finalized")
198 + events: list[dict] = []
199 + self._process(events, eos=True)
200 + missing = [n for n, f in self._spec.fields.items() if not f.optional and n not in self._output]
201 + if missing:
202 + raise ValueError(f"Required response_template fields missing from parsed output: {missing}")
203 + defaults = self._spec.defaults
204 + self._output = {k: v for k, v in self._output.items() if k in defaults or not _is_empty(v)}
205 + self._finalized = True
206 + return self._output, events
207 +
208 + def _process(self, events: list[dict], eos: bool) -> None:
209 + while True:
210 + watch = self._watchlist()
211 + best, hold_start = self._scan(watch, eos)
212 +
213 + if best is not None:
214 + kind, field, m = best
215 + if m.start() > self._pos:
216 + self._accumulate(events, self._buffer[self._pos : m.start()])
217 + self._pos = m.end()
218 + if kind == "open":
219 + self._close_current(events)
220 + self._open_explicit(events, field, m)
221 + else: # "close" (always the implicit region's close here,
222 + # since explicit regions only expose their own close)
223 + had_content = self._opened
224 + self._close_current(events)
225 + # Zero-width close on an already-empty region would just
226 + # re-fire next iteration -- bail out to make progress.
227 + if not had_content and m.start() == m.end():
228 + break
229 + continue
230 +
231 + # No committable match in the current buffer.
232 + if eos:
233 + if self._pos < len(self._buffer):
234 + self._accumulate(events, self._buffer[self._pos :])
235 + self._pos = len(self._buffer)
236 + self._close_current(events)
237 + break
238 + # Stream everything up to the earliest still-pending delimiter. When
239 + # nothing is pending `hold_start == len(self._buffer)`, so this flushes
240 + # the whole buffer; otherwise we hold the (possibly partial) delimiter
241 + # bytes back until more input resolves them.
242 + if hold_start > self._pos:
243 + self._accumulate(events, self._buffer[self._pos : hold_start])
244 + self._pos = hold_start
245 + break
246 +
247 + def _watchlist(self) -> list[tuple[str, ResponseTemplateField]]:
248 + """Patterns we care about right now: the close of the currently-open
249 + explicit region, or -- if we're in the implicit/null region -- every
250 + explicit open plus the implicit's own close (if any)."""
251 + if self._current is not None and self._current != self._implicit_name:
252 + field = self._spec.fields[self._current]
253 + return [("close", field)] if field.close_re is not None else []
254 + watch: list[tuple[str, ResponseTemplateField]] = []
255 + for field in self._spec.fields.values():
256 + if field.open_re is not None:
257 + watch.append(("open", field))
258 + if self._implicit_name is not None:
259 + impl = self._spec.fields[self._implicit_name]
260 + if impl.close_re is not None:
261 + watch.append(("close", impl))
262 + return watch
263 +
264 + def _scan(
265 + self, watch: list[tuple[str, ResponseTemplateField]], eos: bool
266 + ) -> tuple[tuple[str, ResponseTemplateField, Any] | None, int]:
267 + """Single pass over the watched delimiters, using the `regex` module's
268 + partial matching to decide -- per delimiter -- whether it can be committed
269 + now or must be held. Returns `(best, hold_start)`:
270 +
271 + * `best` is the earliest-starting delimiter we can safely commit *now*
272 + (longest on ties, opens before closes), or `None`.
273 + * `hold_start` is the leftmost buffer position occupied by a still-pending
274 + match: a partial (incomplete) delimiter, or a complete one ending at the
275 + buffer edge that more input could still grow. Bytes before it are safe to
276 + emit; bytes from it onward must be held. It stays `len(self._buffer)` when
277 + nothing is pending, letting the caller flush the whole buffer.
278 +
279 + A complete match is committable only if it starts strictly before
280 + `hold_start` -- otherwise an earlier (or co-located) pending delimiter could
281 + turn out to be the real one. At EOS nothing can grow, so partial matching is
282 + skipped and every complete match is committable.
283 +
284 + (The `regex` module always reports the empty string as a live prefix, so a
285 + partial search with no real match returns a zero-width match at the buffer
286 + end; that lands in the pending branch with `start == len(self._buffer)`, a
287 + no-op for `hold_start`.)
288 + """
289 + best_key: tuple | None = None
290 + best: tuple[str, ResponseTemplateField, Any] | None = None
291 + hold_start = len(self._buffer)
292 + for kind, field in watch:
293 + # The watchlist only includes fields whose delimiter regex is set, so pattern is never None here.
294 + pattern = field.open_re if kind == "open" else field.close_re
295 + if eos:
296 + m = pattern.search(self._buffer, self._pos)
297 + else:
298 + m = pattern.search(self._buffer, self._pos, partial=True)
299 + if m is None:
300 + continue
301 + if not eos and (m.partial or self._can_grow(kind, field, m)):
302 + # Pending: can't commit, and blocks emitting from its start onward.
303 + hold_start = min(hold_start, m.start())
304 + continue
305 + key = (m.start(), -(m.end() - m.start()), 0 if kind == "open" else 1, field.name)
306 + if best_key is None or key < best_key:
307 + best_key, best = key, (kind, field, m)
308 + # A committable match co-located with or after a pending one must wait too:
309 + # the pending delimiter starts no later and might be the one that fires.
310 + if best is not None and best[2].start() >= hold_start:
311 + best = None
312 + return best, hold_start
313 +
314 + def _can_grow(self, kind: str, field: ResponseTemplateField, m: Any) -> bool:
315 + r"""Whether a *complete* match ending at the current buffer edge could still
316 + change as more input arrives -- in which case we defer rather than commit. A
317 + match ending before the edge has already seen its terminating byte and is
318 + final. At the edge: zero-width matches (`$` / `\Z`) are only real at true
319 + EOS; a fully-present literal that no other literal in its set extends cannot
320 + grow (the fast path that keeps literal delimiters zero-latency); anything
321 + else (regex delimiters, prefix-overlapping literal lists) might."""
322 + if m.end() != len(self._buffer):
323 + return False
324 + if m.start() == m.end():
325 + return True
326 + literals, can_extend = (
327 + (field.open_literals, field.open_literal_can_extend)
328 + if kind == "open"
329 + else (field.close_literals, field.close_literal_can_extend)
330 + )
331 + return literals is None or can_extend
332 +
333 + def _accumulate(self, events: list[dict], text: str) -> None:
334 + """Route `text` into the currently active region. When the current
335 + region is the null sink (no implicit declared, no explicit open), we
336 + silently discard. Every routed chunk emits a `region_chunk` event so
337 + consumers can render live; `dirty=True` flags chunks from structured
338 + parsers (json, xml-inline, kv-lines) whose raw bytes will only be
339 + parsed into the final value on close."""
340 + if not text or self._current is None:
341 + return
342 + field = self._spec.fields[self._current]
343 + if not self._opened:
344 + events.append({"type": "region_open", "field": self._current})
345 + self._opened = True
346 + self._body += text
347 + dirty = field.content not in STREAMABLE_PARSERS
348 + events.append({"type": "region_chunk", "field": self._current, "text": text, "dirty": dirty})
349 +
350 + def _open_explicit(self, events: list[dict], field: ResponseTemplateField, m: Any) -> None:
351 + self._current = field.name
352 + self._captures = {k: v for k, v in m.groupdict().items() if v is not None}
353 + self._body = ""
354 + self._opened = True
355 + events.append({"type": "region_open", "field": field.name})
356 +
357 + def _close_current(self, events: list[dict]) -> None:
358 + """Close the current region and reset to the implicit/null region.
359 + Skipped (aside from the reset) when the current region never opened --
360 + avoids vacuous open/close pairs at every explicit boundary."""
361 + if self._current is None or not self._opened:
362 + self._reset_to_implicit()
363 + return
364 + field = self._spec.fields[self._current]
365 + value = process_field(self._body, field, self._captures)
366 + if self._tool_params:
367 + value = self._coerce_tool_calls(value)
368 + if field.join is not None:
369 + if not isinstance(value, str):
370 + raise ValueError(
371 + f"Field '{field.name}': 'join' requires each match to parse to a string, "
372 + f"got {type(value).__name__}."
373 + )
374 + previous = self._output.get(self._current)
375 + self._output[self._current] = value if previous is None else previous + field.join + value
376 + elif field.repeats:
377 + self._output.setdefault(self._current, []).append(value)
378 + else:
379 + self._output[self._current] = value
380 + events.append({"type": "region_close", "field": self._current, "value": value})
381 + self._reset_to_implicit()
382 +
383 + def _coerce_tool_calls(self, value: Any) -> Any:
384 + if isinstance(value, list):
385 + return [self._coerce_tool_calls(item) for item in value]
386 + fn = value.get("function") if isinstance(value, dict) else None
387 + if not isinstance(fn, dict):
388 + return value
389 + name, arguments = fn.get("name"), fn.get("arguments")
390 + if not isinstance(name, str) or not isinstance(arguments, dict):
391 + return value
392 + if properties := self._tool_params.get(name):
393 + for key, argument in arguments.items():
394 + if key not in properties or not (types := _schema_types(properties[key])):
395 + continue
396 + if isinstance(argument, str):
397 + arguments[key] = _coerce(argument, types)
398 + elif isinstance(argument, list) and "array" not in types:
399 + # duplicate keys collected by `merge_duplicates`
400 + arguments[key] = [_coerce(item, types) if isinstance(item, str) else item for item in argument]
401 + return value
402 +
403 + def _reset_to_implicit(self) -> None:
404 + self._current = self._implicit_name
405 + self._captures = {}
406 + self._body = ""
407 + self._opened = False
408 +# fmt: on
vllm/parser/chat_parsing/response_templates.py new
+249
@@ -0,0 +1,249 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +# Copyright 2026 The HuggingFace Team. All rights reserved.
5 +# ruff: noqa
6 +# fmt: off
7 +#
8 +# Licensed under the Apache License, Version 2.0 (the "License");
9 +# you may not use this file except in compliance with the License.
10 +# You may obtain a copy of the License at
11 +#
12 +# http://www.apache.org/licenses/LICENSE-2.0
13 +#
14 +# Unless required by applicable law or agreed to in writing, software
15 +# distributed under the License is distributed on an "AS IS" BASIS,
16 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17 +# See the License for the specific language governing permissions and
18 +# limitations under the License.
19 +"""Template loading and validation for response_template dicts."""
20 +
21 +from __future__ import annotations
22 +
23 +import copy
24 +import logging
25 +from dataclasses import dataclass
26 +from typing import Any
27 +
28 +import regex as re
29 +
30 +from .content_parsers import CONTENT_PARSERS, validate_transform_strings
31 +
32 +
33 +logger = logging.getLogger(__name__)
34 +
35 +
36 +@dataclass
37 +class ResponseTemplateField:
38 + name: str
39 + open_re: Any
40 + open_literals: list[str] | None
41 + open_literal_can_extend: bool
42 + close_re: Any
43 + close_literals: list[str] | None
44 + close_literal_can_extend: bool
45 + content: str
46 + content_args: dict
47 + repeats: bool
48 + join: str | None
49 + optional: bool
50 + transform: Any
51 + transform_each: bool
52 +
53 +
54 +@dataclass
55 +class ResponseTemplate:
56 + defaults: dict
57 + fields: dict[str, ResponseTemplateField]
58 + start_anchor_re: Any
59 + implicit: str | None = None
60 + start_anchor_literals: list[str] | None = None
61 +
62 + def truncate_past_last_anchor(self, text: str) -> str:
63 + """When parsing responses, we can't just parse the tokens generated
64 + by the model. This is because chat templates often include early parts of the message such as <start_of_turn>
65 + or <think> tokens in the prefill. Therefore, we take the whole chat as input, but truncate to the start
66 + of the most recent assistant message, which can include tokens from the template as well as the output."""
67 + last_end: int | None = None
68 + for m in self.start_anchor_re.finditer(text):
69 + last_end = m.end()
70 + if last_end is None:
71 + kind = "start_anchor" if self.start_anchor_literals is not None else "start_anchor_pattern"
72 + logger.info(
73 + "response_template defines %s but the anchor was not found "
74 + "in the prefix; the parser will process the entire prefix instead.",
75 + kind,
76 + )
77 + return text
78 + return text[last_end:]
79 +
80 +
81 +def _compile_anchor(scope: str, field: dict, literal_key: str, pattern_key: str) -> tuple[Any, list[str] | None, bool]:
82 + """Compile a region's start/end anchor. An anchor can be:
83 +
84 + - a regex (`pattern_key`),
85 + - a single literal string (`literal_key`), or
86 + - a list of literal strings (`literal_key`), in which case any of these match
87 +
88 + All forms compile to a regex for matching, but we keep the literal strings around too: when present,
89 + the parser can emit regions faster, since it knows exactly how many trailing bytes might be a
90 + partial-match prefix (and that a finished literal match can never be extended by future bytes, except
91 + when a literal is a strict prefix of another in the same list).
92 +
93 + Returns `(compiled_re, literals, can_extend)`:
94 +
95 + - `compiled_re`: the compiled pattern.
96 + - `literals`: the literal strings, or `None` for a regex anchor.
97 + - `can_extend`: `True` iff one literal is a strict prefix of another, in which case a match at the
98 + buffer edge could still be lengthened by future input.
99 + """
100 + if literal_key in field and pattern_key in field:
101 + raise ValueError(f"{scope}: cannot specify both '{literal_key}' and '{pattern_key}'")
102 + if literal_key in field:
103 + raw = field[literal_key]
104 + if isinstance(raw, str):
105 + literals = [raw]
106 + elif isinstance(raw, list):
107 + if not raw:
108 + raise ValueError(f"{scope}: '{literal_key}' list must contain at least one literal")
109 + if not all(isinstance(s, str) for s in raw):
110 + raise ValueError(f"{scope}: '{literal_key}' list must contain only strings")
111 + literals = list(dict.fromkeys(raw)) # dedupe, preserve first-seen order
112 + else:
113 + raise ValueError(f"{scope}: '{literal_key}' must be a string or list of strings, got {type(raw).__name__}")
114 + if any(s == "" for s in literals):
115 + raise ValueError(f"{scope}: '{literal_key}' literals cannot be empty strings")
116 + # Sort longest-first so alternation prefers the longer alternative when both could match.
117 + ordered = sorted(literals, key=len, reverse=True)
118 + can_extend = any(a != b and a.startswith(b) for a in literals for b in literals)
119 + pattern = "|".join(re.escape(s) for s in ordered)
120 + return re.compile(pattern, re.DOTALL), literals, can_extend
121 + if pattern_key in field:
122 + try:
123 + return re.compile(field[pattern_key], re.DOTALL), None, False
124 + except re.error as e:
125 + raise ValueError(f"{scope}: invalid {pattern_key} regex: {e}") from e
126 + return None, None, False
127 +
128 +
129 +def _validate_template_shape(spec: dict) -> None:
130 + """Validate the top-level response_template structure: it must be a dict of the expected version with
131 + only known keys, a dict of `defaults`, and a non-empty dict of `fields`. Per-field specs are checked
132 + separately in `_build_field`."""
133 + if not isinstance(spec, dict):
134 + raise ValueError(f"response_template must be a dict, got {type(spec).__name__}")
135 + version = spec.get("version", 1)
136 + if version != 1:
137 + raise ValueError(f"Unsupported response_template version: {version}")
138 + if unknown_template_keys := set(spec) - {"version", "defaults", "fields", "start_anchor", "start_anchor_pattern"}:
139 + raise ValueError(f"Unknown keys in response_template: {sorted(unknown_template_keys)}")
140 + if not isinstance(spec.get("defaults", {}), dict):
141 + raise ValueError("response_template.defaults must be a dict")
142 + fields_raw = spec.get("fields", {})
143 + if not isinstance(fields_raw, dict) or not fields_raw:
144 + raise ValueError("response_template.fields must be a non-empty dict")
145 +
146 +
147 +def _build_field(name: str, field: dict) -> ResponseTemplateField:
148 + """Validate a single field spec and compile it into a `ResponseTemplateField`."""
149 +
150 + _ALLOWED_FIELD_KEYS = {
151 + "open",
152 + "open_pattern",
153 + "close",
154 + "close_pattern",
155 + "content",
156 + "content_args",
157 + "repeats",
158 + "join",
159 + "optional",
160 + "transform",
161 + "transform_each",
162 + }
163 + scope = f"Field '{name}'"
164 + if not isinstance(field, dict):
165 + raise ValueError(f"{scope} must be a dict")
166 + if unknown_field_keys := set(field) - _ALLOWED_FIELD_KEYS:
167 + raise ValueError(f"{scope}: unknown keys {sorted(unknown_field_keys)}")
168 + content = field.get("content", "text")
169 + if content not in CONTENT_PARSERS:
170 + raise ValueError(f"{scope}: unknown content parser '{content}'. Available: {sorted(CONTENT_PARSERS)}")
171 + open_re, open_literals, open_literal_can_extend = _compile_anchor(scope, field, "open", "open_pattern")
172 + close_re, close_literals, close_literal_can_extend = _compile_anchor(scope, field, "close", "close_pattern")
173 + join = field.get("join")
174 + if join is not None and not isinstance(join, str):
175 + raise ValueError(f"{scope}: 'join' must be a string, got {type(join).__name__}")
176 + if join is not None and not field.get("repeats", False):
177 + raise ValueError(f"{scope}: 'join' requires 'repeats': true")
178 + transform = field.get("transform")
179 + transform_each = field.get("transform_each", False)
180 + if not isinstance(transform_each, bool):
181 + raise ValueError(f"{scope}: transform_each must be a bool, got {type(transform_each).__name__}")
182 + if transform_each and transform is None:
183 + raise ValueError(f"{scope}: transform_each is set but no transform was provided")
184 + if transform is not None:
185 + validate_transform_strings(scope, transform)
186 + else:
187 + # Named captures only reach the output through a transform, so flag any that would be silently dropped.
188 + captured_names = set()
189 + if open_re is not None:
190 + captured_names |= set(open_re.groupindex)
191 + if close_re is not None:
192 + captured_names |= set(close_re.groupindex)
193 + if captured_names:
194 + raise ValueError(
195 + f"{scope}: open_pattern/close_pattern declares named group(s) "
196 + f"{sorted(captured_names)}, but the field has no 'transform'. Named captures "
197 + f"are only surfaced through a 'transform' template (where they appear "
198 + f"alongside 'content'). Either add a 'transform' that uses the captures, or "
199 + f"remove the named groups from the pattern."
200 + )
201 + return ResponseTemplateField(
202 + name=name,
203 + open_re=open_re,
204 + open_literals=open_literals,
205 + open_literal_can_extend=open_literal_can_extend,
206 + close_re=close_re,
207 + close_literals=close_literals,
208 + close_literal_can_extend=close_literal_can_extend,
209 + content=content,
210 + content_args=field.get("content_args", {}),
211 + repeats=field.get("repeats", False),
212 + join=join,
213 + optional=field.get("optional", True),
214 + transform=transform,
215 + transform_each=transform_each,
216 + )
217 +
218 +
219 +def load_response_template(spec: dict | ResponseTemplate) -> ResponseTemplate:
220 + if isinstance(spec, ResponseTemplate):
221 + return spec
222 + _validate_template_shape(spec)
223 +
224 + fields = {name: _build_field(name, raw) for name, raw in spec["fields"].items()}
225 + # A field without an open anchor is the implicit-open / leftover sink; at most one is allowed.
226 + implicit_fields = [name for name, field in fields.items() if field.open_re is None]
227 + if len(implicit_fields) > 1:
228 + raise ValueError(
229 + "At most one field may omit 'open'/'open_pattern' (that field becomes the "
230 + f"implicit-open / leftover sink). Found: {', '.join(implicit_fields)}"
231 + )
232 +
233 + start_anchor_re, start_anchor_literals, _ = _compile_anchor(
234 + "response_template", spec, "start_anchor", "start_anchor_pattern"
235 + )
236 + if start_anchor_re is None:
237 + raise ValueError("response_template must define 'start_anchor' or 'start_anchor_pattern'.")
238 +
239 + return ResponseTemplate(
240 + defaults=copy.deepcopy(spec.get("defaults", {})),
241 + fields=fields,
242 + implicit=implicit_fields[0] if implicit_fields else None,
243 + start_anchor_re=start_anchor_re,
244 + start_anchor_literals=start_anchor_literals,
245 + )
246 +
247 +
248 +__all__ = ["ResponseTemplate", "ResponseTemplateField", "load_response_template"]
249 +# fmt: on