main
py 1,176 lines 42.6 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Parser engine base that handles both reasoning and tool call
4 extraction with a single :class:`StreamingParserEngine`.
5 """
6
7 from __future__ import annotations
8
9 import json
10 from collections.abc import Sequence
11 from functools import cached_property
12 from typing import TYPE_CHECKING
13
14 import regex as re
15
16 from vllm.entrypoints.chat_utils import get_tool_call_id_type, make_tool_call_id
17 from vllm.entrypoints.generate.base.protocol import (
18 DeltaFunctionCall,
19 DeltaMessage,
20 DeltaToolCall,
21 ExtractedToolCallInformation,
22 FunctionCall,
23 ToolCall,
24 )
25 from vllm.logger import init_logger
26 from vllm.parser.abstract_parser import Parser, StreamState
27 from vllm.parser.engine.events import EventType, SemanticEvent
28 from vllm.parser.engine.parser_engine_config import ParserEngineConfig, ParserState
29 from vllm.parser.engine.streaming_parser_engine import StreamingParserEngine
30 from vllm.tool_parsers.utils import (
31 coerce_to_schema_type,
32 extract_types_from_schema,
33 find_tool_name,
34 find_tool_properties,
35 )
36
37 if TYPE_CHECKING:
38 from vllm.entrypoints.openai.chat_completion.protocol import (
39 ChatCompletionRequest,
40 )
41 from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
42 from vllm.tokenizers import TokenizerLike
43 from vllm.tool_parsers.abstract_tool_parser import Tool
44
45 logger = init_logger(__name__)
46
47
48 class ToolCallSlot:
49 __slots__ = (
50 "id",
51 "name",
52 "_args_parts",
53 "_args_joined",
54 "name_sent",
55 "string_keys",
56 "streamed_json",
57 )
58
59 def __init__(self) -> None:
60 self.id: str = ""
61 self.name: str = ""
62 self._args_parts: list[str] = []
63 self._args_joined: str | None = ""
64 self.name_sent: bool = False
65 self.string_keys: set[str] | None = None
66 self.streamed_json: str = ""
67
68 @property
69 def args(self) -> str:
70 if self._args_joined is None:
71 self._args_joined = "".join(self._args_parts)
72 return self._args_joined
73
74 def append_args(self, value: str) -> None:
75 self._args_parts.append(value)
76 self._args_joined = None
77
78
79 class ParserEngine(Parser):
80 """A :class:`Parser` backed by a single declarative engine config.
81
82 Subclasses set the ``ParserEngineConfig`` in ``__init__`` to define the
83 complete output format for a model (reasoning + tool calls).
84 """
85
86 def __init__(
87 self,
88 tokenizer: TokenizerLike,
89 tools: list[Tool] | None = None,
90 *,
91 parser_engine_config: ParserEngineConfig,
92 model_config=None,
93 **kwargs,
94 ) -> None:
95 self.model_tokenizer = tokenizer
96 self._tools = tools
97 self._stream_state = StreamState(
98 tool_call_id_type=(
99 get_tool_call_id_type(model_config)
100 if model_config is not None
101 else "random"
102 ),
103 )
104 self._reasoning_parser = None
105 self._tool_parser = None
106 self.parser_engine_config = parser_engine_config
107 self._engine = StreamingParserEngine(
108 parser_engine_config, tokenizer, vocab=self.vocab
109 )
110
111 self._has_reasoning = (
112 "THINK_END" in parser_engine_config.token_id_terminals
113 or "THINK_START" in parser_engine_config.terminals
114 or "THINK_END" in parser_engine_config.terminals
115 or parser_engine_config.initial_state == ParserState.REASONING
116 )
117 self._reasoning_ended: bool = not self._has_reasoning
118 self._streaming_initialized: bool = False
119 self._prompt_streaming_prepared: bool = False
120
121 self._tool_slots: list[ToolCallSlot] = []
122 self._deferred_content: str = ""
123 self._deferred_reasoning: str = ""
124 self._content_has_nonws: bool = False
125 self._suppress_tool_calls: bool = False
126
127 self._arg_converter = parser_engine_config.arg_converter
128 self._arg_structural_chars = parser_engine_config.arg_structural_chars
129 self._stream_arg_deltas = parser_engine_config.stream_arg_deltas
130 self._strip_trailing_reasoning_ws = (
131 parser_engine_config.strip_trailing_reasoning_whitespace
132 )
133 self._drop_ws_only_content_before_tools = (
134 parser_engine_config.drop_whitespace_only_content_before_tools
135 )
136 self._strip_content_ws_with_tools = (
137 parser_engine_config.strip_content_whitespace_with_tools
138 )
139
140 vocab = self.vocab
141 self._reasoning_start_token_id: int | None = None
142 self._reasoning_end_token_id: int | None = None
143
144 start_text = parser_engine_config.token_id_terminals.get("THINK_START")
145 end_text = parser_engine_config.token_id_terminals.get("THINK_END")
146 if start_text:
147 self._reasoning_start_token_id = vocab.get(start_text)
148 if end_text:
149 self._reasoning_end_token_id = vocab.get(end_text)
150 self._turn_boundary_token_ids: frozenset[int] = frozenset(
151 token_id
152 for token in parser_engine_config.turn_boundary_tokens
153 if (token_id := vocab.get(token)) is not None
154 )
155 self._reasoning_end_token_ids: frozenset[int] = (
156 self._derive_reasoning_end_token_ids(parser_engine_config, vocab)
157 )
158
159 @property
160 def reasoning_start_str(self) -> str | None:
161 return self.parser_engine_config.terminal_literal("THINK_START")
162
163 @property
164 def reasoning_end_str(self) -> str | None:
165 return self.parser_engine_config.terminal_literal("THINK_END")
166
167 @cached_property
168 def vocab(self) -> dict[str, int]:
169 return self.model_tokenizer.get_vocab()
170
171 # ── Engine lifecycle ──────────────────────────────────────────────
172
173 @property
174 def skip_tool_parsing(self) -> bool:
175 return self._engine.skip_tool_parsing
176
177 @skip_tool_parsing.setter
178 def skip_tool_parsing(self, value: bool) -> None:
179 self._engine.skip_tool_parsing = value
180
181 @property
182 def skip_reasoning_parsing(self) -> bool:
183 return self._engine.skip_reasoning_parsing
184
185 @skip_reasoning_parsing.setter
186 def skip_reasoning_parsing(self, value: bool) -> None:
187 self._engine.skip_reasoning_parsing = value
188
189 @property
190 def reasoning_ended(self) -> bool:
191 return self._reasoning_ended
192
193 def initialize_streaming(
194 self,
195 initial_state: ParserState | None = None,
196 ) -> None:
197 if not self._streaming_initialized:
198 self._streaming_initialized = True
199 self._reset(initial_state=initial_state)
200
201 def adjust_initial_state_from_prompt(self, prompt_token_ids: Sequence[int]) -> None:
202 """See :meth:`ReasoningParser.adjust_initial_state_from_prompt`."""
203 return
204
205 def finish_streaming(self) -> DeltaMessage | None:
206 events = self._engine.finish()
207 if events or self._deferred_content:
208 return self._events_to_delta(events, finished=True)
209 return None
210
211 def _reset(self, initial_state: ParserState | None = None) -> None:
212 self._engine.reset(initial_state=initial_state)
213 self._reasoning_ended = not self._has_reasoning
214 self._tool_slots.clear()
215 self._deferred_content = ""
216 self._deferred_reasoning = ""
217 self._content_has_nonws = False
218 self._prompt_streaming_prepared = False
219
220 def adjust_request(
221 self, request: ChatCompletionRequest | ResponsesRequest
222 ) -> ChatCompletionRequest | ResponsesRequest:
223 request.skip_special_tokens = False
224 return request
225
226 def _preprocess_feed(
227 self,
228 delta_text: str,
229 delta_token_ids: Sequence[int],
230 ) -> tuple[str, Sequence[int]]:
231 return delta_text, delta_token_ids
232
233 def _feed(
234 self,
235 delta_text: str,
236 delta_token_ids: Sequence[int],
237 ) -> list[SemanticEvent]:
238 delta_text, delta_token_ids = self._preprocess_feed(delta_text, delta_token_ids)
239 return self._engine.feed(delta_text, delta_token_ids)
240
241 # ── Schema-aware type correction ─────────────────────────────────
242
243 @staticmethod
244 def _coerce_value(value: object, schema: dict) -> tuple[object, bool]:
245 """Coerce a single value according to its schema.
246
247 Returns ``(coerced_value, changed)``.
248 """
249 if isinstance(value, str):
250 types = extract_types_from_schema(schema)
251 coerced = coerce_to_schema_type(value, types)
252 if coerced is not value:
253 return coerced, True
254 return value, False
255
256 if isinstance(value, dict):
257 nested_props = schema.get("properties")
258 if isinstance(nested_props, dict):
259 _, changed = ParserEngine._coerce_dict(value, nested_props)
260 return value, changed
261 return value, False
262
263 if isinstance(value, list):
264 items_schema = schema.get("items")
265 if isinstance(items_schema, dict):
266 changed = False
267 for i, item in enumerate(value):
268 coerced, item_changed = ParserEngine._coerce_value(
269 item, items_schema
270 )
271 if item_changed:
272 value[i] = coerced
273 changed = True
274 return value, changed
275 return value, False
276
277 types = extract_types_from_schema(schema)
278 as_str = json.dumps(value, ensure_ascii=False)
279 coerced = coerce_to_schema_type(as_str, types)
280 if type(coerced) is not type(value) or coerced != value:
281 return coerced, True
282 return value, False
283
284 @staticmethod
285 def _coerce_dict(args: dict, properties: dict) -> tuple[dict, bool]:
286 """Coerce all values in *args* using *properties* schemas."""
287 changed = False
288 for key, value in args.items():
289 prop = properties.get(key)
290 if not isinstance(prop, dict):
291 continue
292 coerced, val_changed = ParserEngine._coerce_value(value, prop)
293 if val_changed:
294 args[key] = coerced
295 changed = True
296 return args, changed
297
298 @staticmethod
299 def _safe_arg_prefix(json_str: str, string_keys: set[str] | None = None) -> str:
300 """Return the prefix of *json_str* up to the last top-level value.
301
302 Middle values (followed by a comma) are stable across streaming
303 ticks and included. The trailing value is excluded for non-string
304 values because type coercion may change its serialised form between
305 ticks, which would violate the ``startswith(prev)`` prefix invariant.
306 String values for keys in ``string_keys`` are prefix-stable, so stream
307 their unterminated content instead of buffering long arguments until
308 the closing tag arrives.
309 """
310 last_colon = -1
311 last_key: str | None = None
312 pending_key: str | None = None
313 in_string = False
314 escape = False
315 string_start = -1
316 depth = 0
317 for i, c in enumerate(json_str):
318 if escape:
319 escape = False
320 continue
321 if in_string:
322 if c == "\\":
323 escape = True
324 elif c == '"':
325 in_string = False
326 if depth == 1 and string_start >= 0:
327 pending_key = json_str[string_start + 1 : i]
328 continue
329 if c == '"':
330 in_string = True
331 string_start = i
332 elif c in ("{", "["):
333 depth += 1
334 elif c in ("}", "]"):
335 depth -= 1
336 elif c == ":" and depth == 1:
337 last_colon = i
338 last_key = pending_key
339 pending_key = None
340 if last_colon < 0:
341 return ""
342 end = last_colon + 1
343 while end < len(json_str) and json_str[end] in (" ", "\t", "\n", "\r"):
344 end += 1
345 if end >= len(json_str) or json_str[end] != '"':
346 return json_str[:end]
347 if string_keys is not None and last_key not in string_keys:
348 return json_str[:end]
349
350 escape = False
351 for i in range(end + 1, len(json_str)):
352 c = json_str[i]
353 if escape:
354 escape = False
355 continue
356 if c == "\\":
357 escape = True
358 continue
359 if c == '"':
360 return json_str[:i]
361 return json_str
362
363 @staticmethod
364 def _streamable_string_keys(properties: dict) -> set[str] | None:
365 """Return keys whose trailing string values can safely stream.
366
367 ``None`` means there is no schema, so all string values keep their
368 JSON representation as strings. With a schema, only fields that can
369 remain strings are safe to emit before the value is closed; fields
370 coerced to bool/number/null/object/array may serialize differently.
371 """
372 if not properties:
373 return None
374
375 streamable: set[str] = set()
376 for key, schema in properties.items():
377 if set(extract_types_from_schema(schema)) == {"string"}:
378 streamable.add(key)
379 return streamable
380
381 def _fix_arg_types(self, args_json: str, func_name: str) -> str:
382 """Correct parameter types using the tool schema.
383
384 String values are coerced via :func:`coerce_to_schema_type`.
385 Nested objects and arrays are recursed into when the schema
386 defines ``properties`` or ``items``. Without a schema, values
387 stay as strings.
388 """
389 if not self._tools or not func_name:
390 return args_json
391 try:
392 args = json.loads(args_json)
393 except (json.JSONDecodeError, ValueError):
394 return args_json
395 if not isinstance(args, dict):
396 return args_json
397
398 properties = find_tool_properties(self._tools, func_name)
399 if not properties:
400 return args_json
401
402 _, changed = self._coerce_dict(args, properties)
403
404 if changed:
405 return json.dumps(args, ensure_ascii=False)
406 return args_json
407
408 def _is_valid_tool_name(self, name: str) -> bool:
409 if not self.parser_engine_config.validate_tool_names:
410 return True
411 if not self._tools:
412 return True
413 return find_tool_name(self._tools, name)
414
415 def _accept_tool_name(self, name: str) -> bool:
416 return bool(name) and self._is_valid_tool_name(name)
417
418 # ── Private helpers ─────────────────────────────────────────────
419
420 def _check_skip_tool_parsing(
421 self,
422 request: ChatCompletionRequest | ResponsesRequest,
423 ) -> None:
424 tools = getattr(request, "tools", None)
425 if tools:
426 self._tools = tools
427 if not self.skip_tool_parsing and not self._suppress_tool_calls:
428 tool_choice = getattr(request, "tool_choice", None)
429 if tool_choice == "none" and tools:
430 self._suppress_tool_calls = True
431
432 def _strip_content_whitespace(
433 self,
434 content: str,
435 tools_called: bool,
436 ) -> str | None:
437 if tools_called:
438 if self._strip_content_ws_with_tools:
439 content = content.strip()
440 elif self._drop_ws_only_content_before_tools and not content.strip():
441 content = ""
442 return content or None
443
444 # ── Streaming: parse_delta ────────────────────────────────────────
445
446 def parse_delta(
447 self,
448 delta_text: str,
449 delta_token_ids: list[int],
450 request: ChatCompletionRequest | ResponsesRequest,
451 prompt_token_ids: list[int] | None = None,
452 *,
453 finished: bool,
454 ) -> DeltaMessage | None:
455 self._initialize_history_tool_call_cnt(request)
456 if not self._prompt_streaming_prepared and prompt_token_ids is not None:
457 # NOTE: call the hook BEFORE setting the flag, because the hook
458 # may invoke ``_reset`` (e.g. via ``initialize_streaming``) which
459 # clears ``_prompt_streaming_prepared``.
460 self.adjust_initial_state_from_prompt(prompt_token_ids)
461 self._prompt_streaming_prepared = True
462 self._check_skip_tool_parsing(request)
463 events = self._feed(delta_text, delta_token_ids)
464 if finished:
465 events.extend(self._engine.finish())
466 result = self._events_to_delta(events, finished=finished)
467 result = self._strip_trailing_reasoning(result)
468
469 # Suppress reasoning deltas if not requested
470 if result and not request.include_reasoning:
471 result.reasoning = None
472 if not result.content and not result.tool_calls:
473 result = None
474
475 return result
476
477 def _strip_trailing_reasoning(
478 self,
479 delta: DeltaMessage | None,
480 ) -> DeltaMessage | None:
481 """Strip trailing whitespace from reasoning, deferring it until we
482 know whether more reasoning follows or reasoning has ended.
483
484 Runs in ``parse_delta`` *after* ``_events_to_delta`` (and any
485 subclass overrides) so that overrides see the raw reasoning text.
486
487 Gated by ``strip_trailing_reasoning_whitespace``; when disabled,
488 passes through unchanged.
489 """
490 if not self._strip_trailing_reasoning_ws:
491 return delta
492 if delta is not None and delta.reasoning is not None:
493 combined = self._deferred_reasoning + delta.reasoning
494 trimmed = combined.rstrip()
495 self._deferred_reasoning = combined[len(trimmed) :]
496 delta.reasoning = trimmed or None
497 if (
498 delta.reasoning is None
499 and delta.content is None
500 and not delta.tool_calls
501 ):
502 return None
503 elif self._deferred_reasoning and self._reasoning_ended:
504 self._deferred_reasoning = ""
505 return delta
506
507 # ── Non-streaming: extract_reasoning ──────────────────────────────
508
509 def extract_reasoning(
510 self,
511 model_output: str,
512 request: ChatCompletionRequest | ResponsesRequest,
513 ) -> tuple[str | None, str | None]:
514 self._reset()
515 events = self._feed(model_output, [])
516 events.extend(self._engine.finish())
517
518 reasoning_parts: list[str] = []
519 content_parts: list[str] = []
520
521 for event in events:
522 if event.type == EventType.REASONING_CHUNK:
523 reasoning_parts.append(event.value)
524 elif event.type == EventType.TEXT_CHUNK:
525 content_parts.append(event.value)
526 elif event.type == EventType.REASONING_END:
527 self._reasoning_ended = True
528
529 raw_reasoning = "".join(reasoning_parts)
530 if self._strip_trailing_reasoning_ws:
531 raw_reasoning = raw_reasoning.rstrip()
532 reasoning = raw_reasoning or None
533 content = "".join(content_parts) or None
534 return reasoning, content
535
536 # ── Non-streaming: extract_reasoning_streaming ────────────────────
537
538 def extract_reasoning_streaming(
539 self,
540 previous_text: str,
541 current_text: str,
542 delta_text: str,
543 previous_token_ids: Sequence[int],
544 current_token_ids: Sequence[int],
545 delta_token_ids: Sequence[int],
546 ) -> DeltaMessage | None:
547 self.initialize_streaming()
548 events = self._feed(delta_text, delta_token_ids)
549 return self._strip_trailing_reasoning(self._events_to_delta(events))
550
551 # ── Non-streaming: extract_tool_calls ─────────────────────────────
552
553 def extract_tool_calls(
554 self,
555 model_output: str,
556 request: ChatCompletionRequest | ResponsesRequest,
557 ) -> ExtractedToolCallInformation:
558 self._reset()
559 self._streaming_initialized = True
560 result = self.extract_tool_calls_streaming(
561 previous_text="",
562 current_text=model_output,
563 delta_text=model_output,
564 previous_token_ids=[],
565 current_token_ids=[],
566 delta_token_ids=[],
567 request=request,
568 )
569 finish_delta = self.finish_streaming()
570 return self._build_extracted_result(result, finish_delta)
571
572 def extract_tool_calls_from_content(
573 self,
574 content: str,
575 request: ChatCompletionRequest,
576 ) -> ExtractedToolCallInformation:
577 """Extract tool calls from reasoning-stripped content.
578
579 Unlike :meth:`extract_tool_calls` which re-parses the full model
580 output, this method starts the parser engine in ``CONTENT`` state
581 so it can parse content that has already had reasoning stripped.
582 """
583 self._check_skip_tool_parsing(request)
584 _, parsed_content, tool_call_info = self._single_pass_parse(
585 content,
586 [],
587 initial_state=ParserState.CONTENT,
588 )
589 if parsed_content is not None and tool_call_info.content is None:
590 tool_call_info = ExtractedToolCallInformation(
591 tools_called=tool_call_info.tools_called,
592 tool_calls=tool_call_info.tool_calls,
593 content=parsed_content,
594 )
595 return tool_call_info
596
597 def extract_tool_calls_streaming(
598 self,
599 previous_text: str,
600 current_text: str,
601 delta_text: str,
602 previous_token_ids: Sequence[int],
603 current_token_ids: Sequence[int],
604 delta_token_ids: Sequence[int],
605 request: ChatCompletionRequest | ResponsesRequest,
606 ) -> DeltaMessage | None:
607 self.initialize_streaming()
608 self._check_skip_tool_parsing(request)
609 events = self._feed(delta_text, delta_token_ids)
610 return self._strip_trailing_reasoning(self._events_to_delta(events))
611
612 # ── Reasoning state queries ───────────────────────────────────────
613
614 @staticmethod
615 def _derive_reasoning_end_token_ids(
616 config: ParserEngineConfig, vocab: dict[str, int]
617 ) -> frozenset[int]:
618 end_terminals: dict[str, ParserState] = {}
619 for (state, terminal), transition in config.transitions.items():
620 if state != ParserState.REASONING:
621 continue
622 if EventType.REASONING_END in transition.events:
623 end_terminals.setdefault(terminal, transition.next_state)
624 elif transition.next_state != ParserState.REASONING:
625 return frozenset()
626
627 token_ids: set[int] = set()
628 for terminal, next_state in end_terminals.items():
629 text = config.token_id_terminals.get(terminal)
630 token_id = vocab.get(text) if text is not None else None
631 if token_id is not None:
632 token_ids.add(token_id)
633 elif next_state == ParserState.CONTENT:
634 return frozenset()
635 return frozenset(token_ids)
636
637 @property
638 def reasoning_end_token_ids(self) -> frozenset[int]:
639 return self._reasoning_end_token_ids
640
641 def find_reasoning_end_offset(self, token_ids: Sequence[int]) -> int | None:
642 end_ids = self._reasoning_end_token_ids
643 if not end_ids:
644 return None
645 for offset, token_id in enumerate(token_ids):
646 if token_id in end_ids:
647 return offset
648 return len(token_ids)
649
650 def is_reasoning_end(self, input_ids: list[int]) -> bool:
651 config = self.parser_engine_config
652 wait_for_reasoning = config.wait_for_reasoning
653 if wait_for_reasoning is None:
654 wait_for_reasoning = config.initial_state is ParserState.REASONING
655 start_transition = config.transitions.get((config.initial_state, "THINK_START"))
656 start_opens_reasoning = (
657 start_transition is not None
658 and start_transition.next_state is ParserState.REASONING
659 )
660 end_ids = self._reasoning_end_token_ids
661 start_id = self._reasoning_start_token_id
662 boundary_ids = self._turn_boundary_token_ids
663 for token_id in reversed(input_ids):
664 if token_id in end_ids:
665 return True
666 if token_id == start_id:
667 if start_opens_reasoning:
668 return False
669 break
670 if token_id in boundary_ids:
671 break
672 return not wait_for_reasoning
673
674 def extract_content_ids(self, input_ids: list[int]) -> list[int]:
675 config = self.parser_engine_config
676 wait_for_reasoning = config.wait_for_reasoning
677 if wait_for_reasoning is None:
678 wait_for_reasoning = config.initial_state is ParserState.REASONING
679 if not wait_for_reasoning:
680 return input_ids
681
682 end_id = self._reasoning_end_token_id
683 if end_id is not None:
684 for i in range(len(input_ids) - 1, -1, -1):
685 if input_ids[i] == end_id:
686 return input_ids[i + 1 :]
687
688 end_ids = self._reasoning_end_token_ids
689 if end_ids:
690 turn_start = 0
691 boundary_ids = self._turn_boundary_token_ids
692 for i in range(len(input_ids) - 1, -1, -1):
693 if input_ids[i] in boundary_ids:
694 turn_start = i + 1
695 break
696 for i in range(turn_start, len(input_ids)):
697 if input_ids[i] in end_ids:
698 return input_ids[i:]
699 return input_ids
700
701 def get_streaming_fallback_content(
702 self,
703 text: str,
704 request: ChatCompletionRequest | ResponsesRequest,
705 ) -> str | None:
706 return None
707
708 def count_reasoning_tokens(self, token_ids: Sequence[int]) -> int:
709 """Return reasoning tokens observed by the parser engine so far."""
710 return self._engine.reasoning_token_count
711
712 # ── Single-pass parse helper ────────────────────────────────────────
713
714 def _single_pass_parse(
715 self,
716 text: str,
717 token_ids: Sequence[int],
718 initial_state: ParserState | None = None,
719 ) -> tuple[str | None, str | None, ExtractedToolCallInformation]:
720 """Reset, feed, finish, and extract results in one pass.
721
722 Must be called as a unit — ``_events_to_delta`` populates tool
723 state that ``_build_extracted_result`` reads.
724 """
725 self._reset(initial_state=initial_state)
726 events = self._feed(text, token_ids)
727 events.extend(self._engine.finish())
728
729 delta = self._events_to_delta(events, finished=True)
730 tool_call_info = self._build_extracted_result()
731
732 reasoning = delta.reasoning if delta else None
733 if reasoning and self._strip_trailing_reasoning_ws:
734 reasoning = reasoning.rstrip() or None
735
736 content = delta.content if delta else None
737 if content:
738 content = self._strip_content_whitespace(
739 content, tool_call_info.tools_called
740 )
741
742 return reasoning, content, tool_call_info
743
744 # ── Non-streaming: parse ───────────────────────────────────────────
745
746 def parse(
747 self,
748 model_output: str,
749 request: ChatCompletionRequest | ResponsesRequest,
750 enable_auto_tools: bool = False,
751 model_output_token_ids: Sequence[int] = (),
752 ) -> tuple[str | None, str | None, list[FunctionCall] | None]:
753 self._initialize_history_tool_call_cnt(request)
754 self._check_skip_tool_parsing(request)
755 reasoning, content, tool_call_info = self._single_pass_parse(
756 model_output,
757 model_output_token_ids,
758 )
759
760 tool_calls: list[FunctionCall] | None = None
761 if tool_call_info.tools_called:
762 tool_calls = [
763 FunctionCall(
764 id=tc.id,
765 name=tc.function.name,
766 arguments=tc.function.arguments,
767 )
768 for tc in tool_call_info.tool_calls
769 ]
770
771 return reasoning, content, tool_calls
772
773 # ── Event-to-delta conversion ─────────────────────────────────────
774
775 def _events_to_delta(
776 self,
777 events: list[SemanticEvent],
778 finished: bool = False,
779 ) -> DeltaMessage | None:
780 if not events and not self._deferred_content:
781 return None
782
783 tool_call_deltas: list[DeltaToolCall] = []
784 content_parts: list[str] = []
785 reasoning_parts: list[str] = []
786
787 carried_deferred = self._deferred_content
788 seen_tool_event = False
789 suppress = self._suppress_tool_calls
790 for event in events:
791 match event.type:
792 case EventType.TEXT_CHUNK:
793 if seen_tool_event:
794 self._deferred_content += event.value
795 else:
796 content_parts.append(event.value)
797 case EventType.REASONING_CHUNK:
798 reasoning_parts.append(event.value)
799 case EventType.REASONING_END:
800 self._reasoning_ended = True
801 case EventType.TOOL_CALL_START:
802 if not suppress:
803 seen_tool_event = True
804 self._ensure_slot(event.tool_index)
805 case EventType.TOOL_NAME:
806 if not suppress:
807 seen_tool_event = True
808 self._handle_tool_name(event)
809 case EventType.ARG_VALUE_CHUNK:
810 if not suppress:
811 seen_tool_event = True
812 self._handle_arg_chunk(event, tool_call_deltas)
813 case EventType.TOOL_CALL_END:
814 if not suppress:
815 seen_tool_event = True
816 self._handle_tool_end(event, tool_call_deltas)
817 case EventType.REASONING_START:
818 pass # no delta-level effect
819
820 if len(tool_call_deltas) > 1:
821 tool_call_deltas = self._coalesce_tool_call_deltas(tool_call_deltas)
822
823 if self._deferred_content and (not seen_tool_event or not tool_call_deltas):
824 # Deferred content carried in from a previous delta precedes this
825 # delta's content; content deferred during this call (text after
826 # an unpromoted tool event) follows it.
827 deferred_now = self._deferred_content[len(carried_deferred) :]
828 if carried_deferred:
829 content_parts.insert(0, carried_deferred)
830 if deferred_now:
831 content_parts.append(deferred_now)
832 self._deferred_content = ""
833
834 content_str = "".join(content_parts)
835
836 if self._content_has_nonws:
837 pass
838 elif content_str:
839 stripped = content_str.strip()
840 if stripped:
841 self._content_has_nonws = True
842 elif self._tool_slots:
843 if self._drop_ws_only_content_before_tools:
844 content_str = ""
845 elif not finished:
846 self._deferred_content = content_str
847 content_str = ""
848
849 content = content_str or None
850 reasoning = "".join(reasoning_parts) or None
851
852 if content or tool_call_deltas or reasoning:
853 kwargs: dict[str, object] = {}
854 if content is not None:
855 kwargs["content"] = content
856 if reasoning is not None:
857 kwargs["reasoning"] = reasoning
858 if tool_call_deltas:
859 kwargs["tool_calls"] = tool_call_deltas
860 return DeltaMessage(**kwargs)
861 return None
862
863 def _ensure_slot(self, idx: int) -> None:
864 while len(self._tool_slots) <= idx:
865 self._tool_slots.append(ToolCallSlot())
866
867 def _ensure_tool_id(self, slot: ToolCallSlot, name: str) -> None:
868 if not slot.id:
869 state = self._stream_state
870 slot.id = make_tool_call_id(
871 id_type=state.tool_call_id_type,
872 func_name=name,
873 idx=state.history_tool_call_cnt,
874 )
875 state.history_tool_call_cnt += 1
876
877 def _handle_tool_name(self, event: SemanticEvent) -> None:
878 idx = event.tool_index
879 self._tool_slots[idx].name += event.value
880
881 def _emit_name_delta(
882 self,
883 idx: int,
884 deltas: list[DeltaToolCall],
885 name: str | None,
886 ) -> None:
887 if name is None or not self._accept_tool_name(name):
888 return
889 slot = self._tool_slots[idx]
890 slot.name = name
891 slot.name_sent = True
892 slot.string_keys = self._streamable_string_keys(
893 find_tool_properties(self._tools, name)
894 )
895 self._ensure_tool_id(slot, name)
896 deltas.append(
897 DeltaToolCall(
898 index=idx,
899 id=slot.id,
900 type="function",
901 function=DeltaFunctionCall(name=name),
902 )
903 )
904
905 def _handle_arg_chunk(
906 self,
907 event: SemanticEvent,
908 deltas: list[DeltaToolCall],
909 ) -> None:
910 idx = event.tool_index
911 slot = self._tool_slots[idx]
912 if event.value:
913 slot.append_args(event.value)
914
915 if not slot.name_sent:
916 if slot.name:
917 self._emit_name_delta(idx, deltas, slot.name)
918 elif event.value:
919 # Name not yet known — try to extract from accumulated args
920 name = self._try_extract_name(idx)
921 self._emit_name_delta(idx, deltas, name)
922 elif event.value:
923 # Name already sent — emit arg delta
924 arg_delta = self._compute_arg_delta(idx, event.value)
925 if arg_delta:
926 deltas.append(
927 DeltaToolCall(
928 index=idx,
929 function=DeltaFunctionCall(arguments=arg_delta),
930 )
931 )
932
933 def _handle_tool_end(
934 self,
935 event: SemanticEvent,
936 deltas: list[DeltaToolCall],
937 ) -> None:
938 idx = event.tool_index
939 if idx >= len(self._tool_slots):
940 return
941
942 remaining = self._flush_arg_converter(idx)
943 slot = self._tool_slots[idx]
944
945 if not slot.name_sent:
946 name = slot.name or self._try_extract_name(idx) or ""
947 if self._accept_tool_name(name):
948 slot.name = name
949 slot.name_sent = True
950 slot.string_keys = self._streamable_string_keys(
951 find_tool_properties(self._tools, name)
952 )
953 self._ensure_tool_id(slot, name)
954 deltas.append(
955 DeltaToolCall(
956 index=idx,
957 id=slot.id,
958 type="function",
959 function=DeltaFunctionCall(
960 name=name,
961 arguments=remaining or "",
962 ),
963 )
964 )
965 remaining = None
966
967 if remaining and slot.name_sent:
968 deltas.append(
969 DeltaToolCall(
970 index=idx,
971 function=DeltaFunctionCall(arguments=remaining),
972 )
973 )
974
975 # ── Tool-call delta coalescing ──────────────────────────────────────
976
977 @staticmethod
978 def _coalesce_tool_call_deltas(
979 deltas: list[DeltaToolCall],
980 ) -> list[DeltaToolCall]:
981 """Merge entries that share the same index into one per index."""
982 merged: dict[int, DeltaToolCall] = {}
983 for tc in deltas:
984 existing = merged.get(tc.index)
985 if existing is None:
986 merged[tc.index] = tc
987 continue
988 if tc.id is not None and existing.id is None:
989 existing.id = tc.id
990 if tc.type is not None and existing.type is None:
991 existing.type = tc.type
992 if tc.function is not None:
993 if existing.function is None:
994 existing.function = tc.function
995 else:
996 if tc.function.name is not None and existing.function.name is None:
997 existing.function.name = tc.function.name
998 if tc.function.arguments is not None:
999 if existing.function.arguments is None:
1000 existing.function.arguments = tc.function.arguments
1001 else:
1002 existing.function.arguments += tc.function.arguments
1003 if len(merged) == len(deltas):
1004 return deltas
1005 return list(merged.values())
1006
1007 # ── Arg conversion helpers ─────────────────────────────────────────
1008
1009 def _compute_arg_delta(self, idx: int, raw_delta: str) -> str | None:
1010 converter = self._arg_converter
1011 if converter is None:
1012 return raw_delta
1013
1014 if not self._stream_arg_deltas:
1015 return None
1016
1017 structural = self._arg_structural_chars
1018 if structural is not None and structural.isdisjoint(raw_delta):
1019 return None
1020
1021 slot = self._tool_slots[idx]
1022 try:
1023 current_json = converter(slot.args, True)
1024 except (json.JSONDecodeError, ValueError, TypeError):
1025 logger.debug("arg converter failed (streaming): %s", slot.args[:80])
1026 return None
1027
1028 if not current_json:
1029 return None
1030
1031 if slot.name:
1032 current_json = self._fix_arg_types(current_json, slot.name)
1033
1034 prev = slot.streamed_json
1035 safe_json = self._safe_arg_prefix(current_json, slot.string_keys)
1036
1037 if not safe_json or safe_json == prev:
1038 return None
1039
1040 if prev:
1041 if not safe_json.startswith(prev):
1042 return None
1043 diff = safe_json[len(prev) :]
1044 else:
1045 diff = safe_json
1046
1047 if diff:
1048 slot.streamed_json = safe_json
1049 return diff
1050 return None
1051
1052 def _flush_arg_converter(self, idx: int) -> str | None:
1053 converter = self._arg_converter
1054 if converter is None:
1055 return None
1056
1057 slot = self._tool_slots[idx]
1058 try:
1059 final_json = converter(slot.args, False)
1060 except (json.JSONDecodeError, ValueError, TypeError):
1061 logger.debug("arg converter failed (flush): %s", slot.args[:80])
1062 return None
1063
1064 if final_json:
1065 final_json = self._fix_arg_types(final_json, slot.name)
1066
1067 prev = slot.streamed_json
1068 if final_json and len(final_json) > len(prev):
1069 if prev and not final_json.startswith(prev):
1070 return None
1071 diff = final_json[len(prev) :]
1072 slot.streamed_json = final_json
1073 return diff
1074 return None
1075
1076 _NAME_RE = re.compile(r'"name"\s*:\s*"([^"]*)"')
1077
1078 def _try_extract_name(self, idx: int) -> str | None:
1079 m = self._NAME_RE.search(self._tool_slots[idx].args)
1080 if m:
1081 name = m.group(1)
1082 if name:
1083 return name
1084 return None
1085
1086 # ── Build ExtractedToolCallInformation ─────────────────────────────
1087
1088 def _build_extracted_result(
1089 self,
1090 *deltas: DeltaMessage | None,
1091 ) -> ExtractedToolCallInformation:
1092 content_parts: list[str] = []
1093 for delta in deltas:
1094 if delta is not None and delta.content:
1095 content_parts.append(delta.content)
1096
1097 tool_calls: list[ToolCall] = []
1098 for idx, slot in enumerate(self._tool_slots):
1099 if not slot.name and not slot.args:
1100 continue
1101
1102 name = slot.name.strip()
1103 raw_body = slot.args
1104
1105 if not name and raw_body.strip():
1106 name, args_json = self._extract_name_and_args(raw_body)
1107 elif raw_body.strip():
1108 converter = self._arg_converter
1109 if converter is not None:
1110 try:
1111 args_json = converter(raw_body, False)
1112 except (json.JSONDecodeError, ValueError, TypeError):
1113 logger.debug(
1114 "arg converter failed (extract): %s", raw_body[:80]
1115 )
1116 args_json = self._extract_args_json(raw_body, name)
1117 else:
1118 args_json = self._extract_args_json(raw_body, name)
1119 else:
1120 args_json = "{}"
1121
1122 if self._accept_tool_name(name):
1123 self._ensure_tool_id(slot, name)
1124 args_json = self._fix_arg_types(args_json, name)
1125 tool_calls.append(
1126 ToolCall(
1127 id=slot.id,
1128 function=FunctionCall(name=name, arguments=args_json),
1129 )
1130 )
1131
1132 content_str = "".join(content_parts)
1133 content = self._strip_content_whitespace(content_str, len(tool_calls) > 0)
1134
1135 return ExtractedToolCallInformation(
1136 tools_called=len(tool_calls) > 0,
1137 tool_calls=tool_calls,
1138 content=content,
1139 )
1140
1141 @staticmethod
1142 def _extract_args_value(parsed: dict) -> str | None:
1143 for key in ("arguments", "parameters"):
1144 if key in parsed:
1145 val = parsed[key]
1146 if isinstance(val, str):
1147 return val
1148 return json.dumps(val, ensure_ascii=False)
1149 return None
1150
1151 def _extract_name_and_args(
1152 self,
1153 raw_body: str,
1154 ) -> tuple[str, str]:
1155 raw_body = raw_body.strip()
1156 try:
1157 parsed = json.loads(raw_body)
1158 except json.JSONDecodeError:
1159 return "", raw_body
1160
1161 if not isinstance(parsed, dict):
1162 return "", raw_body
1163
1164 name = parsed.get("name", "")
1165 args = self._extract_args_value(parsed)
1166 if args is not None:
1167 return name, args
1168
1169 without_name = {k: v for k, v in parsed.items() if k != "name"}
1170 return name, json.dumps(without_name, ensure_ascii=False)
1171
1172 def _extract_args_json(self, raw_args: str, func_name: str) -> str:
1173 if not raw_args.strip():
1174 return "{}"
1175 _, args = self._extract_name_and_args(raw_args)
1176 return args