[Frontend] Port chat_parsing core from Transformers (#58602)

Signed-off-by: Yoni Gozlan <yonigozlan@users.noreply.github.com>
Co-authored-by: Yoni Gozlan <yonigozlan@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
This commit is contained in:
Yoni Gozlan
2026-10-01 00:21:02 +08:00
committed by GitHub
co-authored by Yoni Gozlan Cursor mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
parent 42a3dd5fec
commit 73a5831127
5 changed files with 2600 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# Copyright 2026 The HuggingFace Team. All rights reserved.
# ruff: noqa
# fmt: off
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Response parsing: convert model-emitted text into the assistant-message
dict used by chat templates, driven by a declarative `response_template` spec."""
from .response_parser import ResponseParser, parse_response
__all__ = ["ResponseParser", "parse_response"]
# fmt: on
+255
View File
@@ -0,0 +1,255 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# Copyright 2026 The HuggingFace Team. All rights reserved.
# ruff: noqa
# fmt: off
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""This file contains the parsers used by chat response parsing. Each parser takes a chunk of captured text
and parses it into a single key in the output message dictionary. Functions are generally boilerplate."""
from __future__ import annotations
import json
from typing import Any
import regex as re
def _text(text: str, args: dict) -> str:
return text.strip() if args.get("strip", True) else text
def _int(text: str, args: dict) -> int:
return int(_text(text, args))
def _float(text: str, args: dict) -> float:
return float(_text(text, args))
def _bool(text: str, args: dict) -> bool:
return _text(text, args).lower() in ("true", "1")
# Sentinel characters for lax-JSON string pre-extraction — ASCII control chars
# that should never appear in real LLM output.
_LAX_OPEN, _LAX_CLOSE = "\x01", "\x02"
def _json(text: str, args: dict) -> Any:
"""JSON parser with optional dialect knobs for LLM-emitted quirks.
`args`:
- `unquoted_keys` (bool): quote bare-identifier keys before parsing.
- `string_delims` ([[open, close], ...]): strings delimited by these
custom markers are pre-extracted, then restored as standard JSON strings.
- `allow_non_json` (bool): return stripped text if parsing fails.
"""
string_delims = args.get("string_delims", [])
unquoted_keys = args.get("unquoted_keys", False)
if string_delims and (_LAX_OPEN in text or _LAX_CLOSE in text):
raise ValueError("json: input contains reserved sentinel characters (\\x01/\\x02); cannot parse safely.")
working = text
captured: list[str] = []
for open_d, close_d in string_delims:
pattern = re.escape(open_d) + r"(.*?)" + re.escape(close_d)
def _capture(m: Any) -> str:
captured.append(m.group(1))
return f"{_LAX_OPEN}{len(captured) - 1}{_LAX_CLOSE}"
working = re.sub(pattern, _capture, working, flags=re.DOTALL)
if unquoted_keys:
working = re.sub(r"(?<=[{,])(\w+):", r'"\1":', working)
for i, s in enumerate(captured):
working = working.replace(f"{_LAX_OPEN}{i}{_LAX_CLOSE}", json.dumps(s))
try:
return json.loads(working)
except json.JSONDecodeError as e:
if args.get("allow_non_json"):
return _text(text, args)
if working == text:
raise ValueError(f"json parser could not parse region as JSON.\nContent: {text!r}\nError: {e}") from e
raise ValueError(
f"json: could not parse after dialect transforms.\n"
f"Original: {text!r}\nTransformed: {working!r}\nError: {e}"
) from e
def _sub_parse(raw: str, value_parser: dict | None) -> Any:
if value_parser is None:
return raw
return parse_content(raw, value_parser.get("name", "text"), value_parser.get("args", {}))
def _xml_inline(text: str, args: dict) -> dict:
"""Parse shallow XML-ish tags into a dict. `tag_pattern` regex must have named
groups `key` and `value`. Optional `value_parser` recurses; `merge_duplicates`
collects duplicate keys into a list."""
tag_pattern = args.get("tag_pattern")
if tag_pattern is None:
raise ValueError("xml-inline: 'tag_pattern' content_arg is required")
value_parser = args.get("value_parser")
merge = args.get("merge_duplicates", False)
out: dict[str, Any] = {}
for m in re.finditer(tag_pattern, text, flags=re.DOTALL):
groups = m.groupdict()
key = groups.get("key")
if key is None:
raise ValueError(f"xml-inline: tag_pattern must have a named group 'key'. Pattern: {tag_pattern}")
value = _sub_parse(groups.get("value", ""), value_parser)
if key in out and merge:
if not isinstance(out[key], list):
out[key] = [out[key]]
out[key].append(value)
else:
out[key] = value
return out
def _kv_lines(text: str, args: dict) -> dict:
"""Parse line-delimited `key<sep>value` pairs into a dict."""
line_sep = args.get("line_sep", "\n")
kv_sep = args.get("kv_sep", ":")
value_parser = args.get("value_parser")
out: dict[str, Any] = {}
for line in text.split(line_sep):
line = _text(line, args)
if not line or kv_sep not in line:
continue
k, v = line.split(kv_sep, 1)
k, v = _text(k, args), _text(v, args)
out[k] = _sub_parse(v, value_parser)
return out
CONTENT_PARSERS = {
"text": _text,
"int": _int,
"float": _float,
"bool": _bool,
"json": _json,
"xml-inline": _xml_inline,
"kv-lines": _kv_lines,
}
# Parsers whose output is the verbatim body text (modulo whitespace) — chunks
# from these fields stream with `dirty=False` because each chunk is part of
# the final value. Structured parsers (`json`, `xml-inline`, `kv-lines`) only
# produce a meaningful value on close, so their chunks stream raw bytes
# flagged `dirty=True` while the parsed value is delivered in `region_close`.
STREAMABLE_PARSERS = frozenset({"text", "int", "float", "bool"})
def parse_content(text: str, name: str, args: dict) -> Any:
return CONTENT_PARSERS[name](text, args)
_PLACEHOLDER = re.compile(r"\{(\w+(?:\.\w+)*)\}")
def _apply_transform(transform: Any, scope: dict) -> Any:
"""Recursively walk a transform template, which is used to restructure
parsed output into the actual shape we want. A dotted placeholder like
`{content.args}` descends into keys of the looked-up value."""
if isinstance(transform, dict):
return {k: _apply_transform(v, scope) for k, v in transform.items()}
if isinstance(transform, list):
return [_apply_transform(v, scope) for v in transform]
if not isinstance(transform, str):
return transform
whole = _PLACEHOLDER.fullmatch(transform)
if not whole:
return transform
path = whole.group(1)
root, *keys = path.split(".")
if root not in scope:
raise KeyError(f"transform placeholder '{{{path}}}' is not defined. Available: {sorted(scope)}")
value = scope[root]
for key in keys:
if not isinstance(value, dict):
raise ValueError(f"transform placeholder '{{{path}}}' cannot index into {type(value).__name__} at '{key}'")
if key not in value:
raise ValueError(f"transform placeholder '{{{path}}}' is missing key '{key}'. Available: {sorted(value)}")
value = value[key]
return value
def validate_transform_strings(scope: str, transform: Any) -> None:
"""Walk a transform template and reject any string that mixes a `{name}`
placeholder with literal text. Only whole-string placeholders (e.g.
`"{content}"`) and plain literals are supported. Called from the template
loader so authors get a clear error at load time, not at parse time."""
if isinstance(transform, dict):
for v in transform.values():
validate_transform_strings(scope, v)
return
if isinstance(transform, list):
for v in transform:
validate_transform_strings(scope, v)
return
if not isinstance(transform, str):
return
if _PLACEHOLDER.search(transform) and not _PLACEHOLDER.fullmatch(transform):
raise ValueError(
f"{scope}: transform string {transform!r} mixes a {{placeholder}} with literal text. "
'Use either a whole-string placeholder (e.g. "{content}") or a plain literal; '
"string interpolation is not supported."
)
def process_field(body: str, field, captures: dict) -> Any:
"""Run `body` through the field's content parser, then optionally apply the
transform template. When `transform_each` is set, the parsed content must
be a list and the template is applied to each element (with the element's
keys unpacked into the template scope, alongside any regex captures).
`field` is a `spec.Field`; typed via duck-typing to avoid a cyclic import."""
value = parse_content(body, field.content, field.content_args)
if field.transform is None:
return value
if field.transform_each:
if not isinstance(value, list):
raise ValueError(
f"Field '{field.name}': transform_each requires the parsed content to be a list, "
f"got {type(value).__name__}."
)
out = []
for item in value:
if not isinstance(item, dict):
raise ValueError(
f"Field '{field.name}': transform_each requires each list element to be a dict, "
f"got {type(item).__name__}."
)
out.append(_apply_transform(field.transform, {**captures, **item}))
return out
return _apply_transform(field.transform, {**captures, "content": value})
__all__ = [
"CONTENT_PARSERS",
"STREAMABLE_PARSERS",
"parse_content",
"process_field",
"validate_transform_strings",
]
# fmt: on
+408
View File
@@ -0,0 +1,408 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# Copyright 2026 The HuggingFace Team. All rights reserved.
# ruff: noqa
# fmt: off
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import copy
import json
from typing import Any
from .content_parsers import STREAMABLE_PARSERS, process_field
from .response_templates import (
ResponseTemplate,
ResponseTemplateField,
load_response_template,
)
def _schema_types(schema: Any) -> tuple[str, ...]:
if not isinstance(schema, dict):
return ()
declared = schema.get("type")
types = [declared] if isinstance(declared, str) else []
if isinstance(declared, list):
types.extend(t for t in declared if isinstance(t, str))
for union_name in ("anyOf", "oneOf"):
for choice in schema.get(union_name) or []:
types.extend(_schema_types(choice))
if schema.get("nullable") and "null" not in types: # `nullable` is how get_json_schema marks Optionals
types.append("null")
return tuple(types)
def _coerce(raw: str, types: tuple[str, ...]) -> Any:
for type_name in types:
try:
if type_name == "integer":
return int(raw)
if type_name == "number":
number = float(raw)
if number != number or number in (float("inf"), float("-inf")):
continue # NaN / inf are not valid JSON numbers
# Preserve ints when the source text had no fractional part.
return int(number) if number.is_integer() and "." not in raw else number
if type_name == "boolean" and raw.strip().lower() in ("true", "1", "false", "0"):
return raw.strip().lower() in ("true", "1")
if type_name == "null" and raw.strip() in ("null", "None"):
return None
if type_name in ("object", "array"):
decoded = json.loads(raw)
if isinstance(decoded, dict if type_name == "object" else list):
return decoded
except ValueError:
continue
return raw # `string` params, unknown types and failed casts all keep the original text
def parse_response(
text: str,
response_template: dict | ResponseTemplate,
*,
prefix: str | None = None,
tools: list[dict] | None = None,
) -> dict:
"""The main function for response parsing when you don't want streaming. Takes generated output
and the prompt prefix and parses them without streaming any events, then returns the parsed message.
Pass OpenAI-style `tools` dictionaries to cast tool-call arguments
using the calling tool's JSON schema.
"""
response_template = load_response_template(response_template)
stream = ResponseParser(response_template, prefix=prefix, tools=tools)
stream.feed(text)
message, _ = stream.finalize()
return message
class ResponseParser:
"""This class implements a streaming parser with a `response_template`. If you don't need streaming and
just want to parse a complete message, use the `parse_response` function above. Streaming parsing emits
events indicating when regions (message fields) are opened and closed, with the model writing to the region
that is currently open.
Usage:
parser = ResponseParser(response_template, prefix=chat_prompt)
for event in parser.initial_events:
handle(event)
for chunk in model_text_stream:
for event in parser.feed(chunk):
handle(event)
message, final_events = parser.finalize()
for event in final_events:
handle(event)
Pass OpenAI-style `tools=` dictionaries to cast tool-call arguments
using the calling tool's JSON schema as each region closes.
Events can be either "region_open", "region_chunk", or "region_close".
ResponseParser requires the chat `prefix` (i.e. the chat history, the prefill before the current generation).
This is because chat templates or assistant prefills can sometimes write part of the message, and if we
only see the model output, and not the template, then we can't reliably parse the message in those cases.
Any events produced while consuming the prefix are exposed as `initial_events`, so renderers can show
prefill regions before the model writes anything; closed prefill regions also land in the output dict.
"""
def __init__(
self,
response_template: dict | ResponseTemplate,
prefix: str | None = None,
*,
tools: list[dict] | None = None,
):
self._spec = load_response_template(response_template)
if prefix is None:
raise ValueError(
"`ResponseParser`/`parse_response` requires `prefix` (the chat prompt sent to the model before "
"generation), because chat templates often pre-write part of the assistant message (e.g. an "
"opening `<think>` tag) that the parser must see to parse the output correctly. If the generation "
'already contains the complete message, pass `prefix=""` to opt out explicitly.'
)
# Maps tool name -> schema `properties`, used to cast parsed tool-call arguments
self._tool_params: dict[str, dict] = {}
for tool in tools or []:
fn = tool.get("function", tool) if isinstance(tool, dict) else None
if isinstance(fn, dict) and isinstance(fn.get("name"), str):
parameters = fn.get("parameters")
properties = parameters.get("properties") if isinstance(parameters, dict) else None
self._tool_params[fn["name"]] = properties if isinstance(properties, dict) else {}
self._buffer: str = ""
self._pos: int = 0
self._output: dict[str, Any] = copy.deepcopy(self._spec.defaults)
self._implicit_name: str | None = self._spec.implicit
# Unified current-region state: starts in the implicit region (or a
# null sink if none was declared), and returns there after every close.
# For explicit regions `_opened` flips to True eagerly on the open
# match; for the implicit region it flips lazily on the first byte.
self._current: str | None = self._implicit_name
self._captures: dict[str, str] = {}
self._body: str = ""
self._opened: bool = False
self._finalized: bool = False
self.initial_events: list[dict] = []
if prefix:
self._consume_prefix(prefix)
def _consume_prefix(self, prefix: str) -> None:
"""Loads the prefix (the chat prefill sent to the model), right-truncates it to the start of the
assistant message (as determined by start_anchor) and then runs the remainder through the parser.
Events produced while processing the prefix are stashed on `initial_events` so callers can replay
them into a renderer before feeding model output.
Think of this as the "get the parser up to speed on the story so far" method.
"""
truncated = self._spec.truncate_past_last_anchor(prefix)
if not truncated:
return
self._buffer = truncated
self._process(self.initial_events, eos=False)
def feed(self, text: str) -> list[dict]:
"""Feeds more text/tokens from the model output into the tokenizer, and returns any events that result
(regions entered or left). This is the method you want to call after each generation step."""
if self._finalized:
raise RuntimeError("ResponseParser already finalized")
if text:
self._buffer += text
events: list[dict] = []
self._process(events, eos=False)
return events
def finalize(self) -> tuple[dict, list[dict]]:
"""Close the stream and return the final message dict together with
any finalization events. This is necessary because some regions may only
end at the end of the sequence, so you won't see the event telling you they're
ready until the sequence is finalized."""
def _is_empty(v: Any) -> bool:
return v is None or (isinstance(v, (list, dict, str)) and not v)
if self._finalized:
raise RuntimeError("ResponseParser already finalized")
events: list[dict] = []
self._process(events, eos=True)
missing = [n for n, f in self._spec.fields.items() if not f.optional and n not in self._output]
if missing:
raise ValueError(f"Required response_template fields missing from parsed output: {missing}")
defaults = self._spec.defaults
self._output = {k: v for k, v in self._output.items() if k in defaults or not _is_empty(v)}
self._finalized = True
return self._output, events
def _process(self, events: list[dict], eos: bool) -> None:
while True:
watch = self._watchlist()
best, hold_start = self._scan(watch, eos)
if best is not None:
kind, field, m = best
if m.start() > self._pos:
self._accumulate(events, self._buffer[self._pos : m.start()])
self._pos = m.end()
if kind == "open":
self._close_current(events)
self._open_explicit(events, field, m)
else: # "close" (always the implicit region's close here,
# since explicit regions only expose their own close)
had_content = self._opened
self._close_current(events)
# Zero-width close on an already-empty region would just
# re-fire next iteration -- bail out to make progress.
if not had_content and m.start() == m.end():
break
continue
# No committable match in the current buffer.
if eos:
if self._pos < len(self._buffer):
self._accumulate(events, self._buffer[self._pos :])
self._pos = len(self._buffer)
self._close_current(events)
break
# Stream everything up to the earliest still-pending delimiter. When
# nothing is pending `hold_start == len(self._buffer)`, so this flushes
# the whole buffer; otherwise we hold the (possibly partial) delimiter
# bytes back until more input resolves them.
if hold_start > self._pos:
self._accumulate(events, self._buffer[self._pos : hold_start])
self._pos = hold_start
break
def _watchlist(self) -> list[tuple[str, ResponseTemplateField]]:
"""Patterns we care about right now: the close of the currently-open
explicit region, or -- if we're in the implicit/null region -- every
explicit open plus the implicit's own close (if any)."""
if self._current is not None and self._current != self._implicit_name:
field = self._spec.fields[self._current]
return [("close", field)] if field.close_re is not None else []
watch: list[tuple[str, ResponseTemplateField]] = []
for field in self._spec.fields.values():
if field.open_re is not None:
watch.append(("open", field))
if self._implicit_name is not None:
impl = self._spec.fields[self._implicit_name]
if impl.close_re is not None:
watch.append(("close", impl))
return watch
def _scan(
self, watch: list[tuple[str, ResponseTemplateField]], eos: bool
) -> tuple[tuple[str, ResponseTemplateField, Any] | None, int]:
"""Single pass over the watched delimiters, using the `regex` module's
partial matching to decide -- per delimiter -- whether it can be committed
now or must be held. Returns `(best, hold_start)`:
* `best` is the earliest-starting delimiter we can safely commit *now*
(longest on ties, opens before closes), or `None`.
* `hold_start` is the leftmost buffer position occupied by a still-pending
match: a partial (incomplete) delimiter, or a complete one ending at the
buffer edge that more input could still grow. Bytes before it are safe to
emit; bytes from it onward must be held. It stays `len(self._buffer)` when
nothing is pending, letting the caller flush the whole buffer.
A complete match is committable only if it starts strictly before
`hold_start` -- otherwise an earlier (or co-located) pending delimiter could
turn out to be the real one. At EOS nothing can grow, so partial matching is
skipped and every complete match is committable.
(The `regex` module always reports the empty string as a live prefix, so a
partial search with no real match returns a zero-width match at the buffer
end; that lands in the pending branch with `start == len(self._buffer)`, a
no-op for `hold_start`.)
"""
best_key: tuple | None = None
best: tuple[str, ResponseTemplateField, Any] | None = None
hold_start = len(self._buffer)
for kind, field in watch:
# The watchlist only includes fields whose delimiter regex is set, so pattern is never None here.
pattern = field.open_re if kind == "open" else field.close_re
if eos:
m = pattern.search(self._buffer, self._pos)
else:
m = pattern.search(self._buffer, self._pos, partial=True)
if m is None:
continue
if not eos and (m.partial or self._can_grow(kind, field, m)):
# Pending: can't commit, and blocks emitting from its start onward.
hold_start = min(hold_start, m.start())
continue
key = (m.start(), -(m.end() - m.start()), 0 if kind == "open" else 1, field.name)
if best_key is None or key < best_key:
best_key, best = key, (kind, field, m)
# A committable match co-located with or after a pending one must wait too:
# the pending delimiter starts no later and might be the one that fires.
if best is not None and best[2].start() >= hold_start:
best = None
return best, hold_start
def _can_grow(self, kind: str, field: ResponseTemplateField, m: Any) -> bool:
r"""Whether a *complete* match ending at the current buffer edge could still
change as more input arrives -- in which case we defer rather than commit. A
match ending before the edge has already seen its terminating byte and is
final. At the edge: zero-width matches (`$` / `\Z`) are only real at true
EOS; a fully-present literal that no other literal in its set extends cannot
grow (the fast path that keeps literal delimiters zero-latency); anything
else (regex delimiters, prefix-overlapping literal lists) might."""
if m.end() != len(self._buffer):
return False
if m.start() == m.end():
return True
literals, can_extend = (
(field.open_literals, field.open_literal_can_extend)
if kind == "open"
else (field.close_literals, field.close_literal_can_extend)
)
return literals is None or can_extend
def _accumulate(self, events: list[dict], text: str) -> None:
"""Route `text` into the currently active region. When the current
region is the null sink (no implicit declared, no explicit open), we
silently discard. Every routed chunk emits a `region_chunk` event so
consumers can render live; `dirty=True` flags chunks from structured
parsers (json, xml-inline, kv-lines) whose raw bytes will only be
parsed into the final value on close."""
if not text or self._current is None:
return
field = self._spec.fields[self._current]
if not self._opened:
events.append({"type": "region_open", "field": self._current})
self._opened = True
self._body += text
dirty = field.content not in STREAMABLE_PARSERS
events.append({"type": "region_chunk", "field": self._current, "text": text, "dirty": dirty})
def _open_explicit(self, events: list[dict], field: ResponseTemplateField, m: Any) -> None:
self._current = field.name
self._captures = {k: v for k, v in m.groupdict().items() if v is not None}
self._body = ""
self._opened = True
events.append({"type": "region_open", "field": field.name})
def _close_current(self, events: list[dict]) -> None:
"""Close the current region and reset to the implicit/null region.
Skipped (aside from the reset) when the current region never opened --
avoids vacuous open/close pairs at every explicit boundary."""
if self._current is None or not self._opened:
self._reset_to_implicit()
return
field = self._spec.fields[self._current]
value = process_field(self._body, field, self._captures)
if self._tool_params:
value = self._coerce_tool_calls(value)
if field.join is not None:
if not isinstance(value, str):
raise ValueError(
f"Field '{field.name}': 'join' requires each match to parse to a string, "
f"got {type(value).__name__}."
)
previous = self._output.get(self._current)
self._output[self._current] = value if previous is None else previous + field.join + value
elif field.repeats:
self._output.setdefault(self._current, []).append(value)
else:
self._output[self._current] = value
events.append({"type": "region_close", "field": self._current, "value": value})
self._reset_to_implicit()
def _coerce_tool_calls(self, value: Any) -> Any:
if isinstance(value, list):
return [self._coerce_tool_calls(item) for item in value]
fn = value.get("function") if isinstance(value, dict) else None
if not isinstance(fn, dict):
return value
name, arguments = fn.get("name"), fn.get("arguments")
if not isinstance(name, str) or not isinstance(arguments, dict):
return value
if properties := self._tool_params.get(name):
for key, argument in arguments.items():
if key not in properties or not (types := _schema_types(properties[key])):
continue
if isinstance(argument, str):
arguments[key] = _coerce(argument, types)
elif isinstance(argument, list) and "array" not in types:
# duplicate keys collected by `merge_duplicates`
arguments[key] = [_coerce(item, types) if isinstance(item, str) else item for item in argument]
return value
def _reset_to_implicit(self) -> None:
self._current = self._implicit_name
self._captures = {}
self._body = ""
self._opened = False
# fmt: on
@@ -0,0 +1,249 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# Copyright 2026 The HuggingFace Team. All rights reserved.
# ruff: noqa
# fmt: off
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Template loading and validation for response_template dicts."""
from __future__ import annotations
import copy
import logging
from dataclasses import dataclass
from typing import Any
import regex as re
from .content_parsers import CONTENT_PARSERS, validate_transform_strings
logger = logging.getLogger(__name__)
@dataclass
class ResponseTemplateField:
name: str
open_re: Any
open_literals: list[str] | None
open_literal_can_extend: bool
close_re: Any
close_literals: list[str] | None
close_literal_can_extend: bool
content: str
content_args: dict
repeats: bool
join: str | None
optional: bool
transform: Any
transform_each: bool
@dataclass
class ResponseTemplate:
defaults: dict
fields: dict[str, ResponseTemplateField]
start_anchor_re: Any
implicit: str | None = None
start_anchor_literals: list[str] | None = None
def truncate_past_last_anchor(self, text: str) -> str:
"""When parsing responses, we can't just parse the tokens generated
by the model. This is because chat templates often include early parts of the message such as <start_of_turn>
or <think> tokens in the prefill. Therefore, we take the whole chat as input, but truncate to the start
of the most recent assistant message, which can include tokens from the template as well as the output."""
last_end: int | None = None
for m in self.start_anchor_re.finditer(text):
last_end = m.end()
if last_end is None:
kind = "start_anchor" if self.start_anchor_literals is not None else "start_anchor_pattern"
logger.info(
"response_template defines %s but the anchor was not found "
"in the prefix; the parser will process the entire prefix instead.",
kind,
)
return text
return text[last_end:]
def _compile_anchor(scope: str, field: dict, literal_key: str, pattern_key: str) -> tuple[Any, list[str] | None, bool]:
"""Compile a region's start/end anchor. An anchor can be:
- a regex (`pattern_key`),
- a single literal string (`literal_key`), or
- a list of literal strings (`literal_key`), in which case any of these match
All forms compile to a regex for matching, but we keep the literal strings around too: when present,
the parser can emit regions faster, since it knows exactly how many trailing bytes might be a
partial-match prefix (and that a finished literal match can never be extended by future bytes, except
when a literal is a strict prefix of another in the same list).
Returns `(compiled_re, literals, can_extend)`:
- `compiled_re`: the compiled pattern.
- `literals`: the literal strings, or `None` for a regex anchor.
- `can_extend`: `True` iff one literal is a strict prefix of another, in which case a match at the
buffer edge could still be lengthened by future input.
"""
if literal_key in field and pattern_key in field:
raise ValueError(f"{scope}: cannot specify both '{literal_key}' and '{pattern_key}'")
if literal_key in field:
raw = field[literal_key]
if isinstance(raw, str):
literals = [raw]
elif isinstance(raw, list):
if not raw:
raise ValueError(f"{scope}: '{literal_key}' list must contain at least one literal")
if not all(isinstance(s, str) for s in raw):
raise ValueError(f"{scope}: '{literal_key}' list must contain only strings")
literals = list(dict.fromkeys(raw)) # dedupe, preserve first-seen order
else:
raise ValueError(f"{scope}: '{literal_key}' must be a string or list of strings, got {type(raw).__name__}")
if any(s == "" for s in literals):
raise ValueError(f"{scope}: '{literal_key}' literals cannot be empty strings")
# Sort longest-first so alternation prefers the longer alternative when both could match.
ordered = sorted(literals, key=len, reverse=True)
can_extend = any(a != b and a.startswith(b) for a in literals for b in literals)
pattern = "|".join(re.escape(s) for s in ordered)
return re.compile(pattern, re.DOTALL), literals, can_extend
if pattern_key in field:
try:
return re.compile(field[pattern_key], re.DOTALL), None, False
except re.error as e:
raise ValueError(f"{scope}: invalid {pattern_key} regex: {e}") from e
return None, None, False
def _validate_template_shape(spec: dict) -> None:
"""Validate the top-level response_template structure: it must be a dict of the expected version with
only known keys, a dict of `defaults`, and a non-empty dict of `fields`. Per-field specs are checked
separately in `_build_field`."""
if not isinstance(spec, dict):
raise ValueError(f"response_template must be a dict, got {type(spec).__name__}")
version = spec.get("version", 1)
if version != 1:
raise ValueError(f"Unsupported response_template version: {version}")
if unknown_template_keys := set(spec) - {"version", "defaults", "fields", "start_anchor", "start_anchor_pattern"}:
raise ValueError(f"Unknown keys in response_template: {sorted(unknown_template_keys)}")
if not isinstance(spec.get("defaults", {}), dict):
raise ValueError("response_template.defaults must be a dict")
fields_raw = spec.get("fields", {})
if not isinstance(fields_raw, dict) or not fields_raw:
raise ValueError("response_template.fields must be a non-empty dict")
def _build_field(name: str, field: dict) -> ResponseTemplateField:
"""Validate a single field spec and compile it into a `ResponseTemplateField`."""
_ALLOWED_FIELD_KEYS = {
"open",
"open_pattern",
"close",
"close_pattern",
"content",
"content_args",
"repeats",
"join",
"optional",
"transform",
"transform_each",
}
scope = f"Field '{name}'"
if not isinstance(field, dict):
raise ValueError(f"{scope} must be a dict")
if unknown_field_keys := set(field) - _ALLOWED_FIELD_KEYS:
raise ValueError(f"{scope}: unknown keys {sorted(unknown_field_keys)}")
content = field.get("content", "text")
if content not in CONTENT_PARSERS:
raise ValueError(f"{scope}: unknown content parser '{content}'. Available: {sorted(CONTENT_PARSERS)}")
open_re, open_literals, open_literal_can_extend = _compile_anchor(scope, field, "open", "open_pattern")
close_re, close_literals, close_literal_can_extend = _compile_anchor(scope, field, "close", "close_pattern")
join = field.get("join")
if join is not None and not isinstance(join, str):
raise ValueError(f"{scope}: 'join' must be a string, got {type(join).__name__}")
if join is not None and not field.get("repeats", False):
raise ValueError(f"{scope}: 'join' requires 'repeats': true")
transform = field.get("transform")
transform_each = field.get("transform_each", False)
if not isinstance(transform_each, bool):
raise ValueError(f"{scope}: transform_each must be a bool, got {type(transform_each).__name__}")
if transform_each and transform is None:
raise ValueError(f"{scope}: transform_each is set but no transform was provided")
if transform is not None:
validate_transform_strings(scope, transform)
else:
# Named captures only reach the output through a transform, so flag any that would be silently dropped.
captured_names = set()
if open_re is not None:
captured_names |= set(open_re.groupindex)
if close_re is not None:
captured_names |= set(close_re.groupindex)
if captured_names:
raise ValueError(
f"{scope}: open_pattern/close_pattern declares named group(s) "
f"{sorted(captured_names)}, but the field has no 'transform'. Named captures "
f"are only surfaced through a 'transform' template (where they appear "
f"alongside 'content'). Either add a 'transform' that uses the captures, or "
f"remove the named groups from the pattern."
)
return ResponseTemplateField(
name=name,
open_re=open_re,
open_literals=open_literals,
open_literal_can_extend=open_literal_can_extend,
close_re=close_re,
close_literals=close_literals,
close_literal_can_extend=close_literal_can_extend,
content=content,
content_args=field.get("content_args", {}),
repeats=field.get("repeats", False),
join=join,
optional=field.get("optional", True),
transform=transform,
transform_each=transform_each,
)
def load_response_template(spec: dict | ResponseTemplate) -> ResponseTemplate:
if isinstance(spec, ResponseTemplate):
return spec
_validate_template_shape(spec)
fields = {name: _build_field(name, raw) for name, raw in spec["fields"].items()}
# A field without an open anchor is the implicit-open / leftover sink; at most one is allowed.
implicit_fields = [name for name, field in fields.items() if field.open_re is None]
if len(implicit_fields) > 1:
raise ValueError(
"At most one field may omit 'open'/'open_pattern' (that field becomes the "
f"implicit-open / leftover sink). Found: {', '.join(implicit_fields)}"
)
start_anchor_re, start_anchor_literals, _ = _compile_anchor(
"response_template", spec, "start_anchor", "start_anchor_pattern"
)
if start_anchor_re is None:
raise ValueError("response_template must define 'start_anchor' or 'start_anchor_pattern'.")
return ResponseTemplate(
defaults=copy.deepcopy(spec.get("defaults", {})),
fields=fields,
implicit=implicit_fields[0] if implicit_fields else None,
start_anchor_re=start_anchor_re,
start_anchor_literals=start_anchor_literals,
)
__all__ = ["ResponseTemplate", "ResponseTemplateField", "load_response_template"]
# fmt: on