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 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
+ [![test & checks](https://github.com/dave-holden/proxytoolpatch/actions/workflows/test.yml/badge.svg)](https://github.com/dave-holden/proxytoolpatch/actions/workflows/test.yml)
18
+ [![PyPI toolpatch](https://img.shields.io/badge/PyPI-toolpatch-blue)](https://pypi.org/project/toolpatch/)
19
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org)
20
+ [![code style: ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.13.5
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any