main
py 1,008 lines 38.8 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import contextlib
5 import json
6 from abc import abstractmethod
7 from collections.abc import Sequence
8 from dataclasses import dataclass, field, replace
9
10 from openai.types.responses import ToolChoiceFunction
11 from pydantic import TypeAdapter, ValidationError
12 from xgrammar import Grammar, StructuralTag
13 from xgrammar.structural_tag import (
14 ConstStringFormat,
15 Format,
16 GrammarFormat,
17 JSONSchemaFormat,
18 OrFormat,
19 RegexFormat,
20 TagFormat,
21 TriggeredTagsFormat,
22 )
23
24 from vllm.entrypoints.chat_utils import (
25 get_tool_call_id_type,
26 make_tool_call_id,
27 )
28 from vllm.entrypoints.generate.base.protocol import (
29 DeltaMessage,
30 ExtractedToolCallInformation,
31 FunctionCall,
32 FunctionDefinition,
33 )
34 from vllm.entrypoints.openai.chat_completion.protocol import (
35 ChatCompletionNamedToolChoiceParam,
36 ChatCompletionRequest,
37 )
38 from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
39 from vllm.exceptions import VLLMValidationError
40 from vllm.logger import init_logger
41 from vllm.parser.engine.adapters import ParserEngineToolAdapter
42 from vllm.parser.metrics import record_tool_parser_invocation
43 from vllm.parser.utils import count_history_tool_calls
44 from vllm.reasoning.abs_reasoning_parsers import ReasoningParser
45 from vllm.sampling_params import SamplingParams, StructuredOutputsParams
46 from vllm.tokenizers import TokenizerLike
47 from vllm.tool_parsers.abstract_tool_parser import Tool, ToolParser
48 from vllm.tool_parsers.streaming import (
49 extract_named_tool_call_streaming,
50 extract_required_tool_call_streaming,
51 )
52 from vllm.tool_parsers.structural_tag_registry import (
53 limit_to_single_tool_call,
54 resolve_tool_strictness,
55 )
56 from vllm.tool_parsers.tool_strict_level import ToolStrictLevel
57
58 logger = init_logger(__name__)
59
60
61 @dataclass
62 class StreamState:
63 """Mutable state for ``Parser.parse_delta()``. One per stream."""
64
65 reasoning_ended: bool = False
66 tool_call_text_started: bool = False
67 prompt_reasoning_checked: bool = False
68 previous_text: str = ""
69 previous_token_ids: list[int] = field(default_factory=list)
70 history_tool_call_cnt: int = 0
71 history_tool_call_cnt_initialized: bool = False
72 tool_call_id_type: str = "random"
73 # only used for "required" and "named tool" choices,
74 # tracks whether function name has been fully returned in the stream yet
75 function_name_returned: bool = False
76 engine_based: bool = False
77
78 def advance(
79 self,
80 delta_text: str,
81 delta_token_ids: list[int],
82 ) -> tuple[str, list[int]]:
83 if self.engine_based:
84 return delta_text, delta_token_ids
85 return (
86 self.previous_text + delta_text,
87 self.previous_token_ids + delta_token_ids,
88 )
89
90 def commit(
91 self,
92 current_text: str,
93 current_token_ids: list[int],
94 ) -> None:
95 if self.engine_based:
96 self.previous_text = ""
97 self.previous_token_ids = []
98 else:
99 self.previous_text = current_text
100 self.previous_token_ids = current_token_ids
101
102
103 class Parser:
104 """Parse model output into reasoning, content and tool calls.
105
106 The serving layer holds one ``Parser`` per request and calls only the
107 members defined here. ``ParserEngine`` implements them over a single
108 declarative engine; ``DelegatingParser`` composes a legacy
109 ``ReasoningParser`` / ``ToolParser`` pair.
110 """
111
112 # Class-level parser classes for compatibility with existing patterns
113 # Subclasses should override these if they use specific parser classes
114 reasoning_parser_cls: type[ReasoningParser] | None = None
115 tool_parser_cls: type[ToolParser] | None = None
116 # Server-side floor for tool-call structural tags (--tool-strict-level).
117 tool_strict_level: ToolStrictLevel = ToolStrictLevel.AUTO
118 always_adjust_request: bool = False
119
120 def __init__(
121 self,
122 tokenizer: TokenizerLike,
123 tools: list[Tool] | None = None,
124 *args,
125 model_config=None,
126 **kwargs,
127 ):
128 self.model_tokenizer = tokenizer
129 self._reasoning_parser: ReasoningParser | None = None
130 self._tool_parser: ToolParser | None = None
131 if self.__class__.reasoning_parser_cls is not None:
132 self._reasoning_parser = self.__class__.reasoning_parser_cls(
133 tokenizer, *args, model_config=model_config, **kwargs
134 )
135 if self.__class__.tool_parser_cls is not None:
136 # Engine-based adapters take the same construction kwargs as
137 # the reasoning parser (e.g. chat_template_kwargs for per-request
138 # thinking toggles); legacy ToolParser classes take none.
139 if issubclass(self.__class__.tool_parser_cls, ParserEngineToolAdapter):
140 self._tool_parser = self.__class__.tool_parser_cls(
141 tokenizer, tools, model_config=model_config, **kwargs
142 )
143 else:
144 self._tool_parser = self.__class__.tool_parser_cls(tokenizer, tools)
145
146 self._engine_based = (
147 self._reasoning_parser is None
148 or self._reasoning_parser.engine_based_streaming
149 ) and (self._tool_parser is None or self._tool_parser.engine_based_streaming)
150 if (
151 self._reasoning_parser is None
152 and self._tool_parser is not None
153 and hasattr(self._tool_parser, "skip_reasoning_parsing")
154 ):
155 # With no reasoning parser configured, reasoning markup is
156 # plain content: an engine-based tool parser should pass it
157 # through verbatim where its grammar allows, not consume or
158 # reclassify it. The engine ignores the flag for markers
159 # shared with non-reasoning structure.
160 self._tool_parser.skip_reasoning_parsing = True
161 self._stream_state = StreamState(
162 tool_call_id_type=(
163 get_tool_call_id_type(model_config)
164 if model_config is not None
165 else "random"
166 ),
167 engine_based=self._engine_based,
168 )
169
170 @property
171 def reasoning_parser(self) -> ReasoningParser | None:
172 """The underlying reasoning parser, if any."""
173 return self._reasoning_parser
174
175 @property
176 def tool_parser(self) -> ToolParser | None:
177 """The underlying tool parser, if any."""
178 return self._tool_parser
179
180 def _initialize_history_tool_call_cnt(
181 self,
182 request: ChatCompletionRequest | ResponsesRequest,
183 ) -> None:
184 state = self._stream_state
185 if state.history_tool_call_cnt_initialized:
186 return
187 if state.tool_call_id_type != "kimi_k2":
188 state.history_tool_call_cnt_initialized = True
189 return
190 state.history_tool_call_cnt = count_history_tool_calls(request)
191 state.history_tool_call_cnt_initialized = True
192
193 def adjust_request(
194 self, request: ChatCompletionRequest | ResponsesRequest
195 ) -> ChatCompletionRequest | ResponsesRequest:
196 """Adjust the request parameters for tool calling.
197
198 Can be overridden by subclasses to modify request parameters
199 (e.g., setting structured output schemas for tool calling).
200
201 Args:
202 request: The original request.
203
204 Returns:
205 The adjusted request.
206
207 """
208 return request
209
210 def set_prompt_token_ids(self, prompt_token_ids: Sequence[int]) -> None:
211 """Provide the exact rendered prompt to parsers that need prefix state."""
212 return
213
214 @abstractmethod
215 def is_reasoning_end(self, input_ids: list[int]) -> bool:
216 """Check if the reasoning content ends in the input_ids.
217
218 Called with the rendered prompt to decide whether generation starts
219 after reasoning. Must be a pure function of the input_ids.
220
221 Args:
222 input_ids: The token IDs of the model output.
223
224 Returns:
225 True if the reasoning content ends in the input_ids.
226
227 """
228
229 @abstractmethod
230 def parse(
231 self,
232 model_output: str,
233 request: ChatCompletionRequest | ResponsesRequest,
234 enable_auto_tools: bool = False,
235 model_output_token_ids: Sequence[int] = (),
236 ) -> tuple[str | None, str | None, list[FunctionCall] | None]:
237 """Parse a complete model output, extracting reasoning and tool calls.
238
239 Args:
240 model_output: The complete model-generated string.
241 request: The request object used to generate the output.
242 enable_auto_tools: Whether to enable automatic tool call parsing.
243 model_output_token_ids: The generated raw output token IDs.
244
245 Returns:
246 A tuple of (reasoning, content, tool_calls).
247
248 """
249
250 @abstractmethod
251 def parse_delta(
252 self,
253 delta_text: str,
254 delta_token_ids: list[int],
255 request: ChatCompletionRequest | ResponsesRequest,
256 prompt_token_ids: list[int] | None = None,
257 *,
258 finished: bool,
259 ) -> DeltaMessage | None:
260 """Parse a single streaming delta, orchestrating reasoning then
261 tool call extraction via internal stream state.
262 """
263
264 def count_reasoning_tokens(self, token_ids: Sequence[int]) -> int:
265 """Return the number of reasoning tokens in generated token IDs."""
266 return 0
267
268
269 def structured_outputs_to_format(params: StructuredOutputsParams) -> Format | None:
270 """Map StructuredOutputsParams in a XGrammar Format."""
271 if params.json_object:
272 return JSONSchemaFormat(json_schema={"type": "object"})
273 if params.json is not None:
274 schema = params.json
275 if isinstance(schema, str):
276 schema = json.loads(schema)
277 return JSONSchemaFormat(json_schema=schema)
278 if params.regex is not None:
279 return RegexFormat(pattern=params.regex)
280 if params.choice is not None:
281 return OrFormat(
282 elements=[ConstStringFormat(value=choice) for choice in params.choice]
283 )
284 if params.grammar is not None:
285 from vllm.v1.structured_output.utils import grammar_is_likely_lark
286
287 grammar = params.grammar
288 # IMPORTANT(arpera):
289 # GrammarFormat only accepts EBNF.
290 if grammar_is_likely_lark(grammar):
291 try:
292 grammar = str(Grammar.from_lark(grammar))
293 except Exception as e:
294 raise VLLMValidationError("Invalid grammar specification.") from e
295 return GrammarFormat(grammar=grammar)
296 if params.structural_tag is not None:
297 s_tag = json.loads(params.structural_tag)
298 if "structures" in s_tag:
299 # LegacyStructuralTagResponseFormat
300 return TriggeredTagsFormat(
301 triggers=s_tag["triggers"],
302 tags=[
303 TagFormat(
304 begin=structure["begin"],
305 content=JSONSchemaFormat(json_schema=structure["schema"]),
306 end=structure["end"],
307 )
308 for structure in s_tag["structures"]
309 ],
310 )
311 # StructuralTagResponseFormat
312 return StructuralTag.model_validate(s_tag).format
313 return None
314
315
316 def _xgrammar_supports(structured_outputs: StructuredOutputsParams) -> bool:
317 """Whether the constraint passes xgrammar validation on its own."""
318 from vllm.v1.structured_output.backend_xgrammar import validate_xgrammar_grammar
319
320 try:
321 # validate_xgrammar_grammar rewrites `choice` into `grammar` in place.
322 validate_xgrammar_grammar(
323 SamplingParams(structured_outputs=replace(structured_outputs))
324 )
325 except (TypeError, ValueError, VLLMValidationError):
326 return False
327 return True
328
329
330 class DelegatingParser(Parser):
331 """A Parser implementation that delegates to separate ReasoningParser and
332 ToolParser instances.
333
334 This is the recommended base class for creating model-specific parsers
335 that combine existing reasoning and tool parser implementations.
336 Subclasses should set `self._reasoning_parser` and `self._tool_parser`
337 in their `__init__` method.
338
339 If either parser is None, the corresponding methods will return default
340 values (no reasoning extraction, no tool calls).
341 """
342
343 def extract_reasoning(
344 self,
345 model_output: str,
346 request: ChatCompletionRequest | ResponsesRequest,
347 ) -> tuple[str | None, str | None]:
348 if self._reasoning_parser is None:
349 return None, model_output
350 return self._reasoning_parser.extract_reasoning(model_output, request)
351
352 def _get_function_name(
353 self, request: ChatCompletionRequest | ResponsesRequest
354 ) -> str:
355 if request.tool_choice and isinstance(request.tool_choice, ToolChoiceFunction):
356 return request.tool_choice.name
357 if request.tool_choice and isinstance(
358 request.tool_choice, ChatCompletionNamedToolChoiceParam
359 ):
360 return request.tool_choice.function.name
361 raise ValueError("Invalid tool_choice for function name extraction.")
362
363 def _make_tool_call_id(self, function_name: str) -> str | None:
364 state = self._stream_state
365 if state.tool_call_id_type != "kimi_k2":
366 return None
367 tool_call_id = make_tool_call_id(
368 id_type=state.tool_call_id_type,
369 func_name=function_name,
370 idx=state.history_tool_call_cnt,
371 )
372 state.history_tool_call_cnt += 1
373 return tool_call_id
374
375 def _extract_tool_calls(
376 self,
377 content: str | None,
378 request: ChatCompletionRequest | ResponsesRequest,
379 enable_auto_tools: bool = False,
380 ) -> tuple[list[FunctionCall] | None, str | None]:
381 tool_parser = self._tool_parser
382 if tool_parser is None:
383 return [], content
384
385 if request.tool_choice == "none":
386 if self._engine_based:
387 result = self.extract_tool_calls(content or "", request=request)
388 return [], result.content
389 return [], content
390
391 supports_required_and_named = tool_parser.supports_required_and_named
392 is_named_tool_choice = request.tool_choice and isinstance(
393 request.tool_choice,
394 (ToolChoiceFunction, ChatCompletionNamedToolChoiceParam),
395 )
396 is_required_tool_choice = request.tool_choice == "required"
397 is_auto_tool_choice = enable_auto_tools and (
398 request.tool_choice == "auto"
399 or request.tool_choice is None
400 or (
401 not supports_required_and_named
402 and (is_named_tool_choice or is_required_tool_choice)
403 )
404 )
405
406 tool_calls = list[FunctionCall]()
407 if is_named_tool_choice and supports_required_and_named:
408 if content is None or (isinstance(content, str) and not content.strip()):
409 return [], None
410 function_name = self._get_function_name(request)
411 tool_calls.append(
412 FunctionCall(
413 id=self._make_tool_call_id(function_name),
414 name=function_name,
415 arguments=content,
416 )
417 )
418 content = None
419 elif is_required_tool_choice and supports_required_and_named:
420 # "required" with standard JSON-based parsing
421 parsed_calls = []
422 with contextlib.suppress(ValidationError):
423 content = content or ""
424 parsed_calls = TypeAdapter(list[FunctionDefinition]).validate_json(
425 content
426 )
427 for tc in parsed_calls:
428 tool_calls.append(
429 FunctionCall(
430 id=self._make_tool_call_id(tc.name),
431 name=tc.name,
432 arguments=json.dumps(tc.parameters, ensure_ascii=False),
433 )
434 )
435 content = None
436 elif is_auto_tool_choice:
437 # Automatic Tool Call Parsing (also used as fallback for
438 # required/named when supports_required_and_named=False)
439 tool_call_info = self.extract_tool_calls(
440 content if content is not None else "",
441 request=request,
442 )
443 if tool_call_info is not None and tool_call_info.tools_called:
444 tool_calls.extend(
445 FunctionCall(
446 id=tc.id,
447 name=tc.function.name,
448 arguments=tc.function.arguments,
449 )
450 for tc in tool_call_info.tool_calls
451 )
452 content = tool_call_info.content
453 if content and content.strip() == "":
454 content = None
455 else:
456 # No tool calls.
457 # For required/named tool choice (when falling back to auto
458 # parsing), if content is empty or whitespace-only, return
459 # empty list with None content.
460 if (is_required_tool_choice or is_named_tool_choice) and (
461 content is None
462 or (isinstance(content, str) and not content.strip())
463 ):
464 return [], None
465 # No complete tool calls: for engine-based parsers, return
466 # the tool parser's content, which drops incomplete
467 # tool-call markup (e.g. a <tool_call> opener truncated by
468 # max_tokens or a stop string), so the non-streaming path
469 # matches streaming. Legacy parsers keep their existing
470 # behavior of returning the raw content.
471 if self._engine_based and tool_call_info is not None:
472 return None, tool_call_info.content or None
473 return None, content
474
475 return tool_calls, content
476
477 def adjust_request(
478 self, request: ChatCompletionRequest | ResponsesRequest
479 ) -> ChatCompletionRequest | ResponsesRequest:
480 if self._reasoning_parser is not None:
481 request = self._reasoning_parser.adjust_request(request)
482 if self._tool_parser is not None:
483 request = self._apply_structural_tag(request)
484 request = self._tool_parser.adjust_request(request)
485 return request
486
487 def _apply_structural_tag(
488 self, request: ChatCompletionRequest | ResponsesRequest
489 ) -> ChatCompletionRequest | ResponsesRequest:
490 tool_parser = self._tool_parser
491 if tool_parser is None or not request.tools:
492 return request
493
494 need_tool_calling = (
495 request.tool_choice == "auto"
496 or request.tool_choice == "required"
497 or isinstance(
498 request.tool_choice,
499 (ChatCompletionNamedToolChoiceParam, ToolChoiceFunction),
500 )
501 )
502 if not need_tool_calling:
503 return request
504
505 structured_outputs = request.extract_structured_outputs()
506 is_auto = request.tool_choice == "auto"
507 single_call = request.parallel_tool_calls is False
508 strict_level = self.tool_strict_level
509 if single_call and not (is_auto and structured_outputs is not None):
510 # Limiting the call count needs the call envelope in the grammar.
511 # Auto with structured outputs keeps its format-only grammar, so the
512 # flag never makes calls possible that are impossible without it.
513 strict_level = max(strict_level, ToolStrictLevel.FUNCTION)
514
515 resolved_tools = None
516 if tool_parser.structural_tag_model is not None:
517 resolved_tools = resolve_tool_strictness(
518 request.tools,
519 request.tool_choice,
520 strict_level,
521 )
522
523 output_format = None
524 if resolved_tools and is_auto and structured_outputs:
525 if not _xgrammar_supports(structured_outputs):
526 logger.warning_once(
527 "Tool calls are not constrained for tool_choice=auto with "
528 "structured outputs because xgrammar does not support the "
529 "structured output constraint; the structured output "
530 "constraint applies.",
531 scope="local",
532 )
533 return request
534 output_format = structured_outputs_to_format(structured_outputs)
535
536 if resolved_tools is not None:
537 tag_request = request
538 if output_format is not None:
539 # IMPORTANT(arpera):
540 # The "auto" tag allows plain text in response.
541 # Use tag "required" here to ensure structured-output branch
542 # remains meaningful.
543 tag_request = request.model_copy(update={"tool_choice": "required"})
544 tools_structural_tag = tool_parser.get_structural_tag(
545 tag_request,
546 reasoning=False,
547 strict_level=strict_level,
548 )
549 else:
550 tools_structural_tag = None
551
552 if tools_structural_tag is None:
553 if is_auto and structured_outputs is not None:
554 logger.warning_once(
555 "Tool calls are not constrained for tool_choice=auto with "
556 "structured outputs because structural tags are unavailable; "
557 "the structured output constraint applies.",
558 scope="local",
559 )
560 return request
561
562 if single_call:
563 limit_to_single_tool_call(tools_structural_tag)
564 structural_tag = tools_structural_tag
565 if output_format is not None:
566 structural_tag = StructuralTag(
567 format=OrFormat(elements=[tools_structural_tag.format, output_format])
568 )
569 elif structured_outputs is not None and not is_auto:
570 logger.warning_once(
571 "structured outputs are ignored because tool_choice forces tool call.",
572 scope="local",
573 )
574
575 request.structured_outputs = StructuredOutputsParams(
576 structural_tag=json.dumps(structural_tag.model_dump()),
577 )
578 if isinstance(request, ResponsesRequest):
579 request.text = None
580 else:
581 request.response_format = None
582 return request
583
584 def extract_reasoning_streaming(
585 self,
586 previous_text: str,
587 current_text: str,
588 delta_text: str,
589 previous_token_ids: Sequence[int],
590 current_token_ids: Sequence[int],
591 delta_token_ids: Sequence[int],
592 ) -> DeltaMessage | None:
593 if self._reasoning_parser is None:
594 return DeltaMessage(content=delta_text)
595 return self._reasoning_parser.extract_reasoning_streaming(
596 previous_text,
597 current_text,
598 delta_text,
599 previous_token_ids,
600 current_token_ids,
601 delta_token_ids,
602 )
603
604 def extract_tool_calls(
605 self,
606 model_output: str,
607 request: ChatCompletionRequest | ResponsesRequest,
608 ) -> ExtractedToolCallInformation:
609 if self._tool_parser is None:
610 return ExtractedToolCallInformation(
611 tools_called=False, tool_calls=[], content=model_output
612 )
613 result = None
614 is_tool_called: bool | Exception = False
615 try:
616 result = self._tool_parser.extract_tool_calls(
617 model_output,
618 request=request, # type: ignore[arg-type]
619 )
620 is_tool_called = bool(result.tools_called)
621 except Exception as e:
622 is_tool_called = e
623 raise
624 finally:
625 record_tool_parser_invocation(
626 is_tool_called=is_tool_called,
627 is_streaming=False,
628 request=request,
629 )
630 return result
631
632 def extract_tool_calls_streaming(
633 self,
634 previous_text: str,
635 current_text: str,
636 delta_text: str,
637 previous_token_ids: Sequence[int],
638 current_token_ids: Sequence[int],
639 delta_token_ids: Sequence[int],
640 request: ChatCompletionRequest | ResponsesRequest,
641 ) -> DeltaMessage | None:
642 if self._tool_parser is None:
643 return None
644 result = None
645 is_tool_called: bool | Exception = False
646 try:
647 result = self._tool_parser.extract_tool_calls_streaming(
648 previous_text,
649 current_text,
650 delta_text,
651 previous_token_ids,
652 current_token_ids,
653 delta_token_ids,
654 request, # type: ignore[arg-type]
655 )
656 is_tool_called = bool(result and result.tool_calls)
657 except Exception as e:
658 is_tool_called = e
659 raise
660 finally:
661 record_tool_parser_invocation(
662 is_tool_called=is_tool_called,
663 is_streaming=True,
664 request=request,
665 )
666 return result
667
668 def _extract_tool_calls_streaming(
669 self,
670 previous_text: str,
671 current_text: str,
672 delta_text: str,
673 previous_token_ids: Sequence[int],
674 current_token_ids: Sequence[int],
675 delta_token_ids: Sequence[int],
676 request: ChatCompletionRequest | ResponsesRequest,
677 # The following parameters are used for "required" tool choice parsing and are
678 # tracked in StreamState for streaming parsing.
679 tool_call_idx: int | None = None,
680 tool_call_id_type: str = "random",
681 function_name_returned: bool = False,
682 ) -> tuple[DeltaMessage | None, bool]:
683 assert self._tool_parser is not None
684 supports_required_and_named = self._tool_parser.supports_required_and_named
685
686 if request.tool_choice == "none":
687 if self._engine_based:
688 # Engine-backed parsers route content extraction through
689 # extract_tool_calls_streaming, so run the full pipeline
690 # and strip tool_calls after.
691 delta_message = self.extract_tool_calls_streaming(
692 previous_text,
693 current_text,
694 delta_text,
695 previous_token_ids,
696 current_token_ids,
697 delta_token_ids,
698 request, # type: ignore[arg-type]
699 )
700 if delta_message:
701 delta_message.tool_calls = []
702 return delta_message, False
703 return (DeltaMessage(content=delta_text) if delta_text else None), False
704
705 if (
706 supports_required_and_named
707 and request.tool_choice
708 and isinstance(
709 request.tool_choice,
710 (ToolChoiceFunction, ChatCompletionNamedToolChoiceParam),
711 )
712 ):
713 delta_message, function_name_returned = extract_named_tool_call_streaming(
714 delta_text=delta_text,
715 function_name=self._get_function_name(request),
716 function_name_returned=function_name_returned,
717 tool_call_idx=tool_call_idx,
718 tool_call_id_type=tool_call_id_type,
719 tokenizer=self.model_tokenizer,
720 )
721 return delta_message, function_name_returned
722
723 if supports_required_and_named and request.tool_choice == "required":
724 delta_message, function_name_returned = (
725 extract_required_tool_call_streaming(
726 previous_text=previous_text,
727 current_text=current_text,
728 delta_text=delta_text,
729 function_name_returned=function_name_returned,
730 tool_call_idx=tool_call_idx,
731 tool_call_id_type=tool_call_id_type,
732 )
733 )
734 return delta_message, function_name_returned
735 return self.extract_tool_calls_streaming(
736 previous_text,
737 current_text,
738 delta_text,
739 previous_token_ids,
740 current_token_ids,
741 delta_token_ids,
742 request,
743 ), False
744
745 def is_reasoning_end(self, input_ids: list[int]) -> bool:
746 if self._reasoning_parser is None:
747 return False
748 return self._reasoning_parser.is_reasoning_end(input_ids)
749
750 def _is_reasoning_end_streaming(
751 self, input_ids: list[int], delta_ids: list[int]
752 ) -> bool:
753 if self._reasoning_parser is None:
754 return False
755 return self._reasoning_parser.is_reasoning_end_streaming(input_ids, delta_ids)
756
757 def _extract_content_ids(self, input_ids: list[int]) -> list[int]:
758 if self._reasoning_parser is None:
759 return input_ids
760 return self._reasoning_parser.extract_content_ids(input_ids)
761
762 def _in_reasoning_phase(self, state: StreamState) -> bool:
763 if self._reasoning_parser is None:
764 return False
765 return not state.reasoning_ended
766
767 def _in_tool_call_phase(self, state: StreamState) -> bool:
768 if self._tool_parser is None:
769 return False
770 return state.reasoning_ended
771
772 def _append_unstreamed_tool_args(
773 self,
774 delta_message: DeltaMessage | None,
775 ) -> None:
776 """Append parsed-but-unstreamed tool-call arguments to *delta_message*."""
777 if (
778 self._tool_parser is not None
779 and delta_message
780 and delta_message.tool_calls
781 and (last_tc := delta_message.tool_calls[-1]).function
782 ):
783 last_tc.function.arguments = (
784 last_tc.function.arguments or ""
785 ) + self._tool_parser.get_remaining_unstreamed_args()
786
787 def finalize_generation(
788 self,
789 delta_message: DeltaMessage | None,
790 request: ChatCompletionRequest | ResponsesRequest,
791 state: StreamState,
792 ) -> DeltaMessage | None:
793 """Finalize generation for cases where generation was incomplete.
794 For example, if streaming terminated before reasoning ended
795 """
796 fallback_fn = getattr(
797 self._reasoning_parser, "get_streaming_fallback_content", None
798 )
799 if fallback_fn is not None and not state.reasoning_ended:
800 promoted = fallback_fn(state.previous_text, request)
801 if promoted:
802 if delta_message is None:
803 delta_message = DeltaMessage()
804 delta_message.content = (delta_message.content or "") + promoted
805
806 self._append_unstreamed_tool_args(delta_message)
807 return delta_message
808
809 def parse(
810 self,
811 model_output: str,
812 request: ChatCompletionRequest | ResponsesRequest,
813 enable_auto_tools: bool = False,
814 model_output_token_ids: Sequence[int] = (),
815 ) -> tuple[str | None, str | None, list[FunctionCall] | None]:
816 self._initialize_history_tool_call_cnt(request)
817 reasoning, content = self.extract_reasoning(model_output, request)
818 tool_calls, content = self._extract_tool_calls(
819 content=content,
820 request=request,
821 enable_auto_tools=enable_auto_tools,
822 )
823 return reasoning, content, tool_calls
824
825 def parse_delta(
826 self,
827 delta_text: str,
828 delta_token_ids: list[int],
829 request: ChatCompletionRequest | ResponsesRequest,
830 prompt_token_ids: list[int] | None = None,
831 *,
832 finished: bool,
833 ) -> DeltaMessage | None:
834 self._initialize_history_tool_call_cnt(request)
835 state = self._stream_state
836
837 if not state.prompt_reasoning_checked and prompt_token_ids is not None:
838 state.prompt_reasoning_checked = True
839 if self._reasoning_parser is None or self.is_reasoning_end(
840 prompt_token_ids
841 ):
842 state.reasoning_ended = True
843 else:
844 # Reasoning is still open at the end of the prompt; let the
845 # reasoning parser adjust its initial parsing state so the
846 # first generated tokens are classified correctly.
847 self._reasoning_parser.adjust_initial_state_from_prompt(
848 prompt_token_ids
849 )
850
851 current_text, current_token_ids = state.advance(delta_text, delta_token_ids)
852 delta_message: DeltaMessage | None = None
853 reasoning_transitioned = False
854
855 # Reasoning extraction
856 if self._in_reasoning_phase(state):
857 delta_message = self.extract_reasoning_streaming(
858 previous_text=state.previous_text,
859 current_text=current_text,
860 delta_text=delta_text,
861 previous_token_ids=state.previous_token_ids,
862 current_token_ids=current_token_ids,
863 delta_token_ids=delta_token_ids,
864 )
865 reasoning_parser = self._reasoning_parser
866 if reasoning_parser is not None and reasoning_parser.engine_based_streaming:
867 should_transition = (
868 reasoning_parser.has_engine_confirmed_reasoning_end()
869 )
870 else:
871 should_transition = self._is_reasoning_end_streaming(
872 current_token_ids, delta_token_ids
873 )
874 if should_transition:
875 state.reasoning_ended = True
876 reasoning_transitioned = True
877 current_token_ids = self._extract_content_ids(delta_token_ids)
878 # Flush whenever the reasoning parser is engine-based (not only
879 # when _engine_based is True): it buffers the post-marker text
880 # (e.g. the "<" of "<tool_call>"), surfaced via finish_streaming().
881 flush_delta = (
882 reasoning_parser.finish_streaming() # type: ignore[union-attr, attr-defined]
883 if reasoning_parser is not None
884 and reasoning_parser.engine_based_streaming
885 else None
886 )
887 current_text = (
888 (delta_message.content if delta_message else None) or ""
889 ) + ((flush_delta.content if flush_delta else None) or "")
890 if self._engine_based:
891 if delta_message and self._tool_parser is not None:
892 delta_message.content = None
893 else:
894 delta_text = current_text
895
896 # Tool call extraction
897 if self._in_tool_call_phase(state):
898 if not state.tool_call_text_started:
899 state.tool_call_text_started = True
900 state.previous_text = ""
901 state.previous_token_ids = []
902 delta_text = current_text
903 delta_token_ids = current_token_ids
904
905 reasoning_from_this_batch = (
906 delta_message.reasoning if delta_message else None
907 )
908
909 delta_message, state.function_name_returned = (
910 self._extract_tool_calls_streaming(
911 previous_text=state.previous_text,
912 current_text=current_text,
913 delta_text=delta_text,
914 previous_token_ids=state.previous_token_ids,
915 current_token_ids=current_token_ids,
916 delta_token_ids=delta_token_ids,
917 request=request, # type: ignore[arg-type]
918 tool_call_idx=state.history_tool_call_cnt,
919 tool_call_id_type=state.tool_call_id_type,
920 function_name_returned=state.function_name_returned,
921 )
922 )
923
924 if reasoning_from_this_batch:
925 if delta_message is None:
926 delta_message = DeltaMessage(reasoning=reasoning_from_this_batch)
927 elif not delta_message.reasoning:
928 delta_message.reasoning = reasoning_from_this_batch
929
930 if (
931 delta_message
932 and delta_message.tool_calls
933 and delta_message.tool_calls[0].id is not None
934 ):
935 state.history_tool_call_cnt += 1
936
937 # No phase active: pass through as content.
938 # Skip when reasoning just ended in this delta — the engine already
939 # consumed the end-of-reasoning marker (e.g. </think>) and
940 # delta_text still contains the raw marker text.
941 if (
942 delta_message is None
943 and not reasoning_transitioned
944 and not self._in_reasoning_phase(state)
945 and not self._in_tool_call_phase(state)
946 ):
947 delta_message = DeltaMessage(content=delta_text)
948
949 state.commit(current_text, current_token_ids)
950
951 if finished:
952 delta_message = self.finalize_generation(delta_message, request, state)
953 delta_message = self._flush_engine_parsers(delta_message)
954
955 # Suppress reasoning deltas if not requested
956 if delta_message and not request.include_reasoning:
957 delta_message.reasoning = None
958
959 # If only reasoning was in the message (no content, no tool_calls)
960 # skip emitting entirely
961 if not delta_message.content and not delta_message.tool_calls:
962 delta_message = None
963
964 return delta_message
965
966 def count_reasoning_tokens(self, token_ids: Sequence[int]) -> int:
967 """Count reasoning tokens through the configured reasoning parser."""
968 if self._reasoning_parser is None:
969 return 0
970 return self._reasoning_parser.count_reasoning_tokens(token_ids)
971
972 def _flush_engine_parsers(
973 self, delta_message: DeltaMessage | None
974 ) -> DeltaMessage | None:
975 """Flush buffered state from engine-based parsers at stream end."""
976 reasoning_ended = self._stream_state.reasoning_ended
977 for parser in (self._reasoning_parser, self._tool_parser):
978 if not getattr(parser, "engine_based_streaming", False):
979 continue
980 # When reasoning has ended and we transitioned to the tool
981 # phase, the reasoning parser's engine may still have buffered
982 # characters from tool-call markup it saw with
983 # skip_tool_parsing=True. Flushing that would leak spurious
984 # content (e.g. a stray '"'), so skip it.
985 if parser is self._reasoning_parser and reasoning_ended:
986 continue
987 finish = getattr(parser, "finish_streaming", None)
988 if finish is None:
989 continue
990 flush_delta = finish()
991 if flush_delta is None:
992 continue
993 if delta_message is None:
994 delta_message = flush_delta
995 else:
996 if flush_delta.content:
997 delta_message.content = (
998 delta_message.content or ""
999 ) + flush_delta.content
1000 if flush_delta.reasoning:
1001 delta_message.reasoning = (
1002 delta_message.reasoning or ""
1003 ) + flush_delta.reasoning
1004 if flush_delta.tool_calls:
1005 delta_message.tool_calls = (
1006 delta_message.tool_calls or []
1007 ) + flush_delta.tool_calls
1008 return delta_message