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.
@@ -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
+ [![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,244 @@
1
+ <p align="center">
2
+ <img src="public/assets/images/toolpatch.png" alt="toolpatch" width="200">
3
+ </p>
4
+
5
+ # toolpatch
6
+
7
+ [![test & checks](https://github.com/dave-holden/proxytoolpatch/actions/workflows/test.yml/badge.svg)](https://github.com/dave-holden/proxytoolpatch/actions/workflows/test.yml)
8
+ [![PyPI toolpatch](https://img.shields.io/badge/PyPI-toolpatch-blue)](https://pypi.org/project/toolpatch/)
9
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org)
10
+ [![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)
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
+