coderouter-cli 2.7.0__py3-none-any.whl → 2.7.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- coderouter/config/capability_registry.py +22 -0
- coderouter/config/schemas.py +78 -0
- coderouter/ingress/anthropic_routes.py +108 -0
- coderouter/logging.py +5 -0
- coderouter/routing/capability.py +217 -0
- coderouter/routing/fallback.py +152 -0
- coderouter/translation/tool_repair.py +644 -39
- {coderouter_cli-2.7.0.dist-info → coderouter_cli-2.7.1.dist-info}/METADATA +28 -9
- {coderouter_cli-2.7.0.dist-info → coderouter_cli-2.7.1.dist-info}/RECORD +12 -12
- {coderouter_cli-2.7.0.dist-info → coderouter_cli-2.7.1.dist-info}/WHEEL +0 -0
- {coderouter_cli-2.7.0.dist-info → coderouter_cli-2.7.1.dist-info}/entry_points.txt +0 -0
- {coderouter_cli-2.7.0.dist-info → coderouter_cli-2.7.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -23,15 +23,43 @@ Recognised shapes
|
|
|
23
23
|
2. Bare JSON objects embedded in text:
|
|
24
24
|
"Let me check the current directory. {\"name\":\"Bash\",\"arguments\":{}}"
|
|
25
25
|
3. Multiple JSON objects in sequence (for multi-call turns).
|
|
26
|
+
4. XML-flavoured forms (R3), seen with qwen2.5-coder at default temperature:
|
|
27
|
+
<echo message="probe"/> (attribute form)
|
|
28
|
+
<tool>{"name": "echo", "arguments": {...}}</tool> (wrapper form)
|
|
29
|
+
<echo>{"message": "probe"}</echo> (named-wrapper form)
|
|
26
30
|
|
|
27
31
|
Each candidate is accepted only if it parses to one of:
|
|
28
32
|
{"name": <str>, "arguments": <dict | str>} # direct shape
|
|
29
33
|
{"function": {"name": <str>, "arguments": ...}} # OpenAI shape
|
|
34
|
+
... plus the R2 key aliases (tool / tool_name / parameters / input / args).
|
|
30
35
|
|
|
31
|
-
|
|
36
|
+
Lenient JSON (R1)
|
|
37
|
+
-----------------
|
|
38
|
+
When strict ``json.loads`` fails, a second, tolerant pass is attempted for the
|
|
39
|
+
malformed forms small models actually emit:
|
|
40
|
+
- double-braced objects ``{{...}}`` -> ``{...}`` (L2 default-temp form)
|
|
41
|
+
- trailing commas
|
|
42
|
+
- single-quoted strings / keys (Python-repr dicts)
|
|
43
|
+
- unquoted object keys
|
|
44
|
+
The lenient parse is a stdlib-only tokenizer; the resulting dict is still run
|
|
45
|
+
through the exact same shape + allow-list validation as a strict parse.
|
|
46
|
+
|
|
47
|
+
False-positive discipline
|
|
48
|
+
-------------------------
|
|
49
|
+
If `allowed_tool_names` is provided, the tool `name` must be in that set;
|
|
32
50
|
otherwise any tool-shaped JSON is accepted. Passing the allow-list is
|
|
33
|
-
strongly recommended
|
|
34
|
-
|
|
51
|
+
strongly recommended.
|
|
52
|
+
|
|
53
|
+
In addition, two context guards keep genuine documentation / source code from
|
|
54
|
+
being turned into executable tool calls, even when it contains an allow-listed
|
|
55
|
+
name:
|
|
56
|
+
- programming-language code fences (```python, ```js, ...) are protected:
|
|
57
|
+
their interior is never scanned by the bare-JSON or XML scanners.
|
|
58
|
+
- bare JSON / XML immediately introduced by an explanatory cue
|
|
59
|
+
("for example", "you would write", "is documented as", ...) is treated
|
|
60
|
+
as prose and left in place.
|
|
61
|
+
These guards are conservative: they only ever *suppress* an extraction, so a
|
|
62
|
+
name that is not in the allow-list is still never repaired.
|
|
35
63
|
"""
|
|
36
64
|
|
|
37
65
|
from __future__ import annotations
|
|
@@ -45,32 +73,68 @@ __all__ = ["deduplicate_tool_calls", "repair_tool_calls_in_text"]
|
|
|
45
73
|
|
|
46
74
|
|
|
47
75
|
# ------------------------------------------------------------------
|
|
48
|
-
#
|
|
76
|
+
# R2: shape detection + normalisation (with key aliases)
|
|
49
77
|
# ------------------------------------------------------------------
|
|
50
78
|
|
|
79
|
+
# Accepted aliases for the tool-name key and the arguments key. Order does not
|
|
80
|
+
# matter for detection, but ambiguity (more than one distinct alias present)
|
|
81
|
+
# is treated as "not a tool call" and left untouched (safe side).
|
|
82
|
+
_NAME_KEYS = ("name", "tool", "tool_name")
|
|
83
|
+
_ARG_KEYS = ("arguments", "parameters", "input", "args")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _pick_unique(obj: dict[str, Any], keys: tuple[str, ...]) -> tuple[str | None, Any]:
|
|
87
|
+
"""Return (matched_key, value) if exactly one of ``keys`` is present.
|
|
88
|
+
|
|
89
|
+
If zero keys are present, returns (None, None). If more than one key is
|
|
90
|
+
present the result is ambiguous -> returns ("<ambiguous>", None) so callers
|
|
91
|
+
can decline to repair.
|
|
92
|
+
"""
|
|
93
|
+
present = [k for k in keys if k in obj]
|
|
94
|
+
if not present:
|
|
95
|
+
return None, None
|
|
96
|
+
if len(present) > 1:
|
|
97
|
+
return "<ambiguous>", None
|
|
98
|
+
k = present[0]
|
|
99
|
+
return k, obj[k]
|
|
100
|
+
|
|
51
101
|
|
|
52
102
|
def _looks_like_tool_call(obj: Any, allowed: set[str] | None) -> tuple[str, Any] | None:
|
|
53
|
-
"""Return (name, arguments) if obj looks like a tool call, else None.
|
|
103
|
+
"""Return (name, arguments) if obj looks like a tool call, else None.
|
|
104
|
+
|
|
105
|
+
Recognises:
|
|
106
|
+
- direct shape with name-key alias + args-key alias
|
|
107
|
+
- OpenAI ``{"function": {...}}`` wrapper (recursed into)
|
|
108
|
+
Ambiguous objects (multiple name aliases or multiple arg aliases) are
|
|
109
|
+
rejected.
|
|
110
|
+
"""
|
|
54
111
|
if not isinstance(obj, dict):
|
|
55
112
|
return None
|
|
56
113
|
|
|
57
|
-
# Direct shape: {"name": "...", "arguments": ...}
|
|
58
|
-
name = obj.get("name")
|
|
59
|
-
if isinstance(name, str) and "arguments" in obj and (allowed is None or name in allowed):
|
|
60
|
-
return name, obj["arguments"]
|
|
61
|
-
|
|
62
114
|
# OpenAI function shape: {"function": {"name": "...", "arguments": ...}}
|
|
63
115
|
fn = obj.get("function")
|
|
64
116
|
if isinstance(fn, dict):
|
|
65
|
-
|
|
66
|
-
if
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
117
|
+
inner = _looks_like_tool_call(fn, allowed)
|
|
118
|
+
if inner is not None:
|
|
119
|
+
return inner
|
|
120
|
+
# fall through: maybe the outer dict itself is tool-shaped
|
|
121
|
+
|
|
122
|
+
name_key, name = _pick_unique(obj, _NAME_KEYS)
|
|
123
|
+
if name_key == "<ambiguous>":
|
|
124
|
+
return None
|
|
125
|
+
arg_key, args = _pick_unique(obj, _ARG_KEYS)
|
|
126
|
+
if arg_key == "<ambiguous>":
|
|
127
|
+
return None
|
|
72
128
|
|
|
73
|
-
|
|
129
|
+
if not isinstance(name, str) or name_key is None:
|
|
130
|
+
return None
|
|
131
|
+
if arg_key is None:
|
|
132
|
+
# A tool-shaped object must carry an arguments container to be a call;
|
|
133
|
+
# a lone {"name": "..."} is not distinguishable from ordinary data.
|
|
134
|
+
return None
|
|
135
|
+
if allowed is not None and name not in allowed:
|
|
136
|
+
return None
|
|
137
|
+
return name, args
|
|
74
138
|
|
|
75
139
|
|
|
76
140
|
def _normalise_to_openai_tool_call(name: str, arguments: Any) -> dict[str, Any]:
|
|
@@ -89,17 +153,198 @@ def _normalise_to_openai_tool_call(name: str, arguments: Any) -> dict[str, Any]:
|
|
|
89
153
|
}
|
|
90
154
|
|
|
91
155
|
|
|
156
|
+
# ------------------------------------------------------------------
|
|
157
|
+
# R1: lenient JSON parsing (only tried after strict json.loads fails)
|
|
158
|
+
# ------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _strip_double_braces(s: str) -> str:
|
|
162
|
+
"""Collapse a fully double-braced object ``{{...}}`` to ``{...}``.
|
|
163
|
+
|
|
164
|
+
Only collapses when the whole (stripped) string is wrapped in exactly one
|
|
165
|
+
extra layer of braces on both ends — the L2 default-temp failure form
|
|
166
|
+
``{{"name": ...}}``. A single wrap is removed; deeper nesting is left as-is
|
|
167
|
+
(the tokenizer below handles the resulting single-braced object).
|
|
168
|
+
"""
|
|
169
|
+
t = s.strip()
|
|
170
|
+
if t.startswith("{{") and t.endswith("}}"):
|
|
171
|
+
return t[1:-1].strip()
|
|
172
|
+
return s
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _lenient_json_loads(raw: str) -> Any:
|
|
176
|
+
"""Best-effort parse of near-JSON emitted by small models.
|
|
177
|
+
|
|
178
|
+
Handles, in a single tolerant tokenizer pass:
|
|
179
|
+
- single-quoted strings ('...') -> double-quoted
|
|
180
|
+
- unquoted object keys -> quoted
|
|
181
|
+
- trailing commas -> dropped
|
|
182
|
+
- (double braces are stripped by the caller before this runs)
|
|
183
|
+
|
|
184
|
+
Returns the parsed object, or raises ValueError if the tokenizer cannot
|
|
185
|
+
produce syntactically valid JSON. Never executes the input.
|
|
186
|
+
"""
|
|
187
|
+
s = raw.strip()
|
|
188
|
+
if not s:
|
|
189
|
+
raise ValueError("empty")
|
|
190
|
+
|
|
191
|
+
out: list[str] = []
|
|
192
|
+
i = 0
|
|
193
|
+
n = len(s)
|
|
194
|
+
# Stack tracks whether the current container is an object ('{') so we can
|
|
195
|
+
# know when a bareword is a key (needs quoting) vs a value.
|
|
196
|
+
while i < n:
|
|
197
|
+
c = s[i]
|
|
198
|
+
|
|
199
|
+
# --- string literals: normalise quote char, copy verbatim otherwise ---
|
|
200
|
+
if c == '"' or c == "'":
|
|
201
|
+
quote = c
|
|
202
|
+
j = i + 1
|
|
203
|
+
buf = ['"']
|
|
204
|
+
while j < n:
|
|
205
|
+
cj = s[j]
|
|
206
|
+
if cj == "\\":
|
|
207
|
+
# keep escape pair verbatim (but a backslash-escaped single
|
|
208
|
+
# quote inside a single-quoted string becomes a plain ').
|
|
209
|
+
if j + 1 < n:
|
|
210
|
+
nxt = s[j + 1]
|
|
211
|
+
if quote == "'" and nxt == "'":
|
|
212
|
+
buf.append("'")
|
|
213
|
+
j += 2
|
|
214
|
+
continue
|
|
215
|
+
buf.append(cj)
|
|
216
|
+
buf.append(nxt)
|
|
217
|
+
j += 2
|
|
218
|
+
continue
|
|
219
|
+
buf.append("\\")
|
|
220
|
+
j += 1
|
|
221
|
+
continue
|
|
222
|
+
if cj == quote:
|
|
223
|
+
j += 1
|
|
224
|
+
break
|
|
225
|
+
if cj == '"' and quote == "'":
|
|
226
|
+
# a double quote inside a single-quoted string must be
|
|
227
|
+
# escaped in the JSON output.
|
|
228
|
+
buf.append('\\"')
|
|
229
|
+
j += 1
|
|
230
|
+
continue
|
|
231
|
+
buf.append(cj)
|
|
232
|
+
j += 1
|
|
233
|
+
else:
|
|
234
|
+
raise ValueError("unterminated string")
|
|
235
|
+
buf.append('"')
|
|
236
|
+
out.append("".join(buf))
|
|
237
|
+
i = j
|
|
238
|
+
continue
|
|
239
|
+
|
|
240
|
+
# --- unquoted key/identifier: quote it ---
|
|
241
|
+
if c.isalpha() or c == "_":
|
|
242
|
+
j = i
|
|
243
|
+
while j < n and (s[j].isalnum() or s[j] in "_-."):
|
|
244
|
+
j += 1
|
|
245
|
+
word = s[i:j]
|
|
246
|
+
lowered = word.lower()
|
|
247
|
+
if lowered in ("true", "false", "null"):
|
|
248
|
+
out.append(lowered)
|
|
249
|
+
else:
|
|
250
|
+
# bareword -> treat as a quoted key/string token
|
|
251
|
+
out.append('"' + word + '"')
|
|
252
|
+
i = j
|
|
253
|
+
continue
|
|
254
|
+
|
|
255
|
+
# --- trailing comma: drop a comma that is followed only by ws then } or ] ---
|
|
256
|
+
if c == ",":
|
|
257
|
+
k = i + 1
|
|
258
|
+
while k < n and s[k] in " \t\r\n":
|
|
259
|
+
k += 1
|
|
260
|
+
if k < n and s[k] in "}]":
|
|
261
|
+
i += 1 # skip the comma
|
|
262
|
+
continue
|
|
263
|
+
out.append(",")
|
|
264
|
+
i += 1
|
|
265
|
+
continue
|
|
266
|
+
|
|
267
|
+
out.append(c)
|
|
268
|
+
i += 1
|
|
269
|
+
|
|
270
|
+
candidate = "".join(out)
|
|
271
|
+
try:
|
|
272
|
+
return json.loads(candidate)
|
|
273
|
+
except json.JSONDecodeError as exc: # pragma: no cover - defensive
|
|
274
|
+
raise ValueError(f"lenient parse failed: {exc}") from exc
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _parse_json_object(body: str) -> Any:
|
|
278
|
+
"""Strict json.loads, then a lenient fallback. Raises ValueError on failure."""
|
|
279
|
+
try:
|
|
280
|
+
return json.loads(body)
|
|
281
|
+
except json.JSONDecodeError:
|
|
282
|
+
pass
|
|
283
|
+
return _lenient_json_loads(_strip_double_braces(body))
|
|
284
|
+
|
|
285
|
+
|
|
92
286
|
# ------------------------------------------------------------------
|
|
93
287
|
# Scanners: fenced code blocks, then balanced braces in remaining text
|
|
94
288
|
# ------------------------------------------------------------------
|
|
95
289
|
|
|
96
290
|
# Match ```json ... ``` or ``` ... ``` with anything after the fence tag line.
|
|
97
|
-
# Group 1 captures the body.
|
|
291
|
+
# Group 1 captures the (optional) language tag, group 2 the body.
|
|
98
292
|
_FENCED_RE = re.compile(
|
|
99
|
-
r"```(
|
|
293
|
+
r"```([\w+-]*)[ \t]*\r?\n(.*?)\r?\n?```",
|
|
100
294
|
re.DOTALL,
|
|
101
295
|
)
|
|
102
296
|
|
|
297
|
+
# Language tags that denote *real source code* (not a serialised tool call).
|
|
298
|
+
# A fence carrying one of these is protected: its interior is never scanned by
|
|
299
|
+
# the bare-JSON / XML scanners, so an allow-listed name written as a code
|
|
300
|
+
# literal inside such a fence is not turned into a tool call.
|
|
301
|
+
_CODE_FENCE_LANGS = frozenset(
|
|
302
|
+
{
|
|
303
|
+
"python",
|
|
304
|
+
"py",
|
|
305
|
+
"python3",
|
|
306
|
+
"js",
|
|
307
|
+
"javascript",
|
|
308
|
+
"ts",
|
|
309
|
+
"typescript",
|
|
310
|
+
"jsx",
|
|
311
|
+
"tsx",
|
|
312
|
+
"go",
|
|
313
|
+
"golang",
|
|
314
|
+
"rust",
|
|
315
|
+
"rs",
|
|
316
|
+
"java",
|
|
317
|
+
"c",
|
|
318
|
+
"cpp",
|
|
319
|
+
"c++",
|
|
320
|
+
"cs",
|
|
321
|
+
"csharp",
|
|
322
|
+
"ruby",
|
|
323
|
+
"rb",
|
|
324
|
+
"php",
|
|
325
|
+
"sh",
|
|
326
|
+
"bash",
|
|
327
|
+
"shell",
|
|
328
|
+
"zsh",
|
|
329
|
+
"sql",
|
|
330
|
+
"html",
|
|
331
|
+
"css",
|
|
332
|
+
"yaml",
|
|
333
|
+
"yml",
|
|
334
|
+
"toml",
|
|
335
|
+
"ini",
|
|
336
|
+
"kotlin",
|
|
337
|
+
"swift",
|
|
338
|
+
"scala",
|
|
339
|
+
"lua",
|
|
340
|
+
"perl",
|
|
341
|
+
"r",
|
|
342
|
+
"dart",
|
|
343
|
+
"elixir",
|
|
344
|
+
"haskell",
|
|
345
|
+
}
|
|
346
|
+
)
|
|
347
|
+
|
|
103
348
|
|
|
104
349
|
def _extract_tool_call_fenced_blocks(
|
|
105
350
|
text: str,
|
|
@@ -107,25 +352,32 @@ def _extract_tool_call_fenced_blocks(
|
|
|
107
352
|
) -> tuple[str, list[dict[str, Any]]]:
|
|
108
353
|
"""Pull tool-call-shaped ```...``` blocks out of text.
|
|
109
354
|
|
|
110
|
-
Only fenced blocks whose body parses
|
|
111
|
-
removed from the text and returned as
|
|
112
|
-
other fenced block (prose, real source
|
|
113
|
-
verbatim in the returned text — the
|
|
114
|
-
decision use the exact same predicate,
|
|
115
|
-
without also being surfaced as a tool
|
|
116
|
-
|
|
355
|
+
Only fenced blocks whose body parses (strictly or leniently) to a
|
|
356
|
+
recognised tool-call shape are removed from the text and returned as
|
|
357
|
+
normalised OpenAI tool_calls. Any other fenced block (prose, real source
|
|
358
|
+
code, non-tool JSON) is preserved verbatim in the returned text — the
|
|
359
|
+
removal decision and the extraction decision use the exact same predicate,
|
|
360
|
+
so a fenced block is never dropped without also being surfaced as a tool
|
|
361
|
+
call (bug H2).
|
|
362
|
+
|
|
363
|
+
Fences carrying a *programming-language* tag (``python``, ``js`` ...) are
|
|
364
|
+
protected: their body is never parsed as a tool call, even if it happens to
|
|
365
|
+
contain a tool-shaped literal. This is the code-fence false-positive guard.
|
|
117
366
|
|
|
118
367
|
Returns (text_without_tool_fences, tool_calls).
|
|
119
368
|
"""
|
|
120
369
|
tool_calls: list[dict[str, Any]] = []
|
|
121
370
|
|
|
122
371
|
def _repair(match: re.Match[str]) -> str:
|
|
123
|
-
|
|
372
|
+
lang = (match.group(1) or "").strip().lower()
|
|
373
|
+
body = match.group(2).strip()
|
|
374
|
+
if lang in _CODE_FENCE_LANGS:
|
|
375
|
+
return match.group(0) # protected source-code fence
|
|
124
376
|
if not body.startswith("{"):
|
|
125
377
|
return match.group(0) # keep non-JSON fenced blocks (e.g. code)
|
|
126
378
|
try:
|
|
127
|
-
obj =
|
|
128
|
-
except
|
|
379
|
+
obj = _parse_json_object(body)
|
|
380
|
+
except ValueError:
|
|
129
381
|
return match.group(0) # keep unparseable fenced blocks
|
|
130
382
|
hit = _looks_like_tool_call(obj, allowed)
|
|
131
383
|
if hit is None:
|
|
@@ -138,12 +390,36 @@ def _extract_tool_call_fenced_blocks(
|
|
|
138
390
|
return cleaned, tool_calls
|
|
139
391
|
|
|
140
392
|
|
|
393
|
+
def _protected_code_spans(text: str) -> list[tuple[int, int]]:
|
|
394
|
+
"""Byte spans of programming-language code fences to exclude from scanning.
|
|
395
|
+
|
|
396
|
+
Returns (start, end) index pairs covering the *entire* fenced block
|
|
397
|
+
(fence markers included) for fences whose language tag is a real
|
|
398
|
+
programming language. Used to shield code from the bare-JSON / XML scanners.
|
|
399
|
+
"""
|
|
400
|
+
spans: list[tuple[int, int]] = []
|
|
401
|
+
for m in _FENCED_RE.finditer(text):
|
|
402
|
+
lang = (m.group(1) or "").strip().lower()
|
|
403
|
+
if lang in _CODE_FENCE_LANGS:
|
|
404
|
+
spans.append((m.start(), m.end()))
|
|
405
|
+
return spans
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _in_spans(pos: int, spans: list[tuple[int, int]]) -> bool:
|
|
409
|
+
return any(start <= pos < end for start, end in spans)
|
|
410
|
+
|
|
411
|
+
|
|
141
412
|
def _find_balanced_json_objects(text: str) -> list[tuple[int, int, str]]:
|
|
142
413
|
"""Find top-level `{...}` JSON substrings by a brace-counter scan.
|
|
143
414
|
|
|
144
415
|
Returns a list of (start, end_exclusive, substring). Handles escape
|
|
145
416
|
sequences and string literals so braces inside JSON strings do not
|
|
146
417
|
confuse the counter. Malformed (unclosed) candidates are skipped.
|
|
418
|
+
|
|
419
|
+
Note: this deliberately matches strict JSON strings (double-quoted) for
|
|
420
|
+
brace balancing. Single-quoted / unquoted malformed objects are found by
|
|
421
|
+
:func:`_find_candidate_object_spans` instead, which brace-balances without
|
|
422
|
+
assuming JSON string syntax.
|
|
147
423
|
"""
|
|
148
424
|
out: list[tuple[int, int, str]] = []
|
|
149
425
|
n = len(text)
|
|
@@ -185,6 +461,287 @@ def _find_balanced_json_objects(text: str) -> list[tuple[int, int, str]]:
|
|
|
185
461
|
return out
|
|
186
462
|
|
|
187
463
|
|
|
464
|
+
def _find_candidate_object_spans(text: str) -> list[tuple[int, int, str]]:
|
|
465
|
+
"""Brace-balance ``{...}`` spans, tolerating single/double quoted strings.
|
|
466
|
+
|
|
467
|
+
Unlike :func:`_find_balanced_json_objects`, this counts braces while
|
|
468
|
+
respecting BOTH ``'`` and ``"`` string delimiters, so it can locate
|
|
469
|
+
Python-repr / single-quoted malformed objects for the lenient parser.
|
|
470
|
+
Returns (start, end_exclusive, substring).
|
|
471
|
+
"""
|
|
472
|
+
out: list[tuple[int, int, str]] = []
|
|
473
|
+
n = len(text)
|
|
474
|
+
i = 0
|
|
475
|
+
while i < n:
|
|
476
|
+
if text[i] != "{":
|
|
477
|
+
i += 1
|
|
478
|
+
continue
|
|
479
|
+
depth = 0
|
|
480
|
+
j = i
|
|
481
|
+
quote: str | None = None
|
|
482
|
+
escape = False
|
|
483
|
+
while j < n:
|
|
484
|
+
c = text[j]
|
|
485
|
+
if escape:
|
|
486
|
+
escape = False
|
|
487
|
+
elif quote is not None:
|
|
488
|
+
if c == "\\":
|
|
489
|
+
escape = True
|
|
490
|
+
elif c == quote:
|
|
491
|
+
quote = None
|
|
492
|
+
else:
|
|
493
|
+
if c == '"' or c == "'":
|
|
494
|
+
quote = c
|
|
495
|
+
elif c == "{":
|
|
496
|
+
depth += 1
|
|
497
|
+
elif c == "}":
|
|
498
|
+
depth -= 1
|
|
499
|
+
if depth == 0:
|
|
500
|
+
out.append((i, j + 1, text[i : j + 1]))
|
|
501
|
+
i = j + 1
|
|
502
|
+
break
|
|
503
|
+
j += 1
|
|
504
|
+
else:
|
|
505
|
+
i += 1
|
|
506
|
+
continue
|
|
507
|
+
return out
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
# ------------------------------------------------------------------
|
|
511
|
+
# Prose-cue guard: bare JSON introduced as an *example* is not a call
|
|
512
|
+
# ------------------------------------------------------------------
|
|
513
|
+
|
|
514
|
+
# If one of these phrases appears immediately before a bare JSON object (within
|
|
515
|
+
# a short window, on the same clause), the object is being *described* rather
|
|
516
|
+
# than emitted as a call. Conservative: only suppresses, never forces a repair.
|
|
517
|
+
_PROSE_CUE_RE = re.compile(
|
|
518
|
+
r"(?:"
|
|
519
|
+
r"for\s+example|for\s+instance|e\.?g\.?|such\s+as|"
|
|
520
|
+
r"you\s+(?:would|could|can|might)\s+write|"
|
|
521
|
+
r"you\s+would\s+(?:use|call)|"
|
|
522
|
+
r"(?:is|are)\s+documented(?:\s+as)?|documented\s+like|"
|
|
523
|
+
r"looks?\s+like\s+this|written\s+as|the\s+format\s+is|"
|
|
524
|
+
r"syntax\s+is|例えば|たとえば|のように書"
|
|
525
|
+
r")"
|
|
526
|
+
r"[^\n{]{0,40}$",
|
|
527
|
+
re.IGNORECASE,
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _preceded_by_prose_cue(text: str, start: int) -> bool:
|
|
532
|
+
"""True if a documentation/example cue immediately precedes position ``start``."""
|
|
533
|
+
prefix = text[:start]
|
|
534
|
+
# Look only within the current line/clause (last ~80 chars, no newline jump
|
|
535
|
+
# further than the immediate lead-in).
|
|
536
|
+
tail = prefix[-80:]
|
|
537
|
+
return bool(_PROSE_CUE_RE.search(tail))
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
# ------------------------------------------------------------------
|
|
541
|
+
# R3: XML-flavoured tool-call forms
|
|
542
|
+
# ------------------------------------------------------------------
|
|
543
|
+
|
|
544
|
+
# Tags that are known NOT to be tool calls: reasoning wrappers and common HTML.
|
|
545
|
+
_NON_TOOL_TAGS = frozenset(
|
|
546
|
+
{
|
|
547
|
+
"think",
|
|
548
|
+
"thinking",
|
|
549
|
+
"thought",
|
|
550
|
+
"reasoning",
|
|
551
|
+
"scratchpad",
|
|
552
|
+
"reflection",
|
|
553
|
+
"answer",
|
|
554
|
+
"final",
|
|
555
|
+
"p",
|
|
556
|
+
"div",
|
|
557
|
+
"span",
|
|
558
|
+
"ul",
|
|
559
|
+
"ol",
|
|
560
|
+
"li",
|
|
561
|
+
"a",
|
|
562
|
+
"b",
|
|
563
|
+
"i",
|
|
564
|
+
"em",
|
|
565
|
+
"strong",
|
|
566
|
+
"code",
|
|
567
|
+
"pre",
|
|
568
|
+
"br",
|
|
569
|
+
"hr",
|
|
570
|
+
"h1",
|
|
571
|
+
"h2",
|
|
572
|
+
"h3",
|
|
573
|
+
"h4",
|
|
574
|
+
"h5",
|
|
575
|
+
"h6",
|
|
576
|
+
"table",
|
|
577
|
+
"tr",
|
|
578
|
+
"td",
|
|
579
|
+
"th",
|
|
580
|
+
"img",
|
|
581
|
+
"button",
|
|
582
|
+
"form",
|
|
583
|
+
"input",
|
|
584
|
+
"label",
|
|
585
|
+
"section",
|
|
586
|
+
"article",
|
|
587
|
+
"header",
|
|
588
|
+
"footer",
|
|
589
|
+
"nav",
|
|
590
|
+
"tool", # handled separately as a generic wrapper (body carries name)
|
|
591
|
+
}
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
# Generic wrapper tags whose *body* carries the actual tool-call JSON.
|
|
595
|
+
_WRAPPER_TAGS = frozenset({"tool", "tool_call", "function_call", "invoke"})
|
|
596
|
+
|
|
597
|
+
# <tag attr="v" .../> or <tag attr="v" ...> (self-closing or open; we only
|
|
598
|
+
# treat the self-closing / immediately-closed form as an attribute call).
|
|
599
|
+
_XML_SELFCLOSE_RE = re.compile(
|
|
600
|
+
r"<([A-Za-z_][\w.-]*)((?:\s+[\w.:-]+\s*=\s*\"[^\"]*\")*)\s*/>",
|
|
601
|
+
)
|
|
602
|
+
# <tag> ... </tag> wrapper.
|
|
603
|
+
_XML_WRAPPER_RE = re.compile(
|
|
604
|
+
r"<([A-Za-z_][\w.-]*)\s*>(.*?)</\1\s*>",
|
|
605
|
+
re.DOTALL,
|
|
606
|
+
)
|
|
607
|
+
_XML_ATTR_RE = re.compile(r"([\w.:-]+)\s*=\s*\"([^\"]*)\"")
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _known_non_tool_tag_ranges(text: str) -> list[tuple[int, int]]:
|
|
611
|
+
"""Ranges covered by <think>...</think> (and similar) reasoning blocks.
|
|
612
|
+
|
|
613
|
+
XML extraction must not reach inside these, so a tool-shaped tag mentioned
|
|
614
|
+
in reasoning is never executed.
|
|
615
|
+
"""
|
|
616
|
+
ranges: list[tuple[int, int]] = []
|
|
617
|
+
for tag in ("think", "thinking", "thought", "reasoning", "scratchpad"):
|
|
618
|
+
for m in re.finditer(
|
|
619
|
+
rf"<{tag}\b[^>]*>.*?</{tag}\s*>", text, re.DOTALL | re.IGNORECASE
|
|
620
|
+
):
|
|
621
|
+
ranges.append((m.start(), m.end()))
|
|
622
|
+
return ranges
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _extract_xml_tool_calls(
|
|
626
|
+
text: str,
|
|
627
|
+
allowed: set[str] | None,
|
|
628
|
+
protected: list[tuple[int, int]],
|
|
629
|
+
) -> tuple[str, list[dict[str, Any]]]:
|
|
630
|
+
"""Extract R3 XML-flavoured tool calls, honouring guards.
|
|
631
|
+
|
|
632
|
+
Returns (text_with_xml_calls_removed, tool_calls). Only forms whose tag /
|
|
633
|
+
inner name resolve to an allow-listed tool are removed.
|
|
634
|
+
"""
|
|
635
|
+
if allowed is None:
|
|
636
|
+
# Without an allow-list, XML extraction is too risky (any <tag> could
|
|
637
|
+
# look like a call). Skip R3 entirely — keeps false positives at zero.
|
|
638
|
+
return text, []
|
|
639
|
+
if "<" not in text:
|
|
640
|
+
return text, []
|
|
641
|
+
|
|
642
|
+
tool_calls: list[dict[str, Any]] = []
|
|
643
|
+
reasoning = _known_non_tool_tag_ranges(text)
|
|
644
|
+
guard = protected + reasoning
|
|
645
|
+
removals: list[tuple[int, int]] = []
|
|
646
|
+
|
|
647
|
+
def _blocked(pos: int) -> bool:
|
|
648
|
+
return _in_spans(pos, guard) or _preceded_by_prose_cue(text, pos)
|
|
649
|
+
|
|
650
|
+
# --- wrapper form: <tool>{JSON}</tool> and <name>{JSON}</name> ---
|
|
651
|
+
for m in _XML_WRAPPER_RE.finditer(text):
|
|
652
|
+
tag = m.group(1)
|
|
653
|
+
inner = m.group(2).strip()
|
|
654
|
+
tag_l = tag.lower()
|
|
655
|
+
if _blocked(m.start()):
|
|
656
|
+
continue
|
|
657
|
+
if tag_l in _WRAPPER_TAGS:
|
|
658
|
+
# Body carries the whole tool-call object.
|
|
659
|
+
if not inner.startswith("{"):
|
|
660
|
+
continue
|
|
661
|
+
try:
|
|
662
|
+
obj = _parse_json_object(inner)
|
|
663
|
+
except ValueError:
|
|
664
|
+
continue
|
|
665
|
+
hit = _looks_like_tool_call(obj, allowed)
|
|
666
|
+
if hit is None:
|
|
667
|
+
continue
|
|
668
|
+
name, args = hit
|
|
669
|
+
tool_calls.append(_normalise_to_openai_tool_call(name, args))
|
|
670
|
+
removals.append((m.start(), m.end()))
|
|
671
|
+
continue
|
|
672
|
+
# Named-wrapper: the tag itself is the tool name, body is the arguments.
|
|
673
|
+
if tag_l in _NON_TOOL_TAGS:
|
|
674
|
+
continue
|
|
675
|
+
if tag not in allowed:
|
|
676
|
+
continue
|
|
677
|
+
if not inner.startswith("{"):
|
|
678
|
+
continue
|
|
679
|
+
try:
|
|
680
|
+
args_obj = _parse_json_object(inner)
|
|
681
|
+
except ValueError:
|
|
682
|
+
continue
|
|
683
|
+
if not isinstance(args_obj, dict):
|
|
684
|
+
continue
|
|
685
|
+
tool_calls.append(_normalise_to_openai_tool_call(tag, args_obj))
|
|
686
|
+
removals.append((m.start(), m.end()))
|
|
687
|
+
|
|
688
|
+
# --- self-closing attribute form: <echo message="probe"/> ---
|
|
689
|
+
for m in _XML_SELFCLOSE_RE.finditer(text):
|
|
690
|
+
tag = m.group(1)
|
|
691
|
+
if tag.lower() in _NON_TOOL_TAGS:
|
|
692
|
+
continue
|
|
693
|
+
if tag not in allowed:
|
|
694
|
+
continue
|
|
695
|
+
if _blocked(m.start()):
|
|
696
|
+
continue
|
|
697
|
+
# Don't double-extract something already inside a wrapper removal.
|
|
698
|
+
if _in_spans(m.start(), removals):
|
|
699
|
+
continue
|
|
700
|
+
attrs = dict(_XML_ATTR_RE.findall(m.group(2) or ""))
|
|
701
|
+
tool_calls.append(_normalise_to_openai_tool_call(tag, attrs))
|
|
702
|
+
removals.append((m.start(), m.end()))
|
|
703
|
+
|
|
704
|
+
if not removals:
|
|
705
|
+
return text, []
|
|
706
|
+
|
|
707
|
+
# Remove matched spans back-to-front; merge overlaps first.
|
|
708
|
+
removals.sort()
|
|
709
|
+
merged: list[tuple[int, int]] = []
|
|
710
|
+
for s, e in removals:
|
|
711
|
+
if merged and s < merged[-1][1]:
|
|
712
|
+
merged[-1] = (merged[-1][0], max(merged[-1][1], e))
|
|
713
|
+
else:
|
|
714
|
+
merged.append((s, e))
|
|
715
|
+
cleaned = text
|
|
716
|
+
for s, e in reversed(merged):
|
|
717
|
+
cleaned = cleaned[:s] + cleaned[e:]
|
|
718
|
+
return cleaned, tool_calls
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
# ------------------------------------------------------------------
|
|
722
|
+
# Residue cleanup (trap #2)
|
|
723
|
+
# ------------------------------------------------------------------
|
|
724
|
+
|
|
725
|
+
# Empty fenced blocks left after a tool-call body was removed.
|
|
726
|
+
_EMPTY_FENCE_RE = re.compile(r"```[\w+-]*[ \t]*\r?\n?[ \t]*\r?\n?```")
|
|
727
|
+
# Array skeletons like "[, ]" or "[ , , ]" left after removing array elements.
|
|
728
|
+
_EMPTY_ARRAY_RE = re.compile(r"\[\s*(?:,\s*)+\]")
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def _cleanup_residue(text: str) -> str:
|
|
732
|
+
"""Remove cosmetic leftovers from extraction (empty fences, ``[,]``).
|
|
733
|
+
|
|
734
|
+
Only *empty* fenced blocks (a fence pair with nothing between them) are
|
|
735
|
+
removed — a lone closing ``` that still belongs to an intact, preserved
|
|
736
|
+
code fence is left alone, so protected source blocks stay valid.
|
|
737
|
+
"""
|
|
738
|
+
text = _EMPTY_FENCE_RE.sub("", text)
|
|
739
|
+
text = _EMPTY_ARRAY_RE.sub("", text)
|
|
740
|
+
# Collapse a lone remaining empty array pair.
|
|
741
|
+
text = re.sub(r"\[\s*\]", "", text)
|
|
742
|
+
return text
|
|
743
|
+
|
|
744
|
+
|
|
188
745
|
# ------------------------------------------------------------------
|
|
189
746
|
# Public API
|
|
190
747
|
# ------------------------------------------------------------------
|
|
@@ -217,17 +774,31 @@ def repair_tool_calls_in_text(
|
|
|
217
774
|
# common shape when a chat-tuned model explains what it's doing. Fenced
|
|
218
775
|
# blocks that are NOT tool calls (prose, real source code, plain JSON
|
|
219
776
|
# examples) are left in place so we never drop legitimate content (H2).
|
|
777
|
+
# Programming-language fences are protected outright.
|
|
220
778
|
cleaned, fenced_tool_calls = _extract_tool_call_fenced_blocks(text, allowed)
|
|
221
779
|
extracted.extend(fenced_tool_calls)
|
|
222
780
|
|
|
223
|
-
# 2.
|
|
224
|
-
#
|
|
225
|
-
#
|
|
226
|
-
|
|
227
|
-
|
|
781
|
+
# 2. R3: XML-flavoured forms (only when an allow-list constrains names).
|
|
782
|
+
# Runs on the fence-stripped text; protected code fences and reasoning
|
|
783
|
+
# blocks are shielded.
|
|
784
|
+
protected = _protected_code_spans(cleaned)
|
|
785
|
+
cleaned, xml_tool_calls = _extract_xml_tool_calls(cleaned, allowed, protected)
|
|
786
|
+
extracted.extend(xml_tool_calls)
|
|
787
|
+
|
|
788
|
+
# 3. Scan remaining text for bare JSON objects (strict then lenient).
|
|
789
|
+
# Skip anything inside a protected code fence or introduced by a prose
|
|
790
|
+
# example cue. Walk back-to-front so slicing removals don't shift the
|
|
791
|
+
# indices of earlier matches.
|
|
792
|
+
protected = _protected_code_spans(cleaned)
|
|
228
793
|
spans_to_remove: list[tuple[int, int]] = []
|
|
229
794
|
repaired_from_bare: list[dict[str, Any]] = []
|
|
230
|
-
|
|
795
|
+
|
|
796
|
+
# First pass: strict-JSON balanced objects.
|
|
797
|
+
for start, end, substr in _find_balanced_json_objects(cleaned):
|
|
798
|
+
if _in_spans(start, protected):
|
|
799
|
+
continue
|
|
800
|
+
if _preceded_by_prose_cue(cleaned, start):
|
|
801
|
+
continue
|
|
231
802
|
try:
|
|
232
803
|
obj = json.loads(substr)
|
|
233
804
|
except json.JSONDecodeError:
|
|
@@ -239,13 +810,47 @@ def repair_tool_calls_in_text(
|
|
|
239
810
|
repaired_from_bare.append(_normalise_to_openai_tool_call(name, args))
|
|
240
811
|
spans_to_remove.append((start, end))
|
|
241
812
|
|
|
242
|
-
#
|
|
243
|
-
|
|
813
|
+
# Second pass: lenient objects (single-quoted / unquoted / trailing comma /
|
|
814
|
+
# double-brace) that strict parsing missed. Skip spans already claimed.
|
|
815
|
+
for start, end, substr in _find_candidate_object_spans(cleaned):
|
|
816
|
+
if _in_spans(start, spans_to_remove):
|
|
817
|
+
continue
|
|
818
|
+
if _in_spans(start, protected):
|
|
819
|
+
continue
|
|
820
|
+
if _preceded_by_prose_cue(cleaned, start):
|
|
821
|
+
continue
|
|
822
|
+
# Skip if strict JSON already parses this (handled in first pass).
|
|
823
|
+
try:
|
|
824
|
+
json.loads(substr)
|
|
825
|
+
continue # strict-parseable -> either already handled or non-tool
|
|
826
|
+
except json.JSONDecodeError:
|
|
827
|
+
pass
|
|
828
|
+
try:
|
|
829
|
+
obj = _lenient_json_loads(_strip_double_braces(substr))
|
|
830
|
+
except ValueError:
|
|
831
|
+
continue
|
|
832
|
+
hit = _looks_like_tool_call(obj, allowed)
|
|
833
|
+
if hit is None:
|
|
834
|
+
continue
|
|
835
|
+
name, args = hit
|
|
836
|
+
repaired_from_bare.append(_normalise_to_openai_tool_call(name, args))
|
|
837
|
+
spans_to_remove.append((start, end))
|
|
838
|
+
|
|
839
|
+
# Remove the matched spans from the text back-to-front (merge overlaps).
|
|
840
|
+
spans_to_remove.sort()
|
|
841
|
+
merged: list[tuple[int, int]] = []
|
|
842
|
+
for s, e in spans_to_remove:
|
|
843
|
+
if merged and s < merged[-1][1]:
|
|
844
|
+
merged[-1] = (merged[-1][0], max(merged[-1][1], e))
|
|
845
|
+
else:
|
|
846
|
+
merged.append((s, e))
|
|
847
|
+
for start, end in reversed(merged):
|
|
244
848
|
cleaned = cleaned[:start] + cleaned[end:]
|
|
245
849
|
|
|
246
850
|
extracted.extend(repaired_from_bare)
|
|
247
851
|
|
|
248
|
-
#
|
|
852
|
+
# Residue cleanup (empty fences, [,], stray backticks) then whitespace.
|
|
853
|
+
cleaned = _cleanup_residue(cleaned)
|
|
249
854
|
cleaned = re.sub(r"[ \t]+\n", "\n", cleaned)
|
|
250
855
|
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned).strip()
|
|
251
856
|
|