toolpatch 2.0.0__tar.gz
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-2.0.0/PKG-INFO +254 -0
- toolpatch-2.0.0/README.md +244 -0
- toolpatch-2.0.0/pyproject.toml +18 -0
- toolpatch-2.0.0/pyproject.toml.orig +15 -0
- toolpatch-2.0.0/src/toolpatch/__init__.py +51 -0
- toolpatch-2.0.0/src/toolpatch/extractor.py +164 -0
- toolpatch-2.0.0/src/toolpatch/fixer.py +63 -0
- toolpatch-2.0.0/src/toolpatch/logging.py +129 -0
- toolpatch-2.0.0/src/toolpatch/request.py +197 -0
- toolpatch-2.0.0/src/toolpatch/schemas.py +52 -0
- toolpatch-2.0.0/src/toolpatch/stream.py +202 -0
toolpatch-2.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: toolpatch
|
|
3
|
+
Version: 2.0.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.png" 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,244 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="public/assets/images/toolpatch.png" alt="toolpatch" width="200">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
# toolpatch
|
|
6
|
+
|
|
7
|
+
[](https://github.com/dave-holden/proxytoolpatch/actions/workflows/test.yml)
|
|
8
|
+
[](https://pypi.org/project/toolpatch/)
|
|
9
|
+
[](https://www.python.org)
|
|
10
|
+
[](https://github.com/astral-sh/ruff)
|
|
11
|
+
|
|
12
|
+
**In plain terms:** this is the *fix-it library*. Local AI models sometimes
|
|
13
|
+
send their tool commands as garbled text inside the chat message instead of
|
|
14
|
+
the structured format your software can execute. Feed the mumbled reply to
|
|
15
|
+
toolpatch and get back a clean, structured tool call. It doesn't serve
|
|
16
|
+
anything by itself — you embed it in your own Python code, or let
|
|
17
|
+
**proxytool** use it for you.
|
|
18
|
+
|
|
19
|
+
A **pure, zero-I/O Python library** for repairing LLM tool-calling
|
|
20
|
+
protocol breakage: no sockets, no event loop, no CLI, nothing runs on its
|
|
21
|
+
own — it is meant to be embedded by any server, proxy, or script that talks
|
|
22
|
+
to local LLMs over an OpenAI-compatible interface.
|
|
23
|
+
|
|
24
|
+
This is the repair "brick" that **proxytool** (`apps/proxytool`) embeds for all
|
|
25
|
+
of its protocol-repair logic:
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
proxytool (the runnable app: HTTP serving, routing, supervision)
|
|
29
|
+
└─ imports
|
|
30
|
+
toolpatch (this library: pure text repair, no I/O)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Any Python program can do the same `import` — see [Embedding](#embedding).
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install toolpatch # once published to PyPI
|
|
39
|
+
# or
|
|
40
|
+
uv add toolpatch # into an existing uv project
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
In this workspace (development install, editable):
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
uv sync --all-packages # one shared root .venv, both modules included
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Quick use
|
|
50
|
+
|
|
51
|
+
One completed (non-streaming) model reply in, clean result out:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from toolpatch import extract_tool_calls
|
|
55
|
+
|
|
56
|
+
reply = 'Sure. \u003ctool_call\u003e{"name": "list_dir", "arguments": {"path": "."}}\u003c/tool_call\u003e'
|
|
57
|
+
|
|
58
|
+
ex = extract_tool_calls(reply, tool_names={"list_dir"})
|
|
59
|
+
if ex:
|
|
60
|
+
ex.content # "Sure." (clean prose, leak gone)
|
|
61
|
+
ex.tool_calls # [{'id': 'call_…', 'type': 'function',
|
|
62
|
+
# 'function': {'name': 'list_dir', 'arguments': '{"path": "."}'}}]
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
That is the whole loop for a completed reply. For streaming replies use
|
|
66
|
+
`StreamHealer.feed()`/`.finish()`, and for outbound request sanitization use
|
|
67
|
+
`normalize_chat_request` — all three below in [API](#api).
|
|
68
|
+
|
|
69
|
+
## What it repairs
|
|
70
|
+
|
|
71
|
+
When a model's chat template is missing or mismatched, some local servers
|
|
72
|
+
emit the tool call as **text inside** `choices[*].message.content` instead
|
|
73
|
+
of the structured `tool_calls` field. The library recognizes three leaked
|
|
74
|
+
payload shapes (all merged into one result, in document order):
|
|
75
|
+
|
|
76
|
+
1. **Hermes-style XML function tags** — an XML tag block wrapping a JSON
|
|
77
|
+
payload, the form Qwen and several other open models emit when they leak.
|
|
78
|
+
2. **Fenced JSON** — ```json … ``` code blocks.
|
|
79
|
+
3. **Bare JSON object** — a raw `{"name": …, "arguments": …}` embedded in
|
|
80
|
+
prose (the extractor checks the keys and heals the payload before
|
|
81
|
+
accepting it).
|
|
82
|
+
|
|
83
|
+
Plus the supporting repairs:
|
|
84
|
+
|
|
85
|
+
- **JSON healing** — trailing commas, raw newlines inside string literals,
|
|
86
|
+
smart quotes, code fences.
|
|
87
|
+
- **History normalization** — merges assistant-turn shapes that
|
|
88
|
+
llama-server-style chat templates reject with 4xx (leaked-JSON "twins" of
|
|
89
|
+
repaired tool calls, adjacent assistant turns).
|
|
90
|
+
- **Streaming** — `StreamHealer` does the same work on SSE content deltas
|
|
91
|
+
with bounded buffering (time-to-first-token is preserved). It is an
|
|
92
|
+
in-flight accumulator, not "buffer until the first complete match": clean
|
|
93
|
+
prose is released as soon as no leak-shape opener is in flight, with a
|
|
94
|
+
10-char suffix window so an opener straddling two deltas is re-judged
|
|
95
|
+
whole. Reasoning-tag-wrapped leaks (`gpt-think … <tool_call>` mid-buffer) are held
|
|
96
|
+
by a mid-buffer opener check, not just tail rules — the Qwen2.5 mlx
|
|
97
|
+
pattern that motivated it. Once a leak resolves, the healer switches to
|
|
98
|
+
*collecting* mode and defers promotion to stream end (bounded by a
|
|
99
|
+
4×`max_buffer` cap), so rapid-succession or parallel blocks — mixed
|
|
100
|
+
families included — all land in **one** `tool_calls` frame instead of
|
|
101
|
+
promoting the first call and suppressing the rest. Whitespace between two
|
|
102
|
+
leaked calls survives as real inter-call prose; only padding outside the
|
|
103
|
+
leak envelope is stripped.
|
|
104
|
+
|
|
105
|
+
## API
|
|
106
|
+
|
|
107
|
+
The public surface is deliberately small — three callables plus the stream
|
|
108
|
+
helper and one dataclass, all re-exported from `toolpatch`:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from toolpatch import extract_tool_calls, heal_json, StreamHealer, normalize_chat_request
|
|
112
|
+
from toolpatch import ExtractedToolCalls # dataclass: content, tool_calls, source
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### `extract_tool_calls(content, tool_names=None,
|
|
116
|
+
allow_unterminated_hermes=False)`
|
|
117
|
+
|
|
118
|
+
`-> ExtractedToolCalls | None`
|
|
119
|
+
|
|
120
|
+
Runs all three detectors over the text and merges every distinct call into
|
|
121
|
+
one result, in document order; overlapping/nested matches dedupe to the
|
|
122
|
+
outer family. Returns `None` for clean prose (passthrough); on a hit
|
|
123
|
+
returns the cleaned content plus the structured tool calls. Pass
|
|
124
|
+
`tool_names={...}` to only accept calls whose `name` is in the set
|
|
125
|
+
(anything else stays prose). `allow_unterminated_hermes=True` is the
|
|
126
|
+
streaming path's opt-in: it tolerates one still-forming Hermes block at the
|
|
127
|
+
end of the text.
|
|
128
|
+
|
|
129
|
+
`ExtractedToolCalls.source` names the matching family (`hermes` / `fenced`
|
|
130
|
+
/ `raw_json`) or `mixed` when families differ across the calls.
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
ex = extract_tool_calls('Sure. ' + hermes_block)
|
|
134
|
+
ex.content # 'Sure.' (leak text removed)
|
|
135
|
+
ex.source # 'hermes'
|
|
136
|
+
ex.tool_calls[0]['function']['name'] # 'list_dir'
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### `heal_json(text) -> str | None`
|
|
140
|
+
|
|
141
|
+
Repairs malformed JSON from a model payload: strips code fences, un-smarts
|
|
142
|
+
quotes, drops trailing commas, escapes raw newlines that broke a string
|
|
143
|
+
literal. Returns the repaired JSON string, or `None` when unrecoverable.
|
|
144
|
+
|
|
145
|
+
### `StreamHealer(max_buffer=512, tool_names=None)`
|
|
146
|
+
|
|
147
|
+
Streaming variant, for SSE content deltas. Feed it partial text as it
|
|
148
|
+
arrives; it buffers only while a tool-call leak might be forming (bounded by
|
|
149
|
+
`max_buffer`) and yields plain-dict events. Boundary safety is built in:
|
|
150
|
+
fragments are re-judged over the whole buffer each delta, with the last
|
|
151
|
+
`max(11, 8)−1 = 10` trailing chars held back (the 11-char `<tool_call>` opener
|
|
152
|
+
vs the 8-char `{"name"` probe, minus one), so a leak opener split across
|
|
153
|
+
two deltas is seen whole once the next delta completes it. A release slice
|
|
154
|
+
containing a leak shape is re-held instead of emitted — the raw leak text
|
|
155
|
+
is never forwarded, so clients can't render duplicate content. After the
|
|
156
|
+
first call resolves, promotion defers to stream end (4×`max_buffer` cap so
|
|
157
|
+
an endless trailing ramble still promotes): all subsequent calls land in
|
|
158
|
+
the one frame; residual prose between them comes out in the same batch,
|
|
159
|
+
before the `tool_calls` event.
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
healer = StreamHealer()
|
|
163
|
+
|
|
164
|
+
for delta in sse_content_deltas:
|
|
165
|
+
for event in healer.feed(delta):
|
|
166
|
+
match event["type"]:
|
|
167
|
+
case "content":
|
|
168
|
+
pass # safe to forward to the client as-is
|
|
169
|
+
case "tool_calls":
|
|
170
|
+
... # event["stop"] is True — one frame per turn;
|
|
171
|
+
# contains ALL calls seen (file, webfetch, …)
|
|
172
|
+
|
|
173
|
+
for event in healer.finish(): # flush at stream end
|
|
174
|
+
...
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Event shapes: `{"type": "content", "text": str}` and
|
|
178
|
+
`{"type": "tool_calls", "tool_calls": [...], "stop": True}`.
|
|
179
|
+
|
|
180
|
+
### `normalize_chat_request(payload, backend="gguf") -> (dict, list[str])`
|
|
181
|
+
|
|
182
|
+
Sanitizes an outbound chat-completion request without mutating the input
|
|
183
|
+
(deep-copies). `backend="mlx"` additionally strips `tool_choice`
|
|
184
|
+
(unsupported by `mlx_lm.server`). Returns the sanitized payload plus
|
|
185
|
+
operator notes describing each fix. History fixes: leaked tool-call text
|
|
186
|
+
turns merged into the following `tool_calls` turn, adjacent assistant turns
|
|
187
|
+
merged or deduped — the shapes llama-server templates reject with `400`s.
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
clean, notes = normalize_chat_request(payload, backend="gguf")
|
|
191
|
+
if notes:
|
|
192
|
+
log.info("sanitized request", extra={"notes": notes})
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## Embedding
|
|
196
|
+
|
|
197
|
+
Concretely, other Python consumers that can embed this today:
|
|
198
|
+
|
|
199
|
+
- **Open WebUI Pipelines** (Python) — run `extract_tool_calls` on the model
|
|
200
|
+
response before it reaches the client.
|
|
201
|
+
- **LiteLLM custom callbacks** (Python) — hook extraction into the response
|
|
202
|
+
path.
|
|
203
|
+
- **FastAPI / FastMCP / custom servers** fronting a local model — call
|
|
204
|
+
`normalize_chat_request` before forwarding upstream.
|
|
205
|
+
- **Plain OpenAI-SDK scripts** — wrap every `chat.completions.create` with
|
|
206
|
+
`normalize_chat_request` on send and `extract_tool_calls` on receive.
|
|
207
|
+
|
|
208
|
+
Non-Python tools (Ollama, llama-server's C++ internals, llama-swap, etc.)
|
|
209
|
+
cannot import a Python library — for those, the recommended integration is
|
|
210
|
+
**proxytool** in front of the backend over HTTP, which applies this same
|
|
211
|
+
repair logic.
|
|
212
|
+
|
|
213
|
+
## Relationship to the app
|
|
214
|
+
|
|
215
|
+
One-way dependency:
|
|
216
|
+
|
|
217
|
+
- `apps/proxytool` (proxytool) imports this library in-process.
|
|
218
|
+
- This library never imports the app, never opens sockets, and its test
|
|
219
|
+
suite is fully offline (`packages/toolpatch/tests`) — it does not need
|
|
220
|
+
the proxy or a model to run.
|
|
221
|
+
|
|
222
|
+
Real-world misformattings captured from live models are kept as regression
|
|
223
|
+
fixture pairs — see `tests/regression/fixtures/README.md`.
|
|
224
|
+
|
|
225
|
+
## Logging
|
|
226
|
+
|
|
227
|
+
The library is **silent by default** — it logs through the stdlib `logging`
|
|
228
|
+
system (logger names `toolpatch.extractor`, `toolpatch.stream`, …) but never
|
|
229
|
+
configures handlers or levels itself; a library shouldn't. In a bare script
|
|
230
|
+
nothing is emitted. To turn repair diagnostics on:
|
|
231
|
+
|
|
232
|
+
```python
|
|
233
|
+
from toolpatch import configure_logger
|
|
234
|
+
|
|
235
|
+
configure_logger("toolpatch", "debug") # or "info"/"warning"/"error"
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
(Host apps like proxytool own this: proxytool's `--log-level debug` also
|
|
239
|
+
raises the library's logger — one knob for everything.)
|
|
240
|
+
|
|
241
|
+
## Status
|
|
242
|
+
|
|
243
|
+
`v2.0.0`. Depends only on `pydantic`
|
|
244
|
+
(a candidate for stdlib-only before publishing).
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "toolpatch"
|
|
3
|
+
version = "2.0.0"
|
|
4
|
+
description = "LLM tool-calling protocol repairer (pure logic, offline-testable)"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
dependencies = ["pydantic>=2.9"]
|
|
8
|
+
|
|
9
|
+
[[project.authors]]
|
|
10
|
+
name = "Dave Holden"
|
|
11
|
+
email = "holden.dave@gmail.com"
|
|
12
|
+
|
|
13
|
+
[build-system]
|
|
14
|
+
requires = ["uv_build>=0.12.23,<0.13.0"]
|
|
15
|
+
build-backend = "uv_build"
|
|
16
|
+
|
|
17
|
+
[tool.uv.build-backend]
|
|
18
|
+
module-name = "toolpatch"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "toolpatch"
|
|
3
|
+
version = "2.0.0"
|
|
4
|
+
description = "LLM tool-calling protocol repairer (pure logic, offline-testable)"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [{ name = "Dave Holden", email = "holden.dave@gmail.com" }]
|
|
7
|
+
requires-python = ">=3.10"
|
|
8
|
+
dependencies = ["pydantic>=2.9"]
|
|
9
|
+
|
|
10
|
+
[build-system]
|
|
11
|
+
requires = ["uv_build>=0.12.23,<0.13.0"]
|
|
12
|
+
build-backend = "uv_build"
|
|
13
|
+
|
|
14
|
+
[tool.uv.build-backend]
|
|
15
|
+
module-name = "toolpatch"
|
|
@@ -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
|
+
|