| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | |
| 4 | from collections.abc import Iterable, Sequence |
| 5 | |
| 6 | from openai.types.responses import ResponseFunctionToolCall |
| 7 | |
| 8 | from vllm.entrypoints.chat_utils import ChatCompletionMessageParam |
| 9 | from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest |
| 10 | from vllm.entrypoints.openai.responses.protocol import ( |
| 11 | ResponseInputOutputItem, |
| 12 | ResponsesRequest, |
| 13 | ) |
| 14 | |
| 15 | |
| 16 | def count_tool_calls(tool_calls: object) -> int: |
| 17 | if tool_calls is None: |
| 18 | return 0 |
| 19 | if isinstance(tool_calls, (str, bytes, dict)): |
| 20 | return 1 |
| 21 | if isinstance(tool_calls, Iterable): |
| 22 | return sum(1 for _ in tool_calls) |
| 23 | return 1 |
| 24 | |
| 25 | |
| 26 | def count_chat_history_tool_calls( |
| 27 | messages: Sequence[ChatCompletionMessageParam], |
| 28 | ) -> int: |
| 29 | return sum( |
| 30 | count_tool_calls(msg.get("tool_calls")) |
| 31 | for msg in messages |
| 32 | if isinstance(msg, dict) and msg.get("role") == "assistant" |
| 33 | ) |
| 34 | |
| 35 | |
| 36 | def count_response_history_tool_calls( |
| 37 | response_items: Sequence[ResponseInputOutputItem], |
| 38 | ) -> int: |
| 39 | count = 0 |
| 40 | for item in response_items: |
| 41 | if isinstance(item, ResponseFunctionToolCall): |
| 42 | count += 1 |
| 43 | continue |
| 44 | |
| 45 | if isinstance(item, dict): |
| 46 | item_type = item.get("type") |
| 47 | if item_type == "function_call": |
| 48 | count += 1 |
| 49 | elif item.get("role") == "assistant": |
| 50 | count += count_tool_calls(item.get("tool_calls")) |
| 51 | |
| 52 | return count |
| 53 | |
| 54 | |
| 55 | def count_history_tool_calls( |
| 56 | request: ChatCompletionRequest | ResponsesRequest, |
| 57 | ) -> int: |
| 58 | if isinstance(request, ChatCompletionRequest): |
| 59 | return count_chat_history_tool_calls(request.messages) |
| 60 | |
| 61 | request_input = request.input |
| 62 | if isinstance(request_input, str): |
| 63 | return 0 |
| 64 | |
| 65 | return count_response_history_tool_calls(request_input) |
| 66 | |
| 67 | |
| 68 | _JSON_WS = " \t\r\n" |
| 69 | |
| 70 | |
| 71 | def scan_json_object_end(raw: str, start: int) -> int | None: |
| 72 | """Return the end index (exclusive) of the JSON object starting at |
| 73 | ``raw[start]`` (which must be ``{``), or ``None`` when it is still |
| 74 | unterminated. String- and escape-aware so braces inside strings do not |
| 75 | mislead the depth count.""" |
| 76 | depth = 0 |
| 77 | in_string = False |
| 78 | escape = False |
| 79 | for i in range(start, len(raw)): |
| 80 | ch = raw[i] |
| 81 | if escape: |
| 82 | escape = False |
| 83 | continue |
| 84 | if in_string: |
| 85 | if ch == "\\": |
| 86 | escape = True |
| 87 | elif ch == '"': |
| 88 | in_string = False |
| 89 | continue |
| 90 | if ch == '"': |
| 91 | in_string = True |
| 92 | elif ch == "{": |
| 93 | depth += 1 |
| 94 | elif ch == "}": |
| 95 | depth -= 1 |
| 96 | if depth == 0: |
| 97 | return i + 1 |
| 98 | return None |
| 99 | |
| 100 | |
| 101 | def extract_json_member_object(raw: str, key: str) -> str | None: |
| 102 | """Extract the raw text span of the top-level object-valued member ``key`` |
| 103 | from a (possibly incomplete) ``{...}`` JSON wrapper. |
| 104 | |
| 105 | Returns the verbatim substring (prefix-stable as the input grows, which |
| 106 | the engine's argument-delta diffing relies on), possibly an unterminated |
| 107 | object prefix; ``None`` when the value has not started. Raises |
| 108 | ``ValueError`` when the member is present but its value is not a JSON |
| 109 | object. A verbatim substring (not re-serialized JSON) is required because |
| 110 | the engine diffs successive converter outputs and expects each to extend |
| 111 | the previous one; the scanner is string/escape-aware so a matching literal |
| 112 | inside another value cannot mislead it, and it recovers a partial span when |
| 113 | the wrapper is truncated (where ``json.loads`` would fail).""" |
| 114 | depth = 0 |
| 115 | in_string = False |
| 116 | escape = False |
| 117 | string_start = -1 |
| 118 | last_string: str | None = None |
| 119 | for i, ch in enumerate(raw): |
| 120 | if escape: |
| 121 | escape = False |
| 122 | continue |
| 123 | if in_string: |
| 124 | if ch == "\\": |
| 125 | escape = True |
| 126 | elif ch == '"': |
| 127 | in_string = False |
| 128 | if depth == 1: |
| 129 | last_string = raw[string_start + 1 : i] |
| 130 | continue |
| 131 | if ch == '"': |
| 132 | in_string = True |
| 133 | string_start = i |
| 134 | elif ch == ":" and depth == 1 and last_string == key: |
| 135 | value_start = i + 1 |
| 136 | while value_start < len(raw) and raw[value_start] in _JSON_WS: |
| 137 | value_start += 1 |
| 138 | if value_start >= len(raw): |
| 139 | return None |
| 140 | if raw[value_start] != "{": |
| 141 | raise ValueError(f"member {key!r} must be a JSON object") |
| 142 | value_end = scan_json_object_end(raw, value_start) |
| 143 | if value_end is None: |
| 144 | return raw[value_start:] |
| 145 | return raw[value_start:value_end] |
| 146 | elif ch in "{[": |
| 147 | depth += 1 |
| 148 | elif ch in "}]": |
| 149 | depth -= 1 |
| 150 | return None |