main
py 642 lines 22.9 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Unified vLLM parser backed by checkpoint `response_template` metadata."""
4
5 from __future__ import annotations
6
7 import copy
8 import json
9 from collections.abc import Callable, Iterable, Sequence
10 from typing import TYPE_CHECKING, Any, cast
11
12 from openai.types.responses import ToolChoiceFunction
13
14 from vllm.entrypoints.chat_utils import make_tool_call_id
15 from vllm.entrypoints.generate.base.protocol import (
16 DeltaFunctionCall,
17 DeltaMessage,
18 DeltaToolCall,
19 FunctionCall,
20 )
21 from vllm.entrypoints.openai.chat_completion.protocol import (
22 ChatCompletionNamedToolChoiceParam,
23 )
24 from vllm.exceptions import VLLMValidationError
25 from vllm.logger import init_logger
26 from vllm.parser.abstract_parser import Parser
27 from vllm.parser.chat_parsing import ResponseParser
28 from vllm.parser.chat_parsing.response_templates import (
29 ResponseTemplate,
30 load_response_template,
31 )
32 from vllm.reasoning.abs_reasoning_parsers import ReasoningParser
33 from vllm.tool_parsers.abstract_tool_parser import ToolParser
34 from vllm.tool_parsers.utils import iter_response_function_tool_dicts
35
36 if TYPE_CHECKING:
37 from vllm.entrypoints.openai.chat_completion.protocol import (
38 ChatCompletionRequest,
39 )
40 from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
41 from vllm.tokenizers import TokenizerLike
42 from vllm.tool_parsers.abstract_tool_parser import Tool
43
44 logger = init_logger(__name__)
45
46 THINKING_FIELD = "thinking"
47 CONTENT_FIELD = "content"
48 TOOL_FIELD = "tool_calls"
49 _SUPPORTED_FIELDS = frozenset({THINKING_FIELD, CONTENT_FIELD, TOOL_FIELD})
50
51
52 def _tool_dict(tool: Any) -> dict[str, Any]:
53 if hasattr(tool, "model_dump"):
54 tool = tool.model_dump(exclude_none=True)
55 return tool if isinstance(tool, dict) else {}
56
57
58 def _as_tool_dicts(tools: Sequence[Any] | None) -> list[dict[str, Any]]:
59 """Responses function tools with flattened namespaces, plus chat tools."""
60 tools = list(tools or ())
61 result = iter_response_function_tool_dicts(tools)
62 result.extend(
63 tool_dict
64 for tool_dict in map(_tool_dict, tools)
65 if isinstance(tool_dict.get("function"), dict)
66 )
67 return result
68
69
70 def _tool_is_strict(tool: Any) -> bool:
71 tool_dict = _tool_dict(tool)
72 function = tool_dict.get("function")
73 return bool(
74 tool_dict.get("strict")
75 or (isinstance(function, dict) and function.get("strict"))
76 )
77
78
79 def _decode(tokenizer: TokenizerLike, token_ids: Sequence[int]) -> str:
80 try:
81 return cast(Any, tokenizer.decode)(
82 token_ids,
83 skip_special_tokens=False,
84 clean_up_tokenization_spaces=False,
85 spaces_between_special_tokens=False,
86 )
87 except TypeError:
88 return tokenizer.decode(token_ids, skip_special_tokens=False)
89
90
91 def resolve_response_template(
92 tokenizer: Any | None,
93 template: dict[str, Any] | None = None,
94 ) -> dict[str, Any] | None:
95 """Return `template` if given, otherwise the tokenizer's metadata."""
96 if template is not None or tokenizer is None:
97 return template
98 template = getattr(tokenizer, "response_template", None)
99 if not isinstance(template, dict):
100 init_kwargs = getattr(tokenizer, "init_kwargs", None) or {}
101 template = init_kwargs.get("response_template")
102 return template if isinstance(template, dict) else None
103
104
105 def validate_response_template_for_serving(
106 template: dict[str, Any],
107 ) -> ResponseTemplate:
108 """Validate the template and reject semantic fields serving cannot route."""
109 loaded = load_response_template(template)
110 unsupported = (set(loaded.fields) - _SUPPORTED_FIELDS) | (
111 set(loaded.defaults) - {"role"}
112 )
113 if unsupported:
114 raise ValueError(
115 "response_template contains unsupported semantic fields: "
116 f"{sorted(unsupported)}. Supported fields are: "
117 f"{sorted(_SUPPORTED_FIELDS)}"
118 )
119 for name in (THINKING_FIELD, CONTENT_FIELD):
120 field = loaded.fields.get(name)
121 if field is None:
122 continue
123 if (
124 field.content != "text"
125 or field.transform is not None
126 or field.join is not None
127 ):
128 raise ValueError(
129 f"response_template field {name!r} uses semantics that cannot "
130 "be streamed by the OpenAI serving adapter"
131 )
132 return loaded
133
134
135 def validate_tokenizer_response_template(
136 tokenizer: Any,
137 *,
138 reasoning: bool,
139 tools: bool,
140 ) -> None:
141 """Fail at startup if the tokenizer cannot serve the requested parsing."""
142 template = resolve_response_template(tokenizer)
143 if template is None:
144 raise TypeError(
145 "The hf parser requires `response_template` metadata "
146 "in the tokenizer configuration"
147 )
148 try:
149 fields = validate_response_template_for_serving(template).fields
150 except (TypeError, ValueError) as exc:
151 raise TypeError(f"Invalid response_template metadata: {exc}") from exc
152 if reasoning and THINKING_FIELD not in fields:
153 raise TypeError(
154 "--reasoning-parser hf requires a response_template with a thinking field"
155 )
156 if tools and TOOL_FIELD not in fields:
157 raise TypeError(
158 "--tool-call-parser hf requires a response_template with a tool_calls field"
159 )
160
161
162 def _streaming_template(template: dict[str, Any]) -> ResponseTemplate:
163 result = copy.deepcopy(template)
164 fields = result["fields"]
165 if CONTENT_FIELD not in fields and all(
166 "open" in field or "open_pattern" in field for field in fields.values()
167 ):
168 fields[CONTENT_FIELD] = {}
169 for field in fields.values():
170 field["optional"] = True
171 return load_response_template(result)
172
173
174 def _is_named_tool_choice(request: ChatCompletionRequest | ResponsesRequest) -> bool:
175 return isinstance(
176 request.tool_choice, (ToolChoiceFunction, ChatCompletionNamedToolChoiceParam)
177 )
178
179
180 def _tool_parsing_enabled(
181 request: ChatCompletionRequest | ResponsesRequest,
182 *,
183 enable_auto_tools: bool,
184 ) -> bool:
185 if request.tool_choice == "none":
186 return False
187 if request.tool_choice == "required" or _is_named_tool_choice(request):
188 return True
189 return enable_auto_tools and request.tool_choice in (None, "auto")
190
191
192 def _unsupported_tool_guarantee(
193 request: ChatCompletionRequest | ResponsesRequest,
194 ) -> tuple[str, str] | None:
195 """The first requested tool guarantee this parser cannot enforce, with the
196 request parameter that asked for it."""
197 if any(_tool_is_strict(tool) for tool in request.tools or ()):
198 return "strict tools", "tools"
199 if request.tool_choice == "required":
200 return "required tool choice", "tool_choice"
201 if _is_named_tool_choice(request):
202 return "named tool choice", "tool_choice"
203 if getattr(request, "parallel_tool_calls", True) is False:
204 return "parallel_tool_calls=False", "parallel_tool_calls"
205 return None
206
207
208 def _reasoning_has_ended(
209 parser: ResponseParser,
210 template: ResponseTemplate,
211 *,
212 thinking_disabled: bool = False,
213 ) -> bool:
214 thinking = template.fields.get(THINKING_FIELD)
215 if thinking is None:
216 return True
217 if thinking.repeats:
218 return False
219
220 active_field: str | None = None
221 thinking_closed = False
222 for event in parser.initial_events:
223 if event["type"] == "region_open":
224 active_field = event["field"]
225 elif event["type"] in {"region_close", "region_malformed"}:
226 thinking_closed |= event["field"] == THINKING_FIELD
227 active_field = None
228 if thinking_closed:
229 return True
230 if active_field in (CONTENT_FIELD, TOOL_FIELD):
231 return True
232
233 # Resolve delimiters held at the prompt boundary. A NUL cannot occur in
234 # the model wire format and any implicit chunk containing it is a probe,
235 # not evidence that content generation has started.
236 for event in parser.feed("\0"):
237 if (
238 event["type"] == "region_open"
239 and event["field"] in (CONTENT_FIELD, TOOL_FIELD)
240 and event["end"] > event["start"]
241 ):
242 return True
243 if event["field"] == THINKING_FIELD:
244 if event["type"] == "region_open":
245 return False
246 if event["type"] in {"region_close", "region_malformed"}:
247 return True
248 if active_field == THINKING_FIELD:
249 return False
250 return thinking_disabled
251
252
253 def _literal_ids(
254 tokenizer: TokenizerLike, vocab: dict[str, int], literals: Sequence[str]
255 ) -> list[list[int]] | None:
256 """Token ids of each literal, or `None` when one encodes to nothing."""
257 ids = [
258 [vocab[literal]]
259 if literal in vocab
260 else tokenizer.encode(literal, add_special_tokens=False)
261 for literal in literals
262 ]
263 return ids if all(ids) else None
264
265
266 def _reasoning_end_ids(
267 tokenizer: TokenizerLike, vocab: dict[str, int], template: ResponseTemplate
268 ) -> list[list[int]] | None:
269 """Token ids of each thinking closer, or `None` when they are not all
270 literal."""
271 thinking = template.fields.get(THINKING_FIELD)
272 if thinking is None or thinking.repeats or not thinking.close_literals:
273 return None
274 return _literal_ids(tokenizer, vocab, thinking.close_literals)
275
276
277 def _ends_within(token_ids: Sequence[int], end_ids: list[int], num_new: int) -> bool:
278 """Whether `end_ids` completes within the last `num_new` of `token_ids`."""
279 window = list(token_ids[max(0, len(token_ids) - num_new - len(end_ids) + 1) :])
280 return any(
281 window[index : index + len(end_ids)] == end_ids
282 for index in range(len(window) - len(end_ids) + 1)
283 )
284
285
286 def _serving_template(
287 tokenizer: Any,
288 template: dict[str, Any] | None,
289 ) -> ResponseTemplate:
290 """The validated streaming form of `template` or the tokenizer's metadata."""
291 template = resolve_response_template(tokenizer, template)
292 if template is None:
293 raise ValueError(
294 "The hf parser requires `response_template` "
295 "metadata in the tokenizer configuration"
296 )
297 validate_response_template_for_serving(template)
298 return _streaming_template(template)
299
300
301 def _to_tool_calls(value: Any) -> list[tuple[str, str]]:
302 """`(name, arguments)` of each call in a tool region value, or nothing if
303 any call is incomplete."""
304 values = value if isinstance(value, list) else [value]
305 calls: list[tuple[str, str]] = []
306 for item in values:
307 function = item.get("function") if isinstance(item, dict) else None
308 if not isinstance(function, dict):
309 return []
310 name = function.get("name")
311 arguments = function.get("arguments")
312 if not isinstance(name, str) or not name or arguments is None:
313 return []
314 if not isinstance(arguments, str):
315 arguments = json.dumps(arguments, ensure_ascii=False)
316 calls.append((name, arguments))
317 return calls
318
319
320 class ResponseTemplateReasoningParser(ReasoningParser):
321 """Reasoning end detection for `--reasoning-parser hf`.
322
323 `ResponseTemplateParser` parses the output; this parser tells it and the
324 structured-output engine where reasoning ends.
325 """
326
327 def __init__(
328 self,
329 tokenizer: TokenizerLike,
330 *args,
331 response_template: dict[str, Any] | None = None,
332 **kwargs,
333 ) -> None:
334 super().__init__(tokenizer, *args, **kwargs)
335 self.response_template = _serving_template(tokenizer, response_template)
336 chat_template_kwargs = kwargs.get("chat_template_kwargs") or {}
337 self._thinking_disabled = not chat_template_kwargs.get("enable_thinking", True)
338 self._reasoning_end_ids = _reasoning_end_ids(
339 tokenizer, self.vocab, self.response_template
340 )
341
342 @property
343 def reasoning_start_str(self) -> str | None:
344 thinking = self.response_template.fields.get(THINKING_FIELD)
345 literals = thinking.open_literals if thinking else None
346 return literals[0] if literals else None
347
348 @property
349 def reasoning_end_str(self) -> str | None:
350 thinking = self.response_template.fields.get(THINKING_FIELD)
351 literals = thinking.close_literals if thinking else None
352 return literals[0] if literals else None
353
354 def is_reasoning_end(self, input_ids: Sequence[int]) -> bool:
355 try:
356 parser = ResponseParser(
357 self.response_template,
358 prefix=_decode(self.model_tokenizer, input_ids),
359 )
360 except Exception:
361 return False
362 return _reasoning_has_ended(
363 parser,
364 self.response_template,
365 thinking_disabled=self._thinking_disabled,
366 )
367
368 def is_reasoning_end_streaming(
369 self, input_ids: Sequence[int], delta_ids: Iterable[int]
370 ) -> bool:
371 if self._reasoning_end_ids is None:
372 return self.is_reasoning_end(input_ids)
373 num_new = len(list(delta_ids))
374 return any(
375 _ends_within(input_ids, end_ids, num_new)
376 for end_ids in self._reasoning_end_ids
377 )
378
379 def extract_content_ids(self, input_ids: list[int]) -> list[int]:
380 raise NotImplementedError("ResponseTemplateParser extracts content")
381
382 def extract_reasoning(
383 self,
384 model_output: str,
385 request: ChatCompletionRequest | ResponsesRequest,
386 ) -> tuple[str | None, str | None]:
387 raise NotImplementedError("ResponseTemplateParser extracts reasoning")
388
389 def extract_reasoning_streaming(
390 self,
391 previous_text: str,
392 current_text: str,
393 delta_text: str,
394 previous_token_ids: Sequence[int],
395 current_token_ids: Sequence[int],
396 delta_token_ids: Sequence[int],
397 ) -> DeltaMessage | None:
398 raise NotImplementedError("ResponseTemplateParser extracts reasoning")
399
400
401 class ResponseTemplateToolParser(ToolParser):
402 """Registers `--tool-call-parser hf`; `ResponseTemplateParser` extracts the
403 tool calls."""
404
405
406 class _DeltaBuilder:
407 """Accumulates one `DeltaMessage`."""
408
409 def __init__(self) -> None:
410 self.reasoning: list[str] = []
411 self.content: list[str] = []
412 self.tool_calls: list[DeltaToolCall] = []
413
414 def build(self) -> DeltaMessage | None:
415 fields: dict[str, Any] = {}
416 if reasoning := "".join(self.reasoning):
417 fields["reasoning"] = reasoning
418 if content := "".join(self.content):
419 fields["content"] = content
420 if self.tool_calls:
421 fields["tool_calls"] = self.tool_calls
422 return DeltaMessage(**fields) if fields else None
423
424
425 class _ResponseStream:
426 """One generation fed through `ResponseParser`, routed to message deltas."""
427
428 def __init__(
429 self,
430 template: ResponseTemplate,
431 *,
432 prefix: str,
433 tools: list[dict[str, Any]],
434 parse_reasoning: bool,
435 include_reasoning: bool,
436 parse_tools: bool,
437 new_tool_call_id: Callable[[str], str],
438 ) -> None:
439 self.parse_reasoning = parse_reasoning
440 self.include_reasoning = include_reasoning
441 self.parse_tools = parse_tools
442 self.new_tool_call_id = new_tool_call_id
443 self.parser = ResponseParser(template, prefix=prefix, tools=tools)
444 self._prefix_end = len(self.parser.input_text)
445 self._next_tool_index = 0
446 self._finalized = False
447
448 def feed(self, text: str, *, finished: bool) -> DeltaMessage | None:
449 if self._finalized:
450 return None
451 delta = _DeltaBuilder()
452 self._route(self.parser.feed(text), delta)
453 if finished:
454 self._finalized = True
455 if len(self.parser.input_text) > self._prefix_end:
456 _, events = self.parser.finalize()
457 self._route(events, delta)
458 return delta.build()
459
460 def _route(self, events: Sequence[dict[str, Any]], delta: _DeltaBuilder) -> None:
461 for event in events:
462 field = event.get("field")
463 if field == TOOL_FIELD:
464 self._route_tool_event(event, delta)
465 elif event["type"] == "region_chunk":
466 if field == THINKING_FIELD and self.parse_reasoning:
467 if self.include_reasoning:
468 delta.reasoning.append(event["text"])
469 elif field in (THINKING_FIELD, CONTENT_FIELD):
470 delta.content.append(event["text"])
471
472 def _route_tool_event(self, event: dict[str, Any], delta: _DeltaBuilder) -> None:
473 """Emit the calls of a tool region once it ends and parses; a region cut
474 off at the end of the stream is parsed as generated. With tool parsing
475 disabled, the region passes through as content."""
476 if not self.parse_tools:
477 if event["type"] == "region_chunk":
478 delta.content.append(event["text"])
479 else:
480 start = max(event["start"], self._prefix_end)
481 delta.content.append(self.parser.input_text[start : event["end"]])
482 return
483 if event["type"] == "region_malformed":
484 logger.warning("response_template: dropping a malformed tool call")
485 elif event["type"] == "region_close":
486 calls = _to_tool_calls(event.get("value"))
487 if not calls:
488 logger.warning("response_template: dropping an incomplete tool call")
489 for name, arguments in calls:
490 delta.tool_calls.append(
491 DeltaToolCall(
492 index=self._next_tool_index,
493 id=self.new_tool_call_id(name),
494 type="function",
495 function=DeltaFunctionCall(name=name, arguments=arguments),
496 )
497 )
498 self._next_tool_index += 1
499
500
501 class ResponseTemplateParser(Parser):
502 """Parse reasoning, content, and tool calls with one response template."""
503
504 reasoning_parser_cls: type[ReasoningParser] | None = ResponseTemplateReasoningParser
505 tool_parser_cls: type[ToolParser] | None = ResponseTemplateToolParser
506 always_adjust_request = True
507 _enable_auto_tools = False
508
509 def __init__(
510 self,
511 tokenizer: TokenizerLike,
512 tools: list[Tool] | None = None,
513 *,
514 response_template: dict[str, Any] | None = None,
515 enable_auto_tools: bool | None = None,
516 **kwargs,
517 ) -> None:
518 self.response_template = _serving_template(tokenizer, response_template)
519 super().__init__(
520 tokenizer, tools, response_template=response_template, **kwargs
521 )
522 if enable_auto_tools is not None:
523 self._enable_auto_tools = enable_auto_tools
524 self._tools = _as_tool_dicts(tools)
525 self._prompt_token_ids: list[int] | None = None
526 self._prefix = ""
527 self._stream: _ResponseStream | None = None
528
529 def set_prompt_token_ids(self, prompt_token_ids: Sequence[int]) -> None:
530 prompt_token_ids = list(prompt_token_ids)
531 if prompt_token_ids == self._prompt_token_ids:
532 return
533 self._prompt_token_ids = prompt_token_ids
534 self._prefix = _decode(self.model_tokenizer, prompt_token_ids)
535 self._stream = None
536
537 def adjust_request(
538 self,
539 request: ChatCompletionRequest | ResponsesRequest,
540 ) -> ChatCompletionRequest | ResponsesRequest:
541 if getattr(request, "chat_template", None) is not None:
542 logger.warning_once(
543 "A request chat template is set; response_template parsing still "
544 "expects the checkpoint's output format."
545 )
546 request.skip_special_tokens = False
547 if hasattr(request, "spaces_between_special_tokens"):
548 request.spaces_between_special_tokens = False
549 if (
550 self._tool_parser is not None
551 and request.tools
552 and request.tool_choice != "none"
553 and (unsupported := _unsupported_tool_guarantee(request)) is not None
554 ):
555 guarantee, parameter = unsupported
556 raise VLLMValidationError(
557 f"response_template parsing cannot guarantee {guarantee} "
558 "without a format-compatible structural grammar",
559 parameter=parameter,
560 )
561 return request
562
563 def is_reasoning_end(self, input_ids: list[int]) -> bool:
564 return (
565 self._reasoning_parser is None
566 or self._reasoning_parser.is_reasoning_end(input_ids)
567 )
568
569 def parse(
570 self,
571 model_output: str,
572 request: ChatCompletionRequest | ResponsesRequest,
573 enable_auto_tools: bool = False,
574 model_output_token_ids: Sequence[int] = (),
575 ) -> tuple[str | None, str | None, list[FunctionCall] | None]:
576 self._initialize_history_tool_call_cnt(request)
577 stream = self._new_stream(
578 request,
579 enable_auto_tools=enable_auto_tools,
580 include_reasoning=True,
581 )
582 delta = stream.feed(model_output, finished=True)
583 if delta is None:
584 return None, None, None
585 tool_calls = [
586 FunctionCall(
587 id=call.id,
588 name=call.function.name or "",
589 arguments=call.function.arguments or "{}",
590 )
591 for call in delta.tool_calls
592 if call.function is not None
593 ]
594 return delta.reasoning, delta.content, tool_calls or None
595
596 def parse_delta(
597 self,
598 delta_text: str,
599 delta_token_ids: list[int],
600 request: ChatCompletionRequest | ResponsesRequest,
601 prompt_token_ids: list[int] | None = None,
602 *,
603 finished: bool,
604 ) -> DeltaMessage | None:
605 self._initialize_history_tool_call_cnt(request)
606 if self._prompt_token_ids is None and prompt_token_ids is not None:
607 self.set_prompt_token_ids(prompt_token_ids)
608 if self._stream is None:
609 self._stream = self._new_stream(
610 request,
611 enable_auto_tools=self._enable_auto_tools,
612 include_reasoning=request.include_reasoning,
613 )
614 return self._stream.feed(delta_text, finished=finished)
615
616 def _new_stream(
617 self,
618 request: ChatCompletionRequest | ResponsesRequest,
619 *,
620 enable_auto_tools: bool,
621 include_reasoning: bool,
622 ) -> _ResponseStream:
623 return _ResponseStream(
624 self.response_template,
625 prefix=self._prefix,
626 tools=self._tools,
627 parse_reasoning=self._reasoning_parser is not None,
628 include_reasoning=include_reasoning,
629 parse_tools=self._tool_parser is not None
630 and _tool_parsing_enabled(request, enable_auto_tools=enable_auto_tools),
631 new_tool_call_id=self._new_tool_call_id,
632 )
633
634 def _new_tool_call_id(self, name: str) -> str:
635 state = self._stream_state
636 tool_call_id = make_tool_call_id(
637 id_type=state.tool_call_id_type,
638 func_name=name,
639 idx=state.history_tool_call_cnt,
640 )
641 state.history_tool_call_cnt += 1
642 return tool_call_id