main
py 255 lines 9.47 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 # Copyright 2026 The HuggingFace Team. All rights reserved.
5 # ruff: noqa
6 # fmt: off
7 #
8 # Licensed under the Apache License, Version 2.0 (the "License");
9 # you may not use this file except in compliance with the License.
10 # You may obtain a copy of the License at
11 #
12 # http://www.apache.org/licenses/LICENSE-2.0
13 #
14 # Unless required by applicable law or agreed to in writing, software
15 # distributed under the License is distributed on an "AS IS" BASIS,
16 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17 # See the License for the specific language governing permissions and
18 # limitations under the License.
19 """This file contains the parsers used by chat response parsing. Each parser takes a chunk of captured text
20 and parses it into a single key in the output message dictionary. Functions are generally boilerplate."""
21
22 from __future__ import annotations
23
24 import json
25 from typing import Any
26
27 import regex as re
28
29
30 def _text(text: str, args: dict) -> str:
31 return text.strip() if args.get("strip", True) else text
32
33
34 def _int(text: str, args: dict) -> int:
35 return int(_text(text, args))
36
37
38 def _float(text: str, args: dict) -> float:
39 return float(_text(text, args))
40
41
42 def _bool(text: str, args: dict) -> bool:
43 return _text(text, args).lower() in ("true", "1")
44
45
46 # Sentinel characters for lax-JSON string pre-extraction — ASCII control chars
47 # that should never appear in real LLM output.
48 _LAX_OPEN, _LAX_CLOSE = "\x01", "\x02"
49
50
51 def _json(text: str, args: dict) -> Any:
52 """JSON parser with optional dialect knobs for LLM-emitted quirks.
53
54 `args`:
55 - `unquoted_keys` (bool): quote bare-identifier keys before parsing.
56 - `string_delims` ([[open, close], ...]): strings delimited by these
57 custom markers are pre-extracted, then restored as standard JSON strings.
58 - `allow_non_json` (bool): return stripped text if parsing fails.
59 """
60 string_delims = args.get("string_delims", [])
61 unquoted_keys = args.get("unquoted_keys", False)
62
63 if string_delims and (_LAX_OPEN in text or _LAX_CLOSE in text):
64 raise ValueError("json: input contains reserved sentinel characters (\\x01/\\x02); cannot parse safely.")
65
66 working = text
67 captured: list[str] = []
68 for open_d, close_d in string_delims:
69 pattern = re.escape(open_d) + r"(.*?)" + re.escape(close_d)
70
71 def _capture(m: Any) -> str:
72 captured.append(m.group(1))
73 return f"{_LAX_OPEN}{len(captured) - 1}{_LAX_CLOSE}"
74
75 working = re.sub(pattern, _capture, working, flags=re.DOTALL)
76
77 if unquoted_keys:
78 working = re.sub(r"(?<=[{,])(\w+):", r'"\1":', working)
79
80 for i, s in enumerate(captured):
81 working = working.replace(f"{_LAX_OPEN}{i}{_LAX_CLOSE}", json.dumps(s))
82
83 try:
84 return json.loads(working)
85 except json.JSONDecodeError as e:
86 if args.get("allow_non_json"):
87 return _text(text, args)
88 if working == text:
89 raise ValueError(f"json parser could not parse region as JSON.\nContent: {text!r}\nError: {e}") from e
90 raise ValueError(
91 f"json: could not parse after dialect transforms.\n"
92 f"Original: {text!r}\nTransformed: {working!r}\nError: {e}"
93 ) from e
94
95
96 def _sub_parse(raw: str, value_parser: dict | None) -> Any:
97 if value_parser is None:
98 return raw
99 return parse_content(raw, value_parser.get("name", "text"), value_parser.get("args", {}))
100
101
102 def _xml_inline(text: str, args: dict) -> dict:
103 """Parse shallow XML-ish tags into a dict. `tag_pattern` regex must have named
104 groups `key` and `value`. Optional `value_parser` recurses; `merge_duplicates`
105 collects duplicate keys into a list."""
106 tag_pattern = args.get("tag_pattern")
107 if tag_pattern is None:
108 raise ValueError("xml-inline: 'tag_pattern' content_arg is required")
109 value_parser = args.get("value_parser")
110 merge = args.get("merge_duplicates", False)
111
112 out: dict[str, Any] = {}
113 for m in re.finditer(tag_pattern, text, flags=re.DOTALL):
114 groups = m.groupdict()
115 key = groups.get("key")
116 if key is None:
117 raise ValueError(f"xml-inline: tag_pattern must have a named group 'key'. Pattern: {tag_pattern}")
118 value = _sub_parse(groups.get("value", ""), value_parser)
119 if key in out and merge:
120 if not isinstance(out[key], list):
121 out[key] = [out[key]]
122 out[key].append(value)
123 else:
124 out[key] = value
125 return out
126
127
128 def _kv_lines(text: str, args: dict) -> dict:
129 """Parse line-delimited `key<sep>value` pairs into a dict."""
130 line_sep = args.get("line_sep", "\n")
131 kv_sep = args.get("kv_sep", ":")
132 value_parser = args.get("value_parser")
133
134 out: dict[str, Any] = {}
135 for line in text.split(line_sep):
136 line = _text(line, args)
137 if not line or kv_sep not in line:
138 continue
139 k, v = line.split(kv_sep, 1)
140 k, v = _text(k, args), _text(v, args)
141 out[k] = _sub_parse(v, value_parser)
142 return out
143
144
145 CONTENT_PARSERS = {
146 "text": _text,
147 "int": _int,
148 "float": _float,
149 "bool": _bool,
150 "json": _json,
151 "xml-inline": _xml_inline,
152 "kv-lines": _kv_lines,
153 }
154
155 # Parsers whose output is the verbatim body text (modulo whitespace) — chunks
156 # from these fields stream with `dirty=False` because each chunk is part of
157 # the final value. Structured parsers (`json`, `xml-inline`, `kv-lines`) only
158 # produce a meaningful value on close, so their chunks stream raw bytes
159 # flagged `dirty=True` while the parsed value is delivered in `region_close`.
160 STREAMABLE_PARSERS = frozenset({"text", "int", "float", "bool"})
161
162
163 def parse_content(text: str, name: str, args: dict) -> Any:
164 return CONTENT_PARSERS[name](text, args)
165
166
167 _PLACEHOLDER = re.compile(r"\{(\w+(?:\.\w+)*)\}")
168
169
170 def _apply_transform(transform: Any, scope: dict) -> Any:
171 """Recursively walk a transform template, which is used to restructure
172 parsed output into the actual shape we want. A dotted placeholder like
173 `{content.args}` descends into keys of the looked-up value."""
174 if isinstance(transform, dict):
175 return {k: _apply_transform(v, scope) for k, v in transform.items()}
176 if isinstance(transform, list):
177 return [_apply_transform(v, scope) for v in transform]
178 if not isinstance(transform, str):
179 return transform
180 whole = _PLACEHOLDER.fullmatch(transform)
181 if not whole:
182 return transform
183 path = whole.group(1)
184 root, *keys = path.split(".")
185 if root not in scope:
186 raise KeyError(f"transform placeholder '{{{path}}}' is not defined. Available: {sorted(scope)}")
187 value = scope[root]
188 for key in keys:
189 if not isinstance(value, dict):
190 raise ValueError(f"transform placeholder '{{{path}}}' cannot index into {type(value).__name__} at '{key}'")
191 if key not in value:
192 raise ValueError(f"transform placeholder '{{{path}}}' is missing key '{key}'. Available: {sorted(value)}")
193 value = value[key]
194 return value
195
196
197 def validate_transform_strings(scope: str, transform: Any) -> None:
198 """Walk a transform template and reject any string that mixes a `{name}`
199 placeholder with literal text. Only whole-string placeholders (e.g.
200 `"{content}"`) and plain literals are supported. Called from the template
201 loader so authors get a clear error at load time, not at parse time."""
202 if isinstance(transform, dict):
203 for v in transform.values():
204 validate_transform_strings(scope, v)
205 return
206 if isinstance(transform, list):
207 for v in transform:
208 validate_transform_strings(scope, v)
209 return
210 if not isinstance(transform, str):
211 return
212 if _PLACEHOLDER.search(transform) and not _PLACEHOLDER.fullmatch(transform):
213 raise ValueError(
214 f"{scope}: transform string {transform!r} mixes a {{placeholder}} with literal text. "
215 'Use either a whole-string placeholder (e.g. "{content}") or a plain literal; '
216 "string interpolation is not supported."
217 )
218
219
220 def process_field(body: str, field, captures: dict) -> Any:
221 """Run `body` through the field's content parser, then optionally apply the
222 transform template. When `transform_each` is set, the parsed content must
223 be a list and the template is applied to each element (with the element's
224 keys unpacked into the template scope, alongside any regex captures).
225
226 `field` is a `spec.Field`; typed via duck-typing to avoid a cyclic import."""
227 value = parse_content(body, field.content, field.content_args)
228 if field.transform is None:
229 return value
230 if field.transform_each:
231 if not isinstance(value, list):
232 raise ValueError(
233 f"Field '{field.name}': transform_each requires the parsed content to be a list, "
234 f"got {type(value).__name__}."
235 )
236 out = []
237 for item in value:
238 if not isinstance(item, dict):
239 raise ValueError(
240 f"Field '{field.name}': transform_each requires each list element to be a dict, "
241 f"got {type(item).__name__}."
242 )
243 out.append(_apply_transform(field.transform, {**captures, **item}))
244 return out
245 return _apply_transform(field.transform, {**captures, "content": value})
246
247
248 __all__ = [
249 "CONTENT_PARSERS",
250 "STREAMABLE_PARSERS",
251 "parse_content",
252 "process_field",
253 "validate_transform_strings",
254 ]
255 # fmt: on