toolpatch 0.4.0__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.
- toolpatch/__init__.py +51 -0
- toolpatch/extractor.py +206 -0
- toolpatch/fixer.py +63 -0
- toolpatch/logging.py +129 -0
- toolpatch/request.py +197 -0
- toolpatch/schemas.py +52 -0
- toolpatch/stream.py +214 -0
- toolpatch-0.4.0.dist-info/METADATA +254 -0
- toolpatch-0.4.0.dist-info/RECORD +10 -0
- toolpatch-0.4.0.dist-info/WHEEL +4 -0
toolpatch/__init__.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""toolpatch: LLM tool-calling template-mismatch detector and repairer."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError
|
|
4
|
+
from importlib.metadata import version as _metadata_version
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
__version__: str = _metadata_version("toolpatch")
|
|
8
|
+
except PackageNotFoundError: # unbuilt dev checkout
|
|
9
|
+
__version__ = "0.0.0.dev0+local"
|
|
10
|
+
|
|
11
|
+
from toolpatch.extractor import ExtractedToolCalls, extract_tool_calls
|
|
12
|
+
from toolpatch.fixer import heal_json
|
|
13
|
+
from toolpatch.logging import NullLogger, configure_logger, get_logger
|
|
14
|
+
from toolpatch.request import normalize_chat_request
|
|
15
|
+
from toolpatch.stream import StreamHealer
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _configure_logging(*, level: str | None = None, quiet: bool = False) -> None:
|
|
19
|
+
"""Thin wrapper so proxytool can drive toolpatch logging without importing
|
|
20
|
+
the stdlib logger setup inside toolpatch itself.
|
|
21
|
+
|
|
22
|
+
``level`` uses the same tier strings as proxytool: ``debug``, ``info``,
|
|
23
|
+
``warning``, or ``error``. When ``level`` is ``None``, toolpatch reverts
|
|
24
|
+
to its internal no-op logger.
|
|
25
|
+
"""
|
|
26
|
+
from toolpatch.logging import LogLevels
|
|
27
|
+
from toolpatch.logging import configure_logger as _cfg
|
|
28
|
+
|
|
29
|
+
_valid: dict[str, LogLevels] = {
|
|
30
|
+
"debug": "debug", "info": "info", "warning": "warning", "error": "error",
|
|
31
|
+
}
|
|
32
|
+
_level: LogLevels | None = None
|
|
33
|
+
if quiet:
|
|
34
|
+
_level = "error"
|
|
35
|
+
elif level:
|
|
36
|
+
_level = _valid.get(level)
|
|
37
|
+
_cfg("toolpatch", _level)
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"ExtractedToolCalls",
|
|
41
|
+
"NullLogger",
|
|
42
|
+
"StreamHealer",
|
|
43
|
+
"__version__",
|
|
44
|
+
"configure_logger",
|
|
45
|
+
"extract_tool_calls",
|
|
46
|
+
"get_logger",
|
|
47
|
+
"heal_json",
|
|
48
|
+
"normalize_chat_request",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
toolpatch/extractor.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Detect and extract tool-call payloads leaked into assistant ``content``.
|
|
2
|
+
|
|
3
|
+
Qwen (with a mismatched chat template) writes its tool call as text inside
|
|
4
|
+
``choices[*].message.content`` instead of the structured ``tool_calls`` field.
|
|
5
|
+
Recognition families (all merged into one result, document order):
|
|
6
|
+
|
|
7
|
+
1. Hermes XML: <tool_call>{...}</tool_call>
|
|
8
|
+
2. Fenced JSON: ```json {...} ```
|
|
9
|
+
3. Bare object: {"name": ..., "arguments": ...} inside prose
|
|
10
|
+
|
|
11
|
+
Families 2 and 3 can sit inside a template-specific wrapper tag — the live
|
|
12
|
+
Qwen2.5-Coder GGUF run emits ``<tools>{"name": …}</tools>`` or
|
|
13
|
+
``<function>{…}</function>`` depending on the template it loaded with, and
|
|
14
|
+
the MLX build ``<function-call>…</function-call>``. A wrapper around the
|
|
15
|
+
leak envelope is part of the leak: leaving ``<tools></tools>`` behind in
|
|
16
|
+
``content`` makes clients render stray markup next to the parsed call.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import re
|
|
23
|
+
import uuid
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from typing import Any, Literal
|
|
26
|
+
|
|
27
|
+
from toolpatch.fixer import heal_json
|
|
28
|
+
from toolpatch.logging import get_logger
|
|
29
|
+
|
|
30
|
+
log = get_logger("toolpatch.extractor")
|
|
31
|
+
|
|
32
|
+
_HERMES_OPEN = "<tool_call>"
|
|
33
|
+
_HERMES_CLOSE = "</tool_call>"
|
|
34
|
+
|
|
35
|
+
# Group 1 captures the JSON payload.
|
|
36
|
+
_HERMES_RE = re.compile(r"<tool_call>\s*(.*?)\s*</tool_call>", re.DOTALL)
|
|
37
|
+
_FENCED_RE = re.compile(r"```(?:json)?\s*(\{.*?\})\s*```", re.DOTALL)
|
|
38
|
+
_RAW_RE = re.compile(
|
|
39
|
+
r'\{\s*"name"\s*:\s*"[^"]+"\s*,\s*"arguments"\s*:\s*(\{[^{}]*\}|\{.*?\}|"[^"]*")\s*\}',
|
|
40
|
+
re.DOTALL,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
CallSource = Literal["hermes", "fenced", "raw_json", "mixed"]
|
|
44
|
+
|
|
45
|
+
# Template-specific wrappers around a leaked call (see module docstring):
|
|
46
|
+
# the same Qwen2.5-Coder build writes `<tools>{...}</tools>` or
|
|
47
|
+
# `<function>{...}</function>` depending on the chat template it was loaded
|
|
48
|
+
# with; MLX builds use `<function-call>`. All of them are leak syntax.
|
|
49
|
+
WRAPPER_TAGS = (
|
|
50
|
+
"tools", "functions", "function",
|
|
51
|
+
# singular/plural, hyphen/underscore: the same model has been seen writing
|
|
52
|
+
# `<function>`, `<function-call>` and `<function-calls>` on successive
|
|
53
|
+
# prompts with one chat template loaded.
|
|
54
|
+
"tool_call", "tool_calls", "tool-call", "tool-calls",
|
|
55
|
+
"function_call", "function_calls", "function-call", "function-calls",
|
|
56
|
+
)
|
|
57
|
+
_WRAPPER_OPEN_RE = re.compile(
|
|
58
|
+
r"<\s*(" + "|".join(re.escape(t) for t in WRAPPER_TAGS) + r")\s*>\s*$"
|
|
59
|
+
)
|
|
60
|
+
_WRAPPER_CLOSE = r"\s*</\s*{tag}\s*>"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class ExtractedToolCalls:
|
|
65
|
+
content: str | None # cleaned content; None when nothing remains
|
|
66
|
+
tool_calls: list[dict] = field(default_factory=list)
|
|
67
|
+
source: CallSource | None = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _to_tool_call(parsed: dict) -> dict:
|
|
71
|
+
args = parsed.get("arguments", {})
|
|
72
|
+
return {
|
|
73
|
+
"id": f"call_{uuid.uuid4().hex[:24]}",
|
|
74
|
+
"type": "function",
|
|
75
|
+
"function": {
|
|
76
|
+
"name": parsed["name"],
|
|
77
|
+
"arguments": args if isinstance(args, str) else json.dumps(args),
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _parse_candidate(regex: re.Pattern[str], m: re.Match[str]) -> dict[str, Any] | None:
|
|
83
|
+
"""Heal and parse one regex match; return the {"name": ...} dict or None.
|
|
84
|
+
|
|
85
|
+
The captured payload may be arguments-only (hermes/raw call): in that
|
|
86
|
+
case fall back to the full match, which is the call object itself.
|
|
87
|
+
"""
|
|
88
|
+
payload = m.group(1) if (regex.groups and m.group(1) is not None) else m.group(0)
|
|
89
|
+
healed = heal_json(payload)
|
|
90
|
+
if not healed:
|
|
91
|
+
return None
|
|
92
|
+
try:
|
|
93
|
+
parsed = json.loads(healed)
|
|
94
|
+
except json.JSONDecodeError:
|
|
95
|
+
return None
|
|
96
|
+
if isinstance(parsed, dict) and "name" in parsed:
|
|
97
|
+
return parsed
|
|
98
|
+
# The group-1 payload was arguments-only (hermes/raw call); the full
|
|
99
|
+
# match is the JSON object to inspect.
|
|
100
|
+
full = heal_json(m.group(0))
|
|
101
|
+
if not full:
|
|
102
|
+
return None
|
|
103
|
+
try:
|
|
104
|
+
parsed2 = json.loads(full)
|
|
105
|
+
except json.JSONDecodeError:
|
|
106
|
+
return None
|
|
107
|
+
return parsed2 if isinstance(parsed2, dict) and "name" in parsed2 else None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _absorb_wrapper(content: str, lo: int, hi: int) -> tuple[int, int]:
|
|
111
|
+
"""Extend a leak span over a template wrapper that encloses the call.
|
|
112
|
+
|
|
113
|
+
The wrapper is leak syntax, not prose: strip it with the payload instead
|
|
114
|
+
of leaving `<tools></tools>` in the repaired content. An opener with no
|
|
115
|
+
close yet (mid-stream) is absorbed too — only the tag is dropped.
|
|
116
|
+
"""
|
|
117
|
+
m = _WRAPPER_OPEN_RE.search(content[:lo])
|
|
118
|
+
if m is None:
|
|
119
|
+
return lo, hi
|
|
120
|
+
close = re.compile(_WRAPPER_CLOSE.format(tag=re.escape(m.group(1))))
|
|
121
|
+
cm = close.match(content, hi)
|
|
122
|
+
return m.start(), (cm.end() if cm else hi)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _collect_all(content: str,
|
|
126
|
+
tool_names: set[str] | None) -> ExtractedToolCalls | None:
|
|
127
|
+
"""Run every detector family and merge the distinct calls found.
|
|
128
|
+
|
|
129
|
+
Multi-call turns — several Hermes blocks, parallel fenced blocks, or a
|
|
130
|
+
mix of families — all land in ONE promotion frame, in document order.
|
|
131
|
+
Overlapping/nested matches (e.g. a raw {"name"…} object inside a fenced
|
|
132
|
+
block) dedupe to the outermost family, which is probed first.
|
|
133
|
+
"""
|
|
134
|
+
found: list[tuple[int, int, dict[str, Any], CallSource]] = []
|
|
135
|
+
detectors: tuple[tuple[CallSource, re.Pattern[str]], ...] = (
|
|
136
|
+
("hermes", _HERMES_RE),
|
|
137
|
+
("fenced", _FENCED_RE),
|
|
138
|
+
("raw_json", _RAW_RE),
|
|
139
|
+
)
|
|
140
|
+
for source, regex in detectors:
|
|
141
|
+
for m in regex.finditer(content):
|
|
142
|
+
span = m.span()
|
|
143
|
+
if any(span[0] < e and s < span[1] for s, e, _, _ in found):
|
|
144
|
+
continue # nested/overlapping: an outer family already claimed it
|
|
145
|
+
parsed = _parse_candidate(regex, m)
|
|
146
|
+
if parsed is None or (
|
|
147
|
+
tool_names is not None and parsed["name"] not in tool_names
|
|
148
|
+
):
|
|
149
|
+
continue
|
|
150
|
+
found.append((span[0], span[1], parsed, source))
|
|
151
|
+
if not found:
|
|
152
|
+
return None
|
|
153
|
+
found.sort(key=lambda c: (c[0], c[1]))
|
|
154
|
+
lo = found[0][0]
|
|
155
|
+
hi = found[-1][1] # nested/overlapping spans are deduped, so ends ascend
|
|
156
|
+
env_lo, env_hi = _absorb_wrapper(content, lo, hi)
|
|
157
|
+
# Whitespace OUTSIDE the leak envelope (pre-first / post-last span) is
|
|
158
|
+
# model padding around the leak — strip it. Whitespace INSIDE the
|
|
159
|
+
# envelope (between two leaked calls) is genuine inter-call prose —
|
|
160
|
+
# keep it verbatim, including its edge spaces. The envelope can reach
|
|
161
|
+
# wider than the calls themselves (template wrapper tags), so head and
|
|
162
|
+
# tail are cut at env_* while the call slices still come from lo/hi.
|
|
163
|
+
head = content[:env_lo].rstrip()
|
|
164
|
+
tail = content[env_hi:].lstrip()
|
|
165
|
+
cleaned = head
|
|
166
|
+
pos = lo
|
|
167
|
+
for s, e, _, _ in found:
|
|
168
|
+
cleaned += content[pos:s]
|
|
169
|
+
pos = e
|
|
170
|
+
cleaned += tail
|
|
171
|
+
calls = [_to_tool_call(parsed) for _, _, parsed, _ in found]
|
|
172
|
+
families_src: list[CallSource] = list(dict.fromkeys(
|
|
173
|
+
src for _, _, _, src in found))
|
|
174
|
+
source: CallSource = families_src[0] if len(families_src) == 1 else "mixed"
|
|
175
|
+
log.debug("extract probed: %d call(s) from %s", len(calls),
|
|
176
|
+
"+".join(families_src))
|
|
177
|
+
return ExtractedToolCalls(content=cleaned or None, tool_calls=calls, source=source)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def extract_tool_calls(content: str,
|
|
181
|
+
tool_names: set[str] | None = None,
|
|
182
|
+
allow_unterminated_hermes: bool = False) -> ExtractedToolCalls | None:
|
|
183
|
+
"""Extract leaked tool calls; None when the content is clean (passthrough).
|
|
184
|
+
|
|
185
|
+
Multiple distinct calls — Hermes, fenced, raw-JSON, in any mix — are
|
|
186
|
+
all returned in a single promotion (document order), so a parallel or
|
|
187
|
+
rapid-succession multi-tool turn promotes as one ``tool_calls`` array.
|
|
188
|
+
|
|
189
|
+
A hermes open tag with no close tag yet means the call is still forming
|
|
190
|
+
(mid-stream); the bare-JSON rule must not fire early or the open tag
|
|
191
|
+
would leak out as leftover prose.
|
|
192
|
+
"""
|
|
193
|
+
if _open_hermes_only(content):
|
|
194
|
+
if not allow_unterminated_hermes:
|
|
195
|
+
return None # still forming mid-stream: hold, don't half-extract
|
|
196
|
+
content = content + _HERMES_CLOSE # tolerate the missing close at end
|
|
197
|
+
return _collect_all(content, tool_names)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _open_hermes_only(content: str) -> bool:
|
|
201
|
+
"""True when a hermes open tag is present but not yet closed."""
|
|
202
|
+
first_open = content.find(_HERMES_OPEN)
|
|
203
|
+
if first_open == -1:
|
|
204
|
+
return False
|
|
205
|
+
first_close = content.find(_HERMES_CLOSE)
|
|
206
|
+
return first_close == -1 or first_close < first_open
|
toolpatch/fixer.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Heal malformed JSON emitted by Qwen inside tool-call payloads.
|
|
2
|
+
|
|
3
|
+
Repairs, in escalating order:
|
|
4
|
+
1. as-is (valid JSON passes through unchanged)
|
|
5
|
+
2. code-fence / smart-quote cleanup, then trailing-comma removal
|
|
6
|
+
3. trailing commas *and* raw newlines inside string literals
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
from toolpatch.logging import get_logger
|
|
15
|
+
|
|
16
|
+
log = get_logger("toolpatch.fixer")
|
|
17
|
+
|
|
18
|
+
_TRAILING_RE = re.compile(r",\s*([}\]])")
|
|
19
|
+
_FENCE_START = re.compile(r"^\s*```(?:json)?\s*")
|
|
20
|
+
_FENCE_END = re.compile(r"\s*```\s*$")
|
|
21
|
+
_SMART = str.maketrans({
|
|
22
|
+
"\u201c": '"', "\u201d": '"',
|
|
23
|
+
"\u2018": "'", "\u2019": "'",
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _unescape_stray_newlines_in_strings(s: str) -> str:
|
|
28
|
+
"""Escape raw newlines that appear inside JSON string literals."""
|
|
29
|
+
out: list[str] = []
|
|
30
|
+
in_str = False
|
|
31
|
+
esc = False
|
|
32
|
+
for ch in s:
|
|
33
|
+
if in_str and not esc and ch == "\n":
|
|
34
|
+
out.append("\\n")
|
|
35
|
+
continue
|
|
36
|
+
out.append(ch)
|
|
37
|
+
if ch == '"' and not esc:
|
|
38
|
+
in_str = not in_str
|
|
39
|
+
esc = in_str and ch == "\\" and not esc
|
|
40
|
+
return "".join(out)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def heal_json(text: str) -> str | None:
|
|
44
|
+
"""Return a repaired JSON string, or None when unrecoverable."""
|
|
45
|
+
if not text:
|
|
46
|
+
return None
|
|
47
|
+
log.debug("fixer.heal_json: attempt on %d chars", len(text))
|
|
48
|
+
c = _FENCE_START.sub("", text.strip())
|
|
49
|
+
c = _FENCE_END.sub("", c)
|
|
50
|
+
c = c.translate(_SMART)
|
|
51
|
+
|
|
52
|
+
candidates = (
|
|
53
|
+
c,
|
|
54
|
+
_TRAILING_RE.sub(r"\1", c),
|
|
55
|
+
_TRAILING_RE.sub(r"\1", _unescape_stray_newlines_in_strings(c)),
|
|
56
|
+
)
|
|
57
|
+
for attempt in candidates:
|
|
58
|
+
try:
|
|
59
|
+
json.loads(attempt)
|
|
60
|
+
return attempt
|
|
61
|
+
except json.JSONDecodeError:
|
|
62
|
+
continue
|
|
63
|
+
return None
|
toolpatch/logging.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Lightweight logging support for toolpatch.
|
|
2
|
+
|
|
3
|
+
toolpatch is a pure protocol-repair library. It does not configure the
|
|
4
|
+
Python logging system itself; consumers (proxytool) do that at app startup.
|
|
5
|
+
|
|
6
|
+
This module provides:
|
|
7
|
+
|
|
8
|
+
* ``configure_logger(name, level)`` — create/return a logger for ``name`` at
|
|
9
|
+
an explicit level, or a no-op logger when ``level`` is None.
|
|
10
|
+
* ``NullLogger`` — a drop-in logger whose methods are no-ops.
|
|
11
|
+
* ``LogLevels`` — the accepted tier strings shared by proxytool and toolpatch.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any, Literal
|
|
17
|
+
|
|
18
|
+
LogLevels = Literal["debug", "info", "warning", "error"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def configure_logger(name: str, level: LogLevels | None) -> Any:
|
|
22
|
+
"""Return a logger for ``name`` when ``level`` is set, else a no-op logger.
|
|
23
|
+
|
|
24
|
+
When a level is supplied, the underlying stdlib logger is created and its
|
|
25
|
+
threshold is set. It is intentionally not attached to any handler here —
|
|
26
|
+
the host app (proxytool) owns handler/transport configuration so that
|
|
27
|
+
logging can be routed alongside Uvicorn/FastAPI output whichever way the
|
|
28
|
+
app wants.
|
|
29
|
+
"""
|
|
30
|
+
if level is None:
|
|
31
|
+
return NullLogger()
|
|
32
|
+
|
|
33
|
+
import logging
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(name)
|
|
36
|
+
logger.setLevel(_resolve_level(level))
|
|
37
|
+
return logger
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_logger(name: str = "toolpatch") -> Any:
|
|
41
|
+
"""Shared logger for toolpatch internals.
|
|
42
|
+
|
|
43
|
+
Always returns the real stdlib logger for ``name``. Before the host app
|
|
44
|
+
calls ``configure_logger``, it has no handlers and the default root
|
|
45
|
+
level drops debug/info lines silently; afterwards, the app-configured
|
|
46
|
+
level and handlers take over.
|
|
47
|
+
"""
|
|
48
|
+
import logging
|
|
49
|
+
|
|
50
|
+
return logging.getLogger(name)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _configure_app_handler(*, level: int) -> None:
|
|
54
|
+
"""Best-effort handler setup for the shared ``toolpatch`` logger.
|
|
55
|
+
|
|
56
|
+
Called by proxytool's ``configure_logging`` so toolpatch debug lines are
|
|
57
|
+
routed through the same stderr stream/progress as app output. When
|
|
58
|
+
logging is already configured elsewhere, this is a no-op.
|
|
59
|
+
"""
|
|
60
|
+
import logging as _logging
|
|
61
|
+
|
|
62
|
+
logger = _logging.getLogger("toolpatch")
|
|
63
|
+
if logger.handlers:
|
|
64
|
+
return
|
|
65
|
+
_configure_handler(logger, level)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _configure_handler(logger: Any, level: int) -> None:
|
|
69
|
+
"""Attach a stderr handler to ``logger`` when one is not already present."""
|
|
70
|
+
import logging as _logging
|
|
71
|
+
import sys as _sys
|
|
72
|
+
|
|
73
|
+
has_target = any(
|
|
74
|
+
isinstance(h, _logging.StreamHandler) and getattr(h, "stream", None) is _sys.stderr
|
|
75
|
+
for h in logger.handlers
|
|
76
|
+
)
|
|
77
|
+
if has_target:
|
|
78
|
+
return
|
|
79
|
+
handler = _logging.StreamHandler(_sys.stderr)
|
|
80
|
+
handler.setLevel(level)
|
|
81
|
+
handler.setFormatter(_logging.Formatter(
|
|
82
|
+
"%(levelname)s [%(name)s] %(message)s",
|
|
83
|
+
))
|
|
84
|
+
logger.addHandler(handler)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _emit(record: Any) -> None:
|
|
88
|
+
"""Best-effort emit through the real stdlib logger when it has handlers."""
|
|
89
|
+
import logging as _logging
|
|
90
|
+
|
|
91
|
+
logger = _logging.getLogger("toolpatch.emit")
|
|
92
|
+
if not logger.handlers:
|
|
93
|
+
return
|
|
94
|
+
logger.handle(record)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class NullLogger:
|
|
98
|
+
"""No-op logger used by toolpatch when no app-level logger has been injected.
|
|
99
|
+
|
|
100
|
+
Callers can freely call ``debug``/``info``/``warning``/``error`` without
|
|
101
|
+
guarding on ``isEnabledFor``.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
def debug(self, *args: Any, **kwargs: Any) -> None:
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
def info(self, *args: Any, **kwargs: Any) -> None:
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
def warning(self, *args: Any, **kwargs: Any) -> None:
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
def error(self, *args: Any, **kwargs: Any) -> None:
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
def exception(self, *args: Any, **kwargs: Any) -> None:
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _resolve_level(level: LogLevels) -> int:
|
|
121
|
+
import logging
|
|
122
|
+
|
|
123
|
+
mapping = {
|
|
124
|
+
"debug": logging.DEBUG,
|
|
125
|
+
"info": logging.INFO,
|
|
126
|
+
"warning": logging.WARNING,
|
|
127
|
+
"error": logging.ERROR,
|
|
128
|
+
}
|
|
129
|
+
return mapping[level]
|
toolpatch/request.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Outbound request normalization: give the backend its best shot at matching
|
|
2
|
+
its chat template, without mutating the caller's payload.
|
|
3
|
+
|
|
4
|
+
Backend-template history rules (from live opencode traces):
|
|
5
|
+
|
|
6
|
+
- Llama-server rejects histories ending in 2+ assistant messages
|
|
7
|
+
("Cannot have 2 or more assistant messages at the end of the list.").
|
|
8
|
+
- When the extractor repairs a leaked tool call, the client records BOTH the
|
|
9
|
+
leaked text turn and the repaired ``tool_calls`` turn — the text twin must be
|
|
10
|
+
re-attached to the structured turn to form one assistant message.
|
|
11
|
+
- Identical consecutive assistant turns (client double-append) are deduped.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import copy
|
|
17
|
+
from typing import Any, Literal
|
|
18
|
+
|
|
19
|
+
Backend = Literal["gguf", "mlx"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def normalize_chat_request(
|
|
23
|
+
payload: dict, backend: Backend = "gguf"
|
|
24
|
+
) -> tuple[dict, list[str]]:
|
|
25
|
+
"""Return (sanitized_payload, operator_notes). Input is deep-copied, never mutated."""
|
|
26
|
+
out = copy.deepcopy(payload)
|
|
27
|
+
notes: list[str] = []
|
|
28
|
+
|
|
29
|
+
# Rule 1: tool_choice unsupported by mlx_lm.server — strip for MLX.
|
|
30
|
+
if backend == "mlx" and "tool_choice" in out:
|
|
31
|
+
del out["tool_choice"]
|
|
32
|
+
notes.append("tool_choice stripped for mlx backend")
|
|
33
|
+
|
|
34
|
+
# Rule 2: empty/null tools drop the key entirely.
|
|
35
|
+
tools = out.get("tools")
|
|
36
|
+
if tools is not None and not tools:
|
|
37
|
+
del out["tools"]
|
|
38
|
+
notes.append("empty tools array dropped")
|
|
39
|
+
|
|
40
|
+
# Rule 3: parameters must be an object schema; drop offending tools.
|
|
41
|
+
if isinstance(tools, list):
|
|
42
|
+
good, for_name = [], {t.get("function", {}).get("name", "?") for t in tools}
|
|
43
|
+
for t in tools:
|
|
44
|
+
fn = t.get("function") or {}
|
|
45
|
+
params = fn.get("parameters")
|
|
46
|
+
if params is not None and not isinstance(params, dict):
|
|
47
|
+
notes.append(f"tool '{fn.get('name', '?')}' parameters not object-schema; dropped")
|
|
48
|
+
continue
|
|
49
|
+
good.append(t)
|
|
50
|
+
if tools and len(good) != len(tools):
|
|
51
|
+
out["tools"] = good
|
|
52
|
+
elif not tools:
|
|
53
|
+
for_name = set()
|
|
54
|
+
_ = for_name # silence ruff if unused in future edits
|
|
55
|
+
|
|
56
|
+
_sanitize_history(out["messages"], notes)
|
|
57
|
+
return out, notes
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _sanitize_history(messages: list[dict[str, Any]], notes: list[str]) -> None:
|
|
61
|
+
"""Fix assistant-message history shapes llama-server rejects, in place.
|
|
62
|
+
|
|
63
|
+
1. Merge an assistant *text* turn that is a leaked tool call (matches the
|
|
64
|
+
raw-JSON shape the extractor targets) into the following assistant
|
|
65
|
+
``tool_calls`` turn when the tool name + arguments agree.
|
|
66
|
+
2. Merge consecutive assistant messages into one (template allows only one
|
|
67
|
+
assistant turn per generation).
|
|
68
|
+
"""
|
|
69
|
+
if not isinstance(messages, list):
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
import json
|
|
73
|
+
import re
|
|
74
|
+
|
|
75
|
+
leaked = re.compile(r'\{\s*"name"\s*:')
|
|
76
|
+
|
|
77
|
+
def _stem(m: dict[str, Any]) -> tuple | None:
|
|
78
|
+
"""(name, arguments-json) of a message's first tool call, else None."""
|
|
79
|
+
if m.get("role") != "assistant":
|
|
80
|
+
return None
|
|
81
|
+
tcs = m.get("tool_calls") or []
|
|
82
|
+
if not tcs:
|
|
83
|
+
return None
|
|
84
|
+
fn = (tcs[0] or {}).get("function") or {}
|
|
85
|
+
return fn.get("name"), fn.get("arguments")
|
|
86
|
+
|
|
87
|
+
merged = 0
|
|
88
|
+
deduped_ids = 0
|
|
89
|
+
i = 1 # keep index 0 (system) untouched
|
|
90
|
+
while i < len(messages):
|
|
91
|
+
m = messages[i]
|
|
92
|
+
if m.get("role") != "assistant":
|
|
93
|
+
i += 1
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
# 0. Duplicate tool_call id accumulation (LangChain4j-style streaming
|
|
97
|
+
# bug from the live trace): the same call id repeated within one
|
|
98
|
+
# assistant turn must collapse — llama-server rejects duplicated
|
|
99
|
+
# ids with `tool_call_id mismatch` on the reply turn.
|
|
100
|
+
tcs = m.get("tool_calls") or []
|
|
101
|
+
if tcs:
|
|
102
|
+
seen: set[str] = set()
|
|
103
|
+
fresh: list[dict[str, Any]] = []
|
|
104
|
+
for tc in tcs:
|
|
105
|
+
tid = (tc or {}).get("id")
|
|
106
|
+
if tid and tid in seen:
|
|
107
|
+
deduped_ids += 1
|
|
108
|
+
continue
|
|
109
|
+
if tid:
|
|
110
|
+
seen.add(tid)
|
|
111
|
+
fresh.append(tc)
|
|
112
|
+
if len(fresh) != len(tcs):
|
|
113
|
+
m["tool_calls"] = fresh
|
|
114
|
+
notes.append(
|
|
115
|
+
f"duplicate tool_call ids deduped ({len(tcs) - len(fresh)} removed)"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# 1. text-twins of a repaired tool_calls turn: `leaked` text immediately
|
|
119
|
+
# before an assistant tool_calls turn whose call matches.
|
|
120
|
+
content = m.get("content")
|
|
121
|
+
if (
|
|
122
|
+
isinstance(content, str)
|
|
123
|
+
and leaked.search(content)
|
|
124
|
+
and i + 1 < len(messages)
|
|
125
|
+
):
|
|
126
|
+
nxt = messages[i + 1]
|
|
127
|
+
nxt_stem = _stem(nxt)
|
|
128
|
+
if nxt_stem and not m.get("tool_calls"):
|
|
129
|
+
try:
|
|
130
|
+
parsed = json.loads(content)
|
|
131
|
+
name, args_json = nxt_stem
|
|
132
|
+
args_norm = (
|
|
133
|
+
json.dumps(json.loads(args_json), sort_keys=True)
|
|
134
|
+
if isinstance(args_json, str)
|
|
135
|
+
else json.dumps(args_json, sort_keys=True)
|
|
136
|
+
)
|
|
137
|
+
parsed_args_norm = json.dumps(parsed.get("arguments"), sort_keys=True)
|
|
138
|
+
if parsed.get("name") == name and parsed_args_norm == args_norm:
|
|
139
|
+
# drop the text twin; the tool_calls turn already carries it.
|
|
140
|
+
messages.pop(i)
|
|
141
|
+
merged += 1
|
|
142
|
+
notes.append("leaked tool-call text turn merged into tool_calls turn")
|
|
143
|
+
continue
|
|
144
|
+
except (json.JSONDecodeError, KeyError, TypeError, ValueError):
|
|
145
|
+
pass
|
|
146
|
+
|
|
147
|
+
# 2. two assistant messages in a row: try to merge
|
|
148
|
+
if i + 1 < len(messages) and messages[i + 1].get("role") == "assistant":
|
|
149
|
+
nxt = messages[i + 1]
|
|
150
|
+
a_calls = m.get("tool_calls") or []
|
|
151
|
+
b_calls = nxt.get("tool_calls") or []
|
|
152
|
+
if a_calls or b_calls:
|
|
153
|
+
if not b_calls:
|
|
154
|
+
# m carries the structured call; keep it, adopt nxt's text
|
|
155
|
+
# as the turn content when m has none.
|
|
156
|
+
if not (m.get("content") or "").strip():
|
|
157
|
+
m["content"] = (nxt.get("content") or "").strip()
|
|
158
|
+
messages.pop(i + 1)
|
|
159
|
+
merged += 1
|
|
160
|
+
notes.append("assistant turns merged (text into tool_calls)")
|
|
161
|
+
continue
|
|
162
|
+
if not a_calls:
|
|
163
|
+
# nxt carries the structured call; adopt m's text as content
|
|
164
|
+
# when nxt has none.
|
|
165
|
+
if not (nxt.get("content") or "").strip():
|
|
166
|
+
nxt["content"] = (m.get("content") or "").strip()
|
|
167
|
+
messages.pop(i)
|
|
168
|
+
merged += 1
|
|
169
|
+
notes.append("assistant turns merged (text into tool_calls)")
|
|
170
|
+
continue
|
|
171
|
+
# both have tool_calls: merge into the second (tool results follow)
|
|
172
|
+
nxt["tool_calls"] = a_calls + b_calls
|
|
173
|
+
messages.pop(i)
|
|
174
|
+
merged += 1
|
|
175
|
+
notes.append("assistant turns merged (tool_calls + tool_calls)")
|
|
176
|
+
continue
|
|
177
|
+
# plain text duplicates
|
|
178
|
+
if (m.get("content") or "") == (nxt.get("content") or ""):
|
|
179
|
+
messages.pop(i)
|
|
180
|
+
merged += 1
|
|
181
|
+
notes.append("duplicate adjacent assistant turns deduped")
|
|
182
|
+
continue
|
|
183
|
+
# distinct assistant texts: keep the LAST one (the final generation)
|
|
184
|
+
nxt["content"] = (
|
|
185
|
+
(m.get("content") or "") + "\n" + (nxt.get("content") or "")
|
|
186
|
+
).strip()
|
|
187
|
+
messages.pop(i)
|
|
188
|
+
merged += 1
|
|
189
|
+
notes.append("assistant turns merged (text + text)")
|
|
190
|
+
continue
|
|
191
|
+
|
|
192
|
+
i += 1
|
|
193
|
+
|
|
194
|
+
if deduped_ids:
|
|
195
|
+
notes.append(f"tool_call ids deduped: {deduped_ids}")
|
|
196
|
+
if merged:
|
|
197
|
+
notes.append(f"assistant history sanitized: {merged} merge(s)")
|
toolpatch/schemas.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""OpenAI-compatible Pydantic schemas shared by the proxytool pipeline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict
|
|
8
|
+
|
|
9
|
+
Role = Literal["system", "user", "assistant", "tool"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ToolFunction(BaseModel):
|
|
13
|
+
"""A function definition inside a `tools` entry."""
|
|
14
|
+
|
|
15
|
+
name: str
|
|
16
|
+
description: str | None = None
|
|
17
|
+
parameters: dict[str, Any] | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Tool(BaseModel):
|
|
21
|
+
type: Literal["function"] = "function"
|
|
22
|
+
function: ToolFunction
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ToolCallFn(BaseModel):
|
|
26
|
+
name: str
|
|
27
|
+
arguments: str # JSON-encoded string, per OpenAI spec
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ToolCall(BaseModel):
|
|
31
|
+
id: str
|
|
32
|
+
type: Literal["function"] = "function"
|
|
33
|
+
function: ToolCallFn
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Message(BaseModel):
|
|
37
|
+
role: Role
|
|
38
|
+
content: str | list[dict[str, Any]] | None = None
|
|
39
|
+
name: str | None = None
|
|
40
|
+
tool_calls: list[ToolCall] | None = None
|
|
41
|
+
tool_call_id: str | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ChatRequest(BaseModel):
|
|
45
|
+
"""Partial OpenAI chat-completion request; unknown fields pass through."""
|
|
46
|
+
|
|
47
|
+
model: str
|
|
48
|
+
messages: list[Message]
|
|
49
|
+
tools: list[Tool] | None = None
|
|
50
|
+
stream: bool = False
|
|
51
|
+
|
|
52
|
+
model_config = ConfigDict(extra="allow")
|
toolpatch/stream.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""StreamHealer: repair tool-call leaks inside SSE content deltas.
|
|
2
|
+
|
|
3
|
+
Bounded streaming repair: deltas accumulate in a small buffer. Clean prose is
|
|
4
|
+
released quickly; text that *might* be forming a tool call is held until the
|
|
5
|
+
extractor can decide. Emits plain-dict events consumed by the proxy without
|
|
6
|
+
further translation.
|
|
7
|
+
|
|
8
|
+
The hold rule is NOT tail-only: models like Qwen2.5 stream a reasoning block
|
|
9
|
+
(<tool_call>-style tag) that appears BEFORE the tool-call payload in the same
|
|
10
|
+
buffer, and a tail-prefix-only check releases the whole buffer as "prose"
|
|
11
|
+
because the leak prefix sits mid-buffer. The hold rule therefore also fires
|
|
12
|
+
when a leak prefix appears **inside** the buffer, not just at the end.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from toolpatch.extractor import WRAPPER_TAGS, extract_tool_calls
|
|
20
|
+
from toolpatch.logging import get_logger
|
|
21
|
+
|
|
22
|
+
log = get_logger("toolpatch.stream")
|
|
23
|
+
|
|
24
|
+
# Markers that suggest a leak may be forming; while any prefix-match lingers we hold.
|
|
25
|
+
# ALL of these are prefix-triggered hold signals — a tail that could still grow
|
|
26
|
+
# into one of them keeps the buffer held. This covers reasoning/thinking tags
|
|
27
|
+
# (Qwen gpt-think, provider variations) that stream RIGHT BEFORE a leak: the
|
|
28
|
+
# whole 'reasoning + Hermes' sequence holds until the extractor resolves it.
|
|
29
|
+
_HERMES_OPEN = "<tool_call>"
|
|
30
|
+
_FENCE_MARK = "```"
|
|
31
|
+
_JSON_BRACE = '{"name"'
|
|
32
|
+
_BRACE = "{"
|
|
33
|
+
# Template-specific wrappers the extractor strips along with the payload.
|
|
34
|
+
# Without holding on these, `<tools>` is released as prose before the call
|
|
35
|
+
# inside it resolves, and the client renders the tag next to the tool call.
|
|
36
|
+
_WRAPPER_OPENS = tuple(f"<{tag}>" for tag in WRAPPER_TAGS)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class StreamHealer:
|
|
40
|
+
"""Buffers streamed content until a tool-call leak is decided; emits events.
|
|
41
|
+
|
|
42
|
+
Event shapes (plain dicts, consumed verbatim by the proxy):
|
|
43
|
+
|
|
44
|
+
- ``{"type": "content", "text": str}`` safe to forward
|
|
45
|
+
- ``{"type": "tool_calls", "tool_calls": [...], "stop": True}`` replace stream
|
|
46
|
+
- ``{"type": "content", "text": <remaining buffer>}`` flush at finish
|
|
47
|
+
|
|
48
|
+
Multi-call aggregation: once any tool-call leak has been seen, the
|
|
49
|
+
healer enters *collecting* mode — no further prose is released and the
|
|
50
|
+
promotion is deferred to the stream end (or a 4x``max_buffer`` cap), so
|
|
51
|
+
rapid-succession or parallel blocks (Hermes/fenced/raw, in any mix) all
|
|
52
|
+
land in ONE ``tool_calls`` frame instead of promoting the first call
|
|
53
|
+
and suppressing the rest.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, max_buffer: int = 512,
|
|
57
|
+
tool_names: set[str] | None = None,
|
|
58
|
+
logger: Any | None = None) -> None:
|
|
59
|
+
self._max = max_buffer
|
|
60
|
+
self._tool_names = tool_names
|
|
61
|
+
self._buf = ""
|
|
62
|
+
self._holding = False
|
|
63
|
+
self._leak_open = False # a partial leak prefix is in flight
|
|
64
|
+
self._done = False # a leak was extracted; suppress plain content after
|
|
65
|
+
self._seen_call = False # collecting mode: aggregate until stream end
|
|
66
|
+
self._cap = max_buffer * 4 # collecting-mode bound
|
|
67
|
+
self._log = logger or log
|
|
68
|
+
|
|
69
|
+
# -- public ------------------------------------------------------------
|
|
70
|
+
def feed(self, content_delta: str) -> list[dict]:
|
|
71
|
+
if self._done or not content_delta:
|
|
72
|
+
return []
|
|
73
|
+
self._buf += content_delta
|
|
74
|
+
events: list[dict] = []
|
|
75
|
+
|
|
76
|
+
# Try to decide with what we have (cheap probe: full extractor on buffer).
|
|
77
|
+
ex = extract_tool_calls(self._buf, self._tool_names)
|
|
78
|
+
if ex is not None:
|
|
79
|
+
self._seen_call = True
|
|
80
|
+
if self._seen_call:
|
|
81
|
+
# Collecting mode: more calls (rapid succession, parallel
|
|
82
|
+
# blocks, mixed families) may still be streaming downstream, so
|
|
83
|
+
# the promotion waits for the stream end — they all land in ONE
|
|
84
|
+
# ``tool_calls`` frame. Bounded by ``_cap`` for streams that
|
|
85
|
+
# rack up endless trailing content after a call.
|
|
86
|
+
if len(self._buf) >= self._cap:
|
|
87
|
+
self._log.debug("StreamHealer cap reached; promoting buffer")
|
|
88
|
+
return self._promote(allow_unterminated=False)
|
|
89
|
+
return events
|
|
90
|
+
|
|
91
|
+
if self._should_release():
|
|
92
|
+
# Retain a small suffix window, NOT an empty buffer: a leak opener
|
|
93
|
+
# straddling two releases (think>… <tool_ca | ll>{"name") must be
|
|
94
|
+
# seen whole once the next delta completes it. Everything BEFORE
|
|
95
|
+
# the window is emitted; the window itself stays buffered and is
|
|
96
|
+
# re-judged with the next delta (so it is never emitted twice).
|
|
97
|
+
keep = max(len(_HERMES_OPEN), len(_JSON_BRACE)) - 1
|
|
98
|
+
emit, self._buf = self._buf[:-keep], self._buf[-keep:]
|
|
99
|
+
if emit:
|
|
100
|
+
# A leak callee can start inside the release slice itself:
|
|
101
|
+
# with a partial open tag at the slice boundary the extractor
|
|
102
|
+
# cannot yet resolve a call, so re-judge the WHOLE slice
|
|
103
|
+
# before emitting it as prose. If an open/unresolvable tag is
|
|
104
|
+
# now in the slice, un-release: re-buffer and hold.
|
|
105
|
+
if _HERMES_OPEN in emit or self._midbuf_leak(emit):
|
|
106
|
+
self._buf = emit + self._buf
|
|
107
|
+
self._leak_open = True
|
|
108
|
+
self._log.debug("StreamHealer un-releasing slice with leak shape")
|
|
109
|
+
else:
|
|
110
|
+
events.append({"type": "content", "text": emit})
|
|
111
|
+
return events
|
|
112
|
+
|
|
113
|
+
def finish(self) -> list[dict]:
|
|
114
|
+
"""Flush the stream end: decide on whatever buffer remains."""
|
|
115
|
+
if self._done:
|
|
116
|
+
return []
|
|
117
|
+
if not self._buf:
|
|
118
|
+
return []
|
|
119
|
+
return self._promote(allow_unterminated=True)
|
|
120
|
+
|
|
121
|
+
def _promote(self, allow_unterminated: bool) -> list[dict]:
|
|
122
|
+
"""Resolve the buffer: emit residual content once, then the calls.
|
|
123
|
+
|
|
124
|
+
Single promotion point for both feed-time cap overflow and the
|
|
125
|
+
stream end. An unterminated Hermes tag is only tolerated at the
|
|
126
|
+
very end of the stream; mid-stream caps flush what resolved.
|
|
127
|
+
"""
|
|
128
|
+
ex = extract_tool_calls(self._buf, self._tool_names,
|
|
129
|
+
allow_unterminated_hermes=allow_unterminated)
|
|
130
|
+
buf = self._buf
|
|
131
|
+
self._buf = ""
|
|
132
|
+
self._done = True
|
|
133
|
+
self._seen_call = False
|
|
134
|
+
if ex is None:
|
|
135
|
+
if buf:
|
|
136
|
+
return [{"type": "content", "text": buf}]
|
|
137
|
+
return []
|
|
138
|
+
events: list[dict] = []
|
|
139
|
+
# Residual prose (retained window, reasoning tags' text,
|
|
140
|
+
# pre/post-object preamble) must survive — emitted BEFORE the
|
|
141
|
+
# promotion frame so a client renders it around the same turn.
|
|
142
|
+
if ex.content:
|
|
143
|
+
events.append({"type": "content", "text": ex.content})
|
|
144
|
+
events.append({
|
|
145
|
+
"type": "tool_calls",
|
|
146
|
+
"tool_calls": ex.tool_calls,
|
|
147
|
+
"stop": True,
|
|
148
|
+
})
|
|
149
|
+
self._log.debug("StreamHealer extracted tool calls: %d call(s)",
|
|
150
|
+
len(ex.tool_calls))
|
|
151
|
+
return events
|
|
152
|
+
|
|
153
|
+
# -- internals ---------------------------------------------------------
|
|
154
|
+
def _should_release(self) -> bool:
|
|
155
|
+
"""Release only when we're sure no leak prefix is in flight."""
|
|
156
|
+
if self._leak_open:
|
|
157
|
+
# A leak candidate started; hold until it resolves (or max size).
|
|
158
|
+
if len(self._buf) >= self._max:
|
|
159
|
+
self._log.debug("StreamHealer releasing held buffer at max size")
|
|
160
|
+
return True
|
|
161
|
+
return False
|
|
162
|
+
if self._tail_holds():
|
|
163
|
+
# Mark that a leak candidate is now in flight.
|
|
164
|
+
self._leak_open = True
|
|
165
|
+
self._log.debug("StreamHealer holding: possible leak opener detected")
|
|
166
|
+
return False
|
|
167
|
+
return True
|
|
168
|
+
|
|
169
|
+
def _tail_holds(self) -> bool:
|
|
170
|
+
buf = self._buf
|
|
171
|
+
tail = buf[-len(_HERMES_OPEN):] if len(buf) >= len(_HERMES_OPEN) else buf
|
|
172
|
+
if _HERMES_OPEN.startswith(tail) or _HERMES_OPEN in buf[-32:]:
|
|
173
|
+
return True
|
|
174
|
+
fence = buf[-len(_FENCE_MARK):] if len(buf) >= len(_FENCE_MARK) else buf
|
|
175
|
+
if _FENCE_MARK.startswith(fence) or buf.count(_FENCE_MARK) % 2 == 1:
|
|
176
|
+
return True
|
|
177
|
+
for opener in _WRAPPER_OPENS:
|
|
178
|
+
tail = buf[-len(opener):] if len(buf) >= len(opener) else buf
|
|
179
|
+
if opener.startswith(tail):
|
|
180
|
+
return True
|
|
181
|
+
# Any leading '{' opens a candidate tool-call object until either the
|
|
182
|
+
# extractor resolves it (feed re-checks each delta) or max_buffer
|
|
183
|
+
# forces release. The replayed mlx stream showed a bare {"name"...}
|
|
184
|
+
# object released before its arguments were complete.
|
|
185
|
+
stripped = buf.lstrip()
|
|
186
|
+
# Hold while the buffer is *only* a candidate JSON object (nothing but
|
|
187
|
+
# whitespace before the opening brace) and still under max_buffer.
|
|
188
|
+
if (
|
|
189
|
+
stripped.startswith(_BRACE)
|
|
190
|
+
and len(stripped) < self._max
|
|
191
|
+
and not buf[: len(buf) - len(stripped)].strip()
|
|
192
|
+
):
|
|
193
|
+
return True
|
|
194
|
+
# Mid-buffer leak start (reasoning-tag-wrapped leaks, live Qwen2.5
|
|
195
|
+
# mlx trace): a Hermes open / bare-JSON object that BEGINS inside
|
|
196
|
+
# already-released prose, not at the tail. Tail-only rules let the
|
|
197
|
+
# whole reasoning+leak sequence release as prose; hold until the
|
|
198
|
+
# extractor resolves (bounded by max_buffer, as above).
|
|
199
|
+
return self._midbuf_leak(buf)
|
|
200
|
+
|
|
201
|
+
@staticmethod
|
|
202
|
+
def _midbuf_leak(buf: str) -> bool:
|
|
203
|
+
"""True when a leak-shape opener sits anywhere inside the buffer.
|
|
204
|
+
|
|
205
|
+
Cheaper and more conservative than running the full extractor: any
|
|
206
|
+
Hermes open tag or bare `{"name"` object beginning mid-buffer is a
|
|
207
|
+
hold signal. Fences are already covered by the tail rule's parity
|
|
208
|
+
check.
|
|
209
|
+
"""
|
|
210
|
+
return (
|
|
211
|
+
_HERMES_OPEN in buf
|
|
212
|
+
or _JSON_BRACE in buf
|
|
213
|
+
or any(opener in buf for opener in _WRAPPER_OPENS)
|
|
214
|
+
)
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: toolpatch
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: LLM tool-calling protocol repairer (pure logic, offline-testable)
|
|
5
|
+
Author: Dave Holden
|
|
6
|
+
Author-email: Dave Holden <holden.dave@gmail.com>
|
|
7
|
+
Requires-Dist: pydantic>=2.9
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
<p align="center">
|
|
12
|
+
<img src="public/assets/images/toolpatch.webp" alt="toolpatch" width="200">
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
# toolpatch
|
|
16
|
+
|
|
17
|
+
[](https://github.com/dave-holden/proxytoolpatch/actions/workflows/test.yml)
|
|
18
|
+
[](https://pypi.org/project/toolpatch/)
|
|
19
|
+
[](https://www.python.org)
|
|
20
|
+
[](https://github.com/astral-sh/ruff)
|
|
21
|
+
|
|
22
|
+
**In plain terms:** this is the *fix-it library*. Local AI models sometimes
|
|
23
|
+
send their tool commands as garbled text inside the chat message instead of
|
|
24
|
+
the structured format your software can execute. Feed the mumbled reply to
|
|
25
|
+
toolpatch and get back a clean, structured tool call. It doesn't serve
|
|
26
|
+
anything by itself — you embed it in your own Python code, or let
|
|
27
|
+
**proxytool** use it for you.
|
|
28
|
+
|
|
29
|
+
A **pure, zero-I/O Python library** for repairing LLM tool-calling
|
|
30
|
+
protocol breakage: no sockets, no event loop, no CLI, nothing runs on its
|
|
31
|
+
own — it is meant to be embedded by any server, proxy, or script that talks
|
|
32
|
+
to local LLMs over an OpenAI-compatible interface.
|
|
33
|
+
|
|
34
|
+
This is the repair "brick" that **proxytool** (`apps/proxytool`) embeds for all
|
|
35
|
+
of its protocol-repair logic:
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
proxytool (the runnable app: HTTP serving, routing, supervision)
|
|
39
|
+
└─ imports
|
|
40
|
+
toolpatch (this library: pure text repair, no I/O)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Any Python program can do the same `import` — see [Embedding](#embedding).
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install toolpatch # once published to PyPI
|
|
49
|
+
# or
|
|
50
|
+
uv add toolpatch # into an existing uv project
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
In this workspace (development install, editable):
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
uv sync --all-packages # one shared root .venv, both modules included
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Quick use
|
|
60
|
+
|
|
61
|
+
One completed (non-streaming) model reply in, clean result out:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from toolpatch import extract_tool_calls
|
|
65
|
+
|
|
66
|
+
reply = 'Sure. \u003ctool_call\u003e{"name": "list_dir", "arguments": {"path": "."}}\u003c/tool_call\u003e'
|
|
67
|
+
|
|
68
|
+
ex = extract_tool_calls(reply, tool_names={"list_dir"})
|
|
69
|
+
if ex:
|
|
70
|
+
ex.content # "Sure." (clean prose, leak gone)
|
|
71
|
+
ex.tool_calls # [{'id': 'call_…', 'type': 'function',
|
|
72
|
+
# 'function': {'name': 'list_dir', 'arguments': '{"path": "."}'}}]
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
That is the whole loop for a completed reply. For streaming replies use
|
|
76
|
+
`StreamHealer.feed()`/`.finish()`, and for outbound request sanitization use
|
|
77
|
+
`normalize_chat_request` — all three below in [API](#api).
|
|
78
|
+
|
|
79
|
+
## What it repairs
|
|
80
|
+
|
|
81
|
+
When a model's chat template is missing or mismatched, some local servers
|
|
82
|
+
emit the tool call as **text inside** `choices[*].message.content` instead
|
|
83
|
+
of the structured `tool_calls` field. The library recognizes three leaked
|
|
84
|
+
payload shapes (all merged into one result, in document order):
|
|
85
|
+
|
|
86
|
+
1. **Hermes-style XML function tags** — an XML tag block wrapping a JSON
|
|
87
|
+
payload, the form Qwen and several other open models emit when they leak.
|
|
88
|
+
2. **Fenced JSON** — ```json … ``` code blocks.
|
|
89
|
+
3. **Bare JSON object** — a raw `{"name": …, "arguments": …}` embedded in
|
|
90
|
+
prose (the extractor checks the keys and heals the payload before
|
|
91
|
+
accepting it).
|
|
92
|
+
|
|
93
|
+
Plus the supporting repairs:
|
|
94
|
+
|
|
95
|
+
- **JSON healing** — trailing commas, raw newlines inside string literals,
|
|
96
|
+
smart quotes, code fences.
|
|
97
|
+
- **History normalization** — merges assistant-turn shapes that
|
|
98
|
+
llama-server-style chat templates reject with 4xx (leaked-JSON "twins" of
|
|
99
|
+
repaired tool calls, adjacent assistant turns).
|
|
100
|
+
- **Streaming** — `StreamHealer` does the same work on SSE content deltas
|
|
101
|
+
with bounded buffering (time-to-first-token is preserved). It is an
|
|
102
|
+
in-flight accumulator, not "buffer until the first complete match": clean
|
|
103
|
+
prose is released as soon as no leak-shape opener is in flight, with a
|
|
104
|
+
10-char suffix window so an opener straddling two deltas is re-judged
|
|
105
|
+
whole. Reasoning-tag-wrapped leaks (`gpt-think … <tool_call>` mid-buffer) are held
|
|
106
|
+
by a mid-buffer opener check, not just tail rules — the Qwen2.5 mlx
|
|
107
|
+
pattern that motivated it. Once a leak resolves, the healer switches to
|
|
108
|
+
*collecting* mode and defers promotion to stream end (bounded by a
|
|
109
|
+
4×`max_buffer` cap), so rapid-succession or parallel blocks — mixed
|
|
110
|
+
families included — all land in **one** `tool_calls` frame instead of
|
|
111
|
+
promoting the first call and suppressing the rest. Whitespace between two
|
|
112
|
+
leaked calls survives as real inter-call prose; only padding outside the
|
|
113
|
+
leak envelope is stripped.
|
|
114
|
+
|
|
115
|
+
## API
|
|
116
|
+
|
|
117
|
+
The public surface is deliberately small — three callables plus the stream
|
|
118
|
+
helper and one dataclass, all re-exported from `toolpatch`:
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
from toolpatch import extract_tool_calls, heal_json, StreamHealer, normalize_chat_request
|
|
122
|
+
from toolpatch import ExtractedToolCalls # dataclass: content, tool_calls, source
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### `extract_tool_calls(content, tool_names=None,
|
|
126
|
+
allow_unterminated_hermes=False)`
|
|
127
|
+
|
|
128
|
+
`-> ExtractedToolCalls | None`
|
|
129
|
+
|
|
130
|
+
Runs all three detectors over the text and merges every distinct call into
|
|
131
|
+
one result, in document order; overlapping/nested matches dedupe to the
|
|
132
|
+
outer family. Returns `None` for clean prose (passthrough); on a hit
|
|
133
|
+
returns the cleaned content plus the structured tool calls. Pass
|
|
134
|
+
`tool_names={...}` to only accept calls whose `name` is in the set
|
|
135
|
+
(anything else stays prose). `allow_unterminated_hermes=True` is the
|
|
136
|
+
streaming path's opt-in: it tolerates one still-forming Hermes block at the
|
|
137
|
+
end of the text.
|
|
138
|
+
|
|
139
|
+
`ExtractedToolCalls.source` names the matching family (`hermes` / `fenced`
|
|
140
|
+
/ `raw_json`) or `mixed` when families differ across the calls.
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
ex = extract_tool_calls('Sure. ' + hermes_block)
|
|
144
|
+
ex.content # 'Sure.' (leak text removed)
|
|
145
|
+
ex.source # 'hermes'
|
|
146
|
+
ex.tool_calls[0]['function']['name'] # 'list_dir'
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### `heal_json(text) -> str | None`
|
|
150
|
+
|
|
151
|
+
Repairs malformed JSON from a model payload: strips code fences, un-smarts
|
|
152
|
+
quotes, drops trailing commas, escapes raw newlines that broke a string
|
|
153
|
+
literal. Returns the repaired JSON string, or `None` when unrecoverable.
|
|
154
|
+
|
|
155
|
+
### `StreamHealer(max_buffer=512, tool_names=None)`
|
|
156
|
+
|
|
157
|
+
Streaming variant, for SSE content deltas. Feed it partial text as it
|
|
158
|
+
arrives; it buffers only while a tool-call leak might be forming (bounded by
|
|
159
|
+
`max_buffer`) and yields plain-dict events. Boundary safety is built in:
|
|
160
|
+
fragments are re-judged over the whole buffer each delta, with the last
|
|
161
|
+
`max(11, 8)−1 = 10` trailing chars held back (the 11-char `<tool_call>` opener
|
|
162
|
+
vs the 8-char `{"name"` probe, minus one), so a leak opener split across
|
|
163
|
+
two deltas is seen whole once the next delta completes it. A release slice
|
|
164
|
+
containing a leak shape is re-held instead of emitted — the raw leak text
|
|
165
|
+
is never forwarded, so clients can't render duplicate content. After the
|
|
166
|
+
first call resolves, promotion defers to stream end (4×`max_buffer` cap so
|
|
167
|
+
an endless trailing ramble still promotes): all subsequent calls land in
|
|
168
|
+
the one frame; residual prose between them comes out in the same batch,
|
|
169
|
+
before the `tool_calls` event.
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
healer = StreamHealer()
|
|
173
|
+
|
|
174
|
+
for delta in sse_content_deltas:
|
|
175
|
+
for event in healer.feed(delta):
|
|
176
|
+
match event["type"]:
|
|
177
|
+
case "content":
|
|
178
|
+
pass # safe to forward to the client as-is
|
|
179
|
+
case "tool_calls":
|
|
180
|
+
... # event["stop"] is True — one frame per turn;
|
|
181
|
+
# contains ALL calls seen (file, webfetch, …)
|
|
182
|
+
|
|
183
|
+
for event in healer.finish(): # flush at stream end
|
|
184
|
+
...
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Event shapes: `{"type": "content", "text": str}` and
|
|
188
|
+
`{"type": "tool_calls", "tool_calls": [...], "stop": True}`.
|
|
189
|
+
|
|
190
|
+
### `normalize_chat_request(payload, backend="gguf") -> (dict, list[str])`
|
|
191
|
+
|
|
192
|
+
Sanitizes an outbound chat-completion request without mutating the input
|
|
193
|
+
(deep-copies). `backend="mlx"` additionally strips `tool_choice`
|
|
194
|
+
(unsupported by `mlx_lm.server`). Returns the sanitized payload plus
|
|
195
|
+
operator notes describing each fix. History fixes: leaked tool-call text
|
|
196
|
+
turns merged into the following `tool_calls` turn, adjacent assistant turns
|
|
197
|
+
merged or deduped — the shapes llama-server templates reject with `400`s.
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
clean, notes = normalize_chat_request(payload, backend="gguf")
|
|
201
|
+
if notes:
|
|
202
|
+
log.info("sanitized request", extra={"notes": notes})
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## Embedding
|
|
206
|
+
|
|
207
|
+
Concretely, other Python consumers that can embed this today:
|
|
208
|
+
|
|
209
|
+
- **Open WebUI Pipelines** (Python) — run `extract_tool_calls` on the model
|
|
210
|
+
response before it reaches the client.
|
|
211
|
+
- **LiteLLM custom callbacks** (Python) — hook extraction into the response
|
|
212
|
+
path.
|
|
213
|
+
- **FastAPI / FastMCP / custom servers** fronting a local model — call
|
|
214
|
+
`normalize_chat_request` before forwarding upstream.
|
|
215
|
+
- **Plain OpenAI-SDK scripts** — wrap every `chat.completions.create` with
|
|
216
|
+
`normalize_chat_request` on send and `extract_tool_calls` on receive.
|
|
217
|
+
|
|
218
|
+
Non-Python tools (Ollama, llama-server's C++ internals, llama-swap, etc.)
|
|
219
|
+
cannot import a Python library — for those, the recommended integration is
|
|
220
|
+
**proxytool** in front of the backend over HTTP, which applies this same
|
|
221
|
+
repair logic.
|
|
222
|
+
|
|
223
|
+
## Relationship to the app
|
|
224
|
+
|
|
225
|
+
One-way dependency:
|
|
226
|
+
|
|
227
|
+
- `apps/proxytool` (proxytool) imports this library in-process.
|
|
228
|
+
- This library never imports the app, never opens sockets, and its test
|
|
229
|
+
suite is fully offline (`packages/toolpatch/tests`) — it does not need
|
|
230
|
+
the proxy or a model to run.
|
|
231
|
+
|
|
232
|
+
Real-world misformattings captured from live models are kept as regression
|
|
233
|
+
fixture pairs — see `tests/regression/fixtures/README.md`.
|
|
234
|
+
|
|
235
|
+
## Logging
|
|
236
|
+
|
|
237
|
+
The library is **silent by default** — it logs through the stdlib `logging`
|
|
238
|
+
system (logger names `toolpatch.extractor`, `toolpatch.stream`, …) but never
|
|
239
|
+
configures handlers or levels itself; a library shouldn't. In a bare script
|
|
240
|
+
nothing is emitted. To turn repair diagnostics on:
|
|
241
|
+
|
|
242
|
+
```python
|
|
243
|
+
from toolpatch import configure_logger
|
|
244
|
+
|
|
245
|
+
configure_logger("toolpatch", "debug") # or "info"/"warning"/"error"
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
(Host apps like proxytool own this: proxytool's `--log-level debug` also
|
|
249
|
+
raises the library's logger — one knob for everything.)
|
|
250
|
+
|
|
251
|
+
## Status
|
|
252
|
+
|
|
253
|
+
`v2.0.0`. Depends only on `pydantic`
|
|
254
|
+
(a candidate for stdlib-only before publishing).
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
toolpatch/__init__.py,sha256=UbUXCpL78PWFuPB6h9nXklEqqavFdLCCkQ1BMmtSsys,1626
|
|
2
|
+
toolpatch/extractor.py,sha256=SppSIz8PLP2lwgFErB2-6B0VHGjoHz_nK-Bu62fkNQc,8436
|
|
3
|
+
toolpatch/fixer.py,sha256=dLqhP2QdMd1Sf6E9dcQzxhvSRZB8at8MAwSqztJIrhs,1740
|
|
4
|
+
toolpatch/logging.py,sha256=RbK68OVxEJmG4IK-TBy5nLK0EfkpF50jZysSigQjo7U,3909
|
|
5
|
+
toolpatch/request.py,sha256=KtQZGUqdJ0FLB1BbhMYhiWL5X3f7zl8Vf-EWGwOmU9g,8110
|
|
6
|
+
toolpatch/schemas.py,sha256=wMTkwcttzYGeetw3wzPmlGzPcFidxaa_cu37KX1vldY,1203
|
|
7
|
+
toolpatch/stream.py,sha256=B0Wtg-g9C7fL6rJT6qkpO7dv2RpeDZkTAunAA1PPKzw,10005
|
|
8
|
+
toolpatch-0.4.0.dist-info/WHEEL,sha256=si2p8vW03RnhyBnhWl4tj-ObJa78SpE-c9HsEtblGR0,80
|
|
9
|
+
toolpatch-0.4.0.dist-info/METADATA,sha256=22xHJnV5u6uAeK5vYLqYINYRIyuWNWu1xb7J4toaGiY,10698
|
|
10
|
+
toolpatch-0.4.0.dist-info/RECORD,,
|