Skip to content

vllm.parser.inkling

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.

Classes:

InklingParser

Bases: ParserEngine

Methods:

Source code in vllm/parser/inkling.py
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

adjust_initial_state_from_prompt(prompt_token_ids)

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.

Source code in vllm/parser/inkling.py
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

_args_value_span(raw)

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.

Source code in vllm/parser/inkling.py
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

_inkling_arg_converter(raw_args, partial)

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).

Source code in vllm/parser/inkling.py
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

_scan_json_value(raw, start)

Return the end index (exclusive) of the JSON object starting at raw[start], or None when the object is still unterminated.

Source code in vllm/parser/inkling.py
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