Signed-off-by: Harry Mellor <19981378+hmellor@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
418 lines
16 KiB
Python
418 lines
16 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
|
"""Inkling parser: typed content blocks parsed by a single state machine.
|
|
|
|
Inkling output format (every marker is a dedicated special token)::
|
|
|
|
<|message_model|><|content_thinking|>...reasoning...<|end_message|>
|
|
<|message_model|><|content_text|>...visible text...<|end_message|>
|
|
<|message_model|><|content_invoke_tool_json|>
|
|
{"name":"get_weather","args":{"city":"SF"}}<|end_message|>
|
|
|
|
Blocks are self-describing and may repeat in any order; sampling may
|
|
also end a block with the standalone ``<|content_model_end_sampling|>``
|
|
token. The tool-call payload is a single JSON object whose ``name`` is
|
|
extracted by the engine's name-from-args path and whose ``args`` object
|
|
is carved out of the wrapper by :func:`_inkling_arg_converter`.
|
|
|
|
Note the terminal *labels*: ``THINK_START``/``THINK_END`` are what the
|
|
engine keys its reasoning plumbing on (``is_reasoning_end``,
|
|
``count_reasoning_tokens``, initial-state seeding), so ``<|end_message|>``
|
|
is labelled ``THINK_END`` here even though it ends every block kind —
|
|
the transition table, not the label, carries the semantics.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import functools
|
|
import json
|
|
from collections.abc import Sequence
|
|
from typing import TYPE_CHECKING
|
|
|
|
from vllm.entrypoints.generate.base.protocol import ExtractedToolCallInformation
|
|
from vllm.parser.engine.events import EventType
|
|
from vllm.parser.engine.parser_engine import ParserEngine
|
|
from vllm.parser.engine.parser_engine_config import (
|
|
ParserEngineConfig,
|
|
ParserState,
|
|
Transition,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from vllm.tokenizers import TokenizerLike
|
|
from vllm.tool_parsers.abstract_tool_parser import Tool
|
|
|
|
MESSAGE_MODEL = "<|message_model|>"
|
|
CONTENT_TEXT = "<|content_text|>"
|
|
CONTENT_THINKING = "<|content_thinking|>"
|
|
CONTENT_INVOKE_TOOL_JSON = "<|content_invoke_tool_json|>"
|
|
CONTENT_INVOKE_TOOL_TEXT = "<|content_invoke_tool_text|>"
|
|
CONTENT_TOOL_ERROR = "<|content_tool_error|>"
|
|
CONTENT_MODEL_END_SAMPLING = "<|content_model_end_sampling|>"
|
|
END_MESSAGE = "<|end_message|>"
|
|
|
|
INKLING_SPECIAL_TOKENS = (
|
|
MESSAGE_MODEL,
|
|
CONTENT_TEXT,
|
|
CONTENT_THINKING,
|
|
CONTENT_INVOKE_TOOL_JSON,
|
|
CONTENT_INVOKE_TOOL_TEXT,
|
|
CONTENT_TOOL_ERROR,
|
|
CONTENT_MODEL_END_SAMPLING,
|
|
END_MESSAGE,
|
|
)
|
|
|
|
_WS = " \t\r\n"
|
|
|
|
|
|
def _scan_json_value(raw: str, start: int) -> int | None:
|
|
"""Return the end index (exclusive) of the JSON object starting at
|
|
``raw[start]``, or ``None`` when the object is still unterminated."""
|
|
depth = 0
|
|
in_string = False
|
|
escape = False
|
|
for i in range(start, len(raw)):
|
|
ch = raw[i]
|
|
if escape:
|
|
escape = False
|
|
continue
|
|
if in_string:
|
|
if ch == "\\":
|
|
escape = True
|
|
elif ch == '"':
|
|
in_string = False
|
|
continue
|
|
if ch == '"':
|
|
in_string = True
|
|
elif ch == "{":
|
|
depth += 1
|
|
elif ch == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
return i + 1
|
|
return None
|
|
|
|
|
|
def _args_value_span(raw: str) -> str | None:
|
|
"""Extract the raw text span of the top-level ``"args"`` value from a
|
|
(possibly incomplete) ``{"name":...,"args":{...}}`` wrapper.
|
|
|
|
Returns the verbatim substring (prefix-stable across growing input,
|
|
which the engine's argument-delta diffing relies on), possibly an
|
|
unterminated object prefix; ``None`` when the value has not started.
|
|
Raises ``ValueError`` when the value is not a JSON object.
|
|
"""
|
|
depth = 0
|
|
in_string = False
|
|
escape = False
|
|
string_start = -1
|
|
last_string: str | None = None
|
|
for i, ch in enumerate(raw):
|
|
if escape:
|
|
escape = False
|
|
continue
|
|
if in_string:
|
|
if ch == "\\":
|
|
escape = True
|
|
elif ch == '"':
|
|
in_string = False
|
|
if depth == 1:
|
|
last_string = raw[string_start + 1 : i]
|
|
continue
|
|
if ch == '"':
|
|
in_string = True
|
|
string_start = i
|
|
elif ch == ":" and depth == 1 and last_string == "args":
|
|
value_start = i + 1
|
|
while value_start < len(raw) and raw[value_start] in _WS:
|
|
value_start += 1
|
|
if value_start >= len(raw):
|
|
return None
|
|
if raw[value_start] != "{":
|
|
raise ValueError("Inkling tool call args must be a JSON object")
|
|
value_end = _scan_json_value(raw, value_start)
|
|
if value_end is None:
|
|
return raw[value_start:]
|
|
return raw[value_start:value_end]
|
|
elif ch in "{[":
|
|
depth += 1
|
|
elif ch in "}]":
|
|
depth -= 1
|
|
return None
|
|
|
|
|
|
def _inkling_arg_converter(raw_args: str, partial: bool) -> str:
|
|
"""Carve the ``args`` object out of the tool-call JSON wrapper.
|
|
|
|
Why a converter at all: the engine's ``tool_args_json`` machinery
|
|
treats the *entire* TOOL_ARGS text as the tool arguments, but Inkling's
|
|
payload is the ``{"name":...,"args":{...}}`` wrapper — without a
|
|
converter, ``_compute_arg_delta`` streams the wrapper verbatim into
|
|
the OpenAI ``arguments`` field (``converter is None -> raw delta``,
|
|
unconditionally; ``stream_arg_deltas=False`` does not stop it).
|
|
|
|
Why a hand-rolled scanner instead of (partial) ``json.loads`` +
|
|
``json.dumps``: the engine diffs successive converter outputs and
|
|
requires each to extend the previous one (``startswith``); a
|
|
violation silently drops argument deltas. Re-serialization changes
|
|
whitespace and closes unterminated structures differently across
|
|
ticks, so the only prefix-stable output is a verbatim substring of
|
|
the input. The scanner is also string/escape-aware so an ``"args"``
|
|
literal inside the name or a string value cannot mislead it, and it
|
|
still recovers a partial span when EOS truncates the wrapper (where
|
|
``json.loads`` would fail).
|
|
"""
|
|
span = _args_value_span(raw_args)
|
|
if span is None:
|
|
# No args value yet (streaming) or none at all (treat as empty).
|
|
return "" if partial else "{}"
|
|
return span
|
|
|
|
|
|
@functools.cache
|
|
def inkling_config() -> ParserEngineConfig:
|
|
terminals = {
|
|
"MSG_MODEL": MESSAGE_MODEL,
|
|
"TEXT_START": CONTENT_TEXT,
|
|
"THINK_START": CONTENT_THINKING,
|
|
"THINK_END": END_MESSAGE,
|
|
"END_SAMPLING": CONTENT_MODEL_END_SAMPLING,
|
|
"TOOL_START": CONTENT_INVOKE_TOOL_JSON,
|
|
"TOOL_TEXT": CONTENT_INVOKE_TOOL_TEXT,
|
|
"TOOL_ERROR": CONTENT_TOOL_ERROR,
|
|
}
|
|
transitions: dict[tuple[ParserState, str], Transition] = {
|
|
# ── Between blocks / inside a text block ──────────────────────
|
|
(ParserState.CONTENT, "MSG_MODEL"): Transition(
|
|
ParserState.MESSAGE_HEADER,
|
|
(),
|
|
),
|
|
# Opening a block that renders as visible content proves no reasoning
|
|
# is still open. Confirming that here is what lets DelegatingParser
|
|
# hand the following blocks to the tool pass: the reasoning pass only
|
|
# leaves its reasoning phase on an explicit reasoning-end event, and a
|
|
# response with no thinking block never emits one otherwise.
|
|
(ParserState.CONTENT, "TEXT_START"): Transition(
|
|
ParserState.CONTENT,
|
|
(EventType.REASONING_END,),
|
|
),
|
|
(ParserState.CONTENT, "THINK_START"): Transition(
|
|
ParserState.REASONING,
|
|
(EventType.REASONING_START,),
|
|
),
|
|
# A tool block confirms the same boundary, and is the one opener that
|
|
# can start a turn with no visible block ahead of it.
|
|
(ParserState.CONTENT, "TOOL_START"): Transition(
|
|
ParserState.TOOL_ARGS,
|
|
(EventType.REASONING_END, EventType.TOOL_CALL_START),
|
|
),
|
|
# Raw / error tool blocks render as visible text.
|
|
(ParserState.CONTENT, "TOOL_TEXT"): Transition(
|
|
ParserState.CONTENT,
|
|
(EventType.REASONING_END,),
|
|
),
|
|
(ParserState.CONTENT, "TOOL_ERROR"): Transition(
|
|
ParserState.CONTENT,
|
|
(EventType.REASONING_END,),
|
|
),
|
|
# The optional function name between the model-role and content-kind
|
|
# markers is metadata, not visible assistant content.
|
|
(ParserState.MESSAGE_HEADER, "MSG_MODEL"): Transition(
|
|
ParserState.MESSAGE_HEADER,
|
|
(),
|
|
),
|
|
(ParserState.MESSAGE_HEADER, "TEXT_START"): Transition(
|
|
ParserState.CONTENT,
|
|
(EventType.REASONING_END,),
|
|
),
|
|
(ParserState.MESSAGE_HEADER, "THINK_START"): Transition(
|
|
ParserState.REASONING,
|
|
(EventType.REASONING_START,),
|
|
),
|
|
(ParserState.MESSAGE_HEADER, "TOOL_START"): Transition(
|
|
ParserState.TOOL_ARGS,
|
|
(EventType.REASONING_END, EventType.TOOL_CALL_START),
|
|
),
|
|
(ParserState.MESSAGE_HEADER, "TOOL_TEXT"): Transition(
|
|
ParserState.CONTENT,
|
|
(EventType.REASONING_END,),
|
|
),
|
|
(ParserState.MESSAGE_HEADER, "TOOL_ERROR"): Transition(
|
|
ParserState.CONTENT,
|
|
(EventType.REASONING_END,),
|
|
),
|
|
# ── Inside a thinking block ───────────────────────────────────
|
|
(ParserState.REASONING, "THINK_START"): Transition(
|
|
ParserState.REASONING,
|
|
(),
|
|
),
|
|
# Defensive: tool call opening while a thinking block is still
|
|
# unclosed.
|
|
(ParserState.REASONING, "TOOL_START"): Transition(
|
|
ParserState.TOOL_ARGS,
|
|
(EventType.REASONING_END, EventType.TOOL_CALL_START),
|
|
),
|
|
}
|
|
# Block-end terminals behave identically regardless of label. A
|
|
# closed tool block returns to CONTENT (Inkling has no section wrapper;
|
|
# blocks of any kind may follow), which also keeps the block-kind
|
|
# and role tokens out of the engine's tool-terminal set so the
|
|
# skip_tool_parsing reasoning pass still classifies reasoning.
|
|
for end in ("THINK_END", "END_SAMPLING"):
|
|
transitions[(ParserState.CONTENT, end)] = Transition(
|
|
ParserState.CONTENT,
|
|
(),
|
|
)
|
|
transitions[(ParserState.REASONING, end)] = Transition(
|
|
ParserState.CONTENT,
|
|
(EventType.REASONING_END,),
|
|
)
|
|
transitions[(ParserState.TOOL_ARGS, end)] = Transition(
|
|
ParserState.CONTENT,
|
|
(EventType.TOOL_CALL_END,),
|
|
)
|
|
transitions[(ParserState.MESSAGE_HEADER, end)] = Transition(
|
|
ParserState.CONTENT,
|
|
(EventType.TEXT_CHUNK,),
|
|
)
|
|
|
|
return ParserEngineConfig(
|
|
name="inkling",
|
|
# Normal generation continues after a prompt-prefilled
|
|
# `<|message_model|>`. Non-streaming parsing receives only the generated
|
|
# suffix, so begin in the corresponding message-header state as well.
|
|
initial_state=ParserState.MESSAGE_HEADER,
|
|
wait_for_reasoning=True,
|
|
terminals=terminals,
|
|
# Inkling content-kind markers are the grammar. When the engine is
|
|
# used through DelegatingParser, the reasoning pass can hand the tool
|
|
# pass reconstructed text whose token-id slice no longer contains the
|
|
# content-kind marker that starts the tool block, so keep Inkling on
|
|
# the text grammar instead of token-id-only terminal matching.
|
|
token_id_terminals={},
|
|
transitions=transitions,
|
|
arg_converter=_inkling_arg_converter,
|
|
stream_arg_deltas=True,
|
|
tool_args_json=True,
|
|
strip_trailing_reasoning_whitespace=True,
|
|
drop_whitespace_only_content_before_tools=True,
|
|
strip_content_whitespace_with_tools=False,
|
|
validate_tool_names=False,
|
|
)
|
|
|
|
|
|
class InklingParser(ParserEngine):
|
|
CONFIG_NAME = "inkling"
|
|
|
|
def __init__(
|
|
self,
|
|
tokenizer: TokenizerLike,
|
|
tools: list[Tool] | None = None,
|
|
**kwargs,
|
|
) -> None:
|
|
kwargs.setdefault("parser_engine_config", inkling_config())
|
|
super().__init__(tokenizer, tools, **kwargs)
|
|
|
|
def adjust_initial_state_from_prompt(self, prompt_token_ids: Sequence[int]) -> None:
|
|
"""Seed the initial parsing state from the prompt tail.
|
|
|
|
Mirrors the Rust parser's ``initialize()``: scanning the prompt
|
|
backwards, the last relevant special token decides whether generation
|
|
continues inside a thinking block, a text block, a model message
|
|
header, or between blocks.
|
|
"""
|
|
vocab = self.vocab
|
|
thinking_id = vocab.get(CONTENT_THINKING)
|
|
text_id = vocab.get(CONTENT_TEXT)
|
|
model_id = vocab.get(MESSAGE_MODEL)
|
|
special_ids = {vocab[text] for text in INKLING_SPECIAL_TOKENS if text in vocab}
|
|
for token_id in reversed(prompt_token_ids):
|
|
if token_id == thinking_id:
|
|
self._engine.reset(initial_state=ParserState.REASONING)
|
|
self._streaming_initialized = True
|
|
return
|
|
if token_id == text_id:
|
|
self._engine.reset(initial_state=ParserState.CONTENT)
|
|
self._streaming_initialized = True
|
|
return
|
|
if token_id == model_id:
|
|
self._engine.reset(initial_state=ParserState.MESSAGE_HEADER)
|
|
self._streaming_initialized = True
|
|
return
|
|
if token_id in special_ids:
|
|
break
|
|
|
|
def is_reasoning_end(self, input_ids: list[int]) -> bool:
|
|
vocab = self.vocab
|
|
thinking_id = vocab.get(CONTENT_THINKING)
|
|
text_id = vocab.get(CONTENT_TEXT)
|
|
model_id = vocab.get(MESSAGE_MODEL)
|
|
end_sampling_id = vocab.get(CONTENT_MODEL_END_SAMPLING)
|
|
for token_id in reversed(input_ids):
|
|
if token_id in (thinking_id, model_id):
|
|
return False
|
|
if token_id in (text_id, end_sampling_id):
|
|
return True
|
|
return False
|
|
|
|
def count_reasoning_tokens(self, token_ids: Sequence[int]) -> int:
|
|
vocab = self.vocab
|
|
thinking_id = vocab.get(CONTENT_THINKING)
|
|
end_ids = {
|
|
token_id
|
|
for token_id in (
|
|
vocab.get(END_MESSAGE),
|
|
vocab.get(CONTENT_MODEL_END_SAMPLING),
|
|
)
|
|
if token_id is not None
|
|
}
|
|
in_reasoning = False
|
|
count = 0
|
|
for token_id in token_ids:
|
|
if token_id == thinking_id:
|
|
in_reasoning = True
|
|
continue
|
|
if token_id in end_ids:
|
|
in_reasoning = False
|
|
continue
|
|
if in_reasoning:
|
|
count += 1
|
|
return count
|
|
|
|
def _single_pass_parse(
|
|
self,
|
|
text: str,
|
|
token_ids: Sequence[int],
|
|
initial_state: ParserState | None = None,
|
|
) -> tuple[str | None, str | None, ExtractedToolCallInformation]:
|
|
reasoning, content, tool_call_info = super()._single_pass_parse(
|
|
text, token_ids, initial_state=initial_state
|
|
)
|
|
# The engine defers content that follows tool-call events within a
|
|
# single pass; Inkling allows text blocks after tool-call blocks, so
|
|
# flush the trailing text (matching the Rust unified parser).
|
|
if self._deferred_content:
|
|
trailing = self._deferred_content
|
|
self._deferred_content = ""
|
|
content = self._strip_content_whitespace(
|
|
(content or "") + trailing,
|
|
tool_call_info.tools_called,
|
|
)
|
|
tool_call_info = ExtractedToolCallInformation(
|
|
tools_called=tool_call_info.tools_called,
|
|
tool_calls=tool_call_info.tool_calls,
|
|
content=content,
|
|
)
|
|
return reasoning, content, tool_call_info
|
|
|
|
@staticmethod
|
|
def _extract_args_value(parsed: dict) -> str | None:
|
|
# Inkling wraps arguments under "args" rather than "arguments".
|
|
for key in ("args", "arguments", "parameters"):
|
|
if key in parsed:
|
|
val = parsed[key]
|
|
if isinstance(val, str):
|
|
return val
|
|
return json.dumps(val, ensure_ascii=False)
|
|
return None
|