proxytool 0.4.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,557 @@
1
+ Metadata-Version: 2.3
2
+ Name: proxytool
3
+ Version: 0.4.0
4
+ Summary: FastAPI proxytool conduit standing between agent clients and Qwen servers
5
+ Author: Dave Holden
6
+ Author-email: Dave Holden <holden.dave@gmail.com>
7
+ Requires-Dist: toolpatch
8
+ Requires-Dist: fastapi>=0.115
9
+ Requires-Dist: pydantic>=2.9
10
+ Requires-Dist: uvicorn[standard]>=0.34
11
+ Requires-Dist: httpx>=0.28
12
+ Requires-Dist: tomli>=2.0 ; python_full_version < '3.11'
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+
16
+ <p align="center">
17
+ <img src="public/assets/images/proxytool.webp" alt="proxytool" width="200">
18
+ </p>
19
+
20
+ # proxytool (the app)
21
+
22
+ [![test & checks](https://github.com/dave-holden/proxytoolpatch/actions/workflows/test.yml/badge.svg)](https://github.com/dave-holden/proxytoolpatch/actions/workflows/test.yml)
23
+ [![Python 3.10–3.14](https://img.shields.io/badge/python-3.10%E2%80%933.14-blue)](https://www.python.org)
24
+ [![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)
25
+
26
+ **In plain terms:** you run `proxytool serve` in front of the model server(s)
27
+ you already have (llama-server, mlx_lm.server / omlx, or llama-swap in front of
28
+ both): it routes each request to the right backend and fixes garbled tool calls
29
+ before your agent client ever sees them. It does **not** launch those servers —
30
+ automatic start/stop is llama-swap's job (or compose/systemd). You point
31
+ `opencode`, `pi`, or any OpenAI-compatible client at `http://127.0.0.1:8180/v1`
32
+ and stop thinking about it.
33
+
34
+ Technically: **proxytool** (formerly `llama-proxytool` / `proxy` / `toolproxy`) — a FastAPI proxy that
35
+ sits between an agent client (opencode, pi, curl, any OpenAI-compatible tool)
36
+ and local inference backends — `llama-server` (GGUF) or `mlx_lm.server` /
37
+ `omlx` (MLX), or any OpenAI-compatible server on the same
38
+ `POST /v1/chat/completions` interface — and repairs the tool calls local
39
+ models leak as raw text.
40
+
41
+ When a model's chat template is missing or mismatched, it emits its tool call
42
+ as **text inside** `choices[*].message.content` instead of the structured
43
+ `tool_calls` field — so clients print raw JSON in the TUI instead of
44
+ executing the tool. proxytool repairs it in-flight:
45
+
46
+ - Request side: validates/sanitizes `tools` + `tool_choice` (MLX drops
47
+ `tool_choice`), without mutating unknown fields.
48
+
49
+ - Response side: extracts leaked tool calls (Hermes-style XML function tags,
50
+ fenced ```json blocks, bare `{"name":...}` JSON in prose), heals broken
51
+ JSON (trailing commas, raw newlines, smart quotes, fences) and rebuilds a
52
+ clean OpenAI `tool_calls` payload — including during streaming, with
53
+ bounded buffering that preserves time-to-first-token.
54
+
55
+ - Passthrough-first: clean requests/responses come out byte-equivalent.
56
+
57
+ - History sanitizer: merges/dedupes assistant-message shapes llama-server's
58
+ chat template rejects (leaked-JSON twins of repaired tool calls, adjacent
59
+ assistant turns), so multi-turn agent sessions don't hit upstream `400`s.
60
+
61
+ - Stream termination guarantee: the repaired SSE stream always emits a
62
+ `finish_reason` chunk before `[DONE]`, so agent clients close the turn
63
+ instead of re-prompting.
64
+
65
+ - Frame-safe SSE ingestion: upstream HTTP chunks are reassembled into whole
66
+ SSE lines before decoding (`_SSEParser`), so a `data:` payload or a
67
+ multibyte UTF-8 character split across two chunks is neither dropped nor
68
+ corrupted into U+FFFD — covered by split-frame regression tests.
69
+
70
+ - LoopGuard: detects consecutive byte-identical requests (same sanitized
71
+ messages + tools), nudges the model, and aborts after a bounded number of
72
+ repeats.
73
+
74
+ **Status:** v2.0.0 — GGUF (llama-server) and MLX (mlx_lm.server) both
75
+ round-trip verified end-to-end in live agent sessions. Ships a `proxytool`
76
+ multi-backend routing over already-running OpenAI-compatible servers.
77
+
78
+ > This app imports all repair logic from the sibling library
79
+ > [`packages/toolpatch`](../../packages/toolpatch/README.md). Workspace
80
+ > overview and install: root [`README.md`](../../README.md).
81
+
82
+ ## The rig
83
+
84
+ ```
85
+ ┌────────────┐ 8180 → ┌───────────────────┐ 8181 upstream → ┌────────────────┐
86
+ │ opencode / │ │ FastAPI proxy │ │ Qwen (GGUF/MLX)│
87
+ │ curl │───────►│ sanitize + repair │────────────────►│ or fake │
88
+ └────────────┘◄───────┤ (this app) │◄────────────────└────────────────┘
89
+ ```
90
+
91
+ ## The `proxytool` CLI
92
+
93
+ A thin launcher around the FastAPI app (`src/proxytool/cli.py`; entry point
94
+ registered in `apps/proxytool/pyproject.toml` as a `project.scripts` console
95
+ script, so installing the package puts `proxytool` on your PATH). It does
96
+ *not* replace Uvicorn: it builds a uvicorn server in-process (`start`
97
+ daemonises that same `serve` process) and health-checks the upstream(s) it
98
+ fronts — it never launches the model servers themselves.
99
+
100
+ ### Install & run
101
+
102
+ ```bash
103
+ uv sync --all-packages # workspace install; provides `proxytool`
104
+ uv run proxytool serve # default: 127.0.0.1:8180 → http://127.0.0.1:8181
105
+ uv run proxytool version
106
+ ```
107
+
108
+ Once published: `pip install proxytool` and just `proxytool serve`.
109
+
110
+ **How the command gets on your PATH.** Both install paths register a shell
111
+ command via the packaging metadata — no manual copying required:
112
+
113
+ ```toml
114
+ # apps/proxytool/pyproject.toml
115
+ [project.scripts]
116
+ proxytool = "proxytool.cli:main"
117
+ ```
118
+
119
+ - `uv sync --all-packages` creates a `proxytool` executable inside the
120
+ workspace `.venv` (`uv run proxytool …` runs it without activating the
121
+ venv).
122
+ - `pip install proxytool` writes a `proxytool` entry-point script into the
123
+ Python environment's `bin/` (or `Scripts/` on Windows) — `~/.venv/bin` on
124
+ a venv, or `uv tool install proxytool` to get it on the user PATH without
125
+ a project.
126
+
127
+ Eight subcommands:
128
+
129
+ | Subcommand | Purpose |
130
+ |---|---|
131
+ | `serve` | run the proxytool in the foreground |
132
+ | `start` | daemonize `serve` (same flags; detached; waits for /health) |
133
+ | `stop` | gracefully stop a `start`ed server (SIGTERM → grace → SIGKILL) |
134
+ | `status` | running / stopped / stale, from the state file |
135
+ | `health` | JSON probe: proxytool `/health` + each registered backend |
136
+ | `models` | JSON: each upstream's `/v1/models` ids (what routing matches) |
137
+ | `config` | diagnose the config file: `config print` (discovery + validation + resolved settings vs. env), `config check` (validate only) |
138
+ | `version` | print the version |
139
+
140
+ `proxytool serve --help` lists all flags; `proxytool --help` never starts a
141
+ server. Lifecycle state lives in `$PTP_STATE_PATH` or
142
+ `/tmp/proxytool-serve-state.json` — see `dev/DEVELOPMENT.md`
143
+ (dev docs live outside the repo, in local-only `dev/`).
144
+
145
+ ### Flags (every flag has an env-var equivalent — flags win)
146
+
147
+ | Flag | Env var | Default | Purpose |
148
+ |---|---|---|---|
149
+ | `--host` | `PTP_HOST` | `127.0.0.1` | bind host |
150
+ | `--port` | `PTP_PORT` | `8180` | bind port |
151
+ | `--upstream NAME=URL` (repeatable) | `PTP_UPSTREAM_<NAME>` | — | register a routed backend |
152
+ | `--upstream-models NAME=m1,m2` (repeatable) | `PTP_UPSTREAM_<NAME>_MODELS` | — | model ids routed to that backend |
153
+ | `--default-backend NAME` | `PTP_DEFAULT_BACKEND` | first registered | backend used when no model list matches |
154
+ | `--upstream-url URL` | `PTP_UPSTREAM_URL` / `PTP_UPSTREAM_URL` | `http://127.0.0.1:8181` | standalone single-backend URL |
155
+ | `--model-map JSON` / `--model-map-file PATH` | `PTP_MODEL_MAP` / `PTP_MODEL_MAP_FILE` | — | client alias → real model id |
156
+ | `--upstream-api-key TOK` | `PTP_UPSTREAM_API_KEY` | — | static bearer token upstream |
157
+ | `--upstream-timeout S` | `PTP_UPSTREAM_TIMEOUT` | `300` | upstream read timeout (covers cold starts) |
158
+ | `--loop-nudge N` / `--loop-abort N` | `PTP_LOOP_NUDGE` / `PTP_LOOP_ABORT` | `3` / `6` | LoopGuard thresholds (0 = off) |
159
+ | `--trace` | `PTP_TRACE` / `PTP_TRACE` | off | dump payload transit points to stderr |
160
+ | `--log-level debug\|info\|warning\|error` | `PTP_LOG_LEVEL` / `PTP_LOG_LEVEL` | `info` | app log tier (proxytool + toolpatch debug lines; Uvicorn's own access logs follow the same value via `serve`) |
161
+ | `--quiet` | — | off | only log errors (≡ `--log-level error`) |
162
+ | `--config PATH` | `PTP_CONFIG` | discovery | TOML config file — see [Config file](#config-file) below |
163
+
164
+ `serve` and `start` share every flag above; `start` additionally daemonizes
165
+ (daemon log lands next to the state file, `/tmp/proxytool-serve-state.log` by
166
+ default). `status`/`stop`/`health`/`models` take `--host`/`--port` and
167
+ `--config` (config `host`/`port` and `[routing.*]` feed their probes too).
168
+
169
+ ### Upstream selection — three ways
170
+
171
+ ```bash
172
+ # A. Standalone single backend (most common):
173
+ proxytool serve --upstream-url http://127.0.0.1:8181
174
+
175
+ # B. Multi-backend routing table (see below)
176
+
177
+ # Note: proxytool fronts servers that are ALREADY running. For automatic
178
+ # start/stop of model servers use llama-swap (or compose/systemd).
179
+ ```
180
+
181
+ ### Multi-backend routing
182
+
183
+ Register more than one backend and route incoming requests by the client's
184
+ `model` id, with fallback to a default:
185
+
186
+ ```bash
187
+ proxytool serve \
188
+ --upstream gguf=http://127.0.0.1:8181 --upstream-models gguf=qwen-gguf,qwen \
189
+ --upstream mlx=http://127.0.0.1:8281 --upstream-models mlx=qwen-mlx \
190
+ --default-backend mlx
191
+ ```
192
+
193
+ Exactly one registered backend → it receives everything. Several → chat
194
+ requests are routed **per request** by the client's `model` id: model-list
195
+ match first, then `--default-backend` (route decisions are logged as
196
+ `PT.ROUTE` when tracing). `/health`, `/v1/models`, and catch-all passthrough
197
+ still hit the primary URL (`--upstream-url` / first backend). The same wiring
198
+ via env:
199
+
200
+ ```bash
201
+ PTP_UPSTREAM_GGUF=http://127.0.0.1:8181 \
202
+ PTP_UPSTREAM_GGUF_MODELS=qwen-gguf,qwen \
203
+ PTP_UPSTREAM_MLX=http://127.0.0.1:8281 \
204
+ PTP_DEFAULT_BACKEND=mlx \
205
+ proxytool serve
206
+ ```
207
+
208
+ The repair layer needs no dialect knob: for each request, proxytool decides
209
+ from the model id which server dialect the upstream speaks (an mlx model id
210
+ gets `tool_choice` stripped; everything else keeps it). proxytool never
211
+ launches the server — pointing at any OpenAI-compatible upstream just works.
212
+
213
+ ### Test routing yourself
214
+
215
+ ```bash
216
+ # two mock upstreams + per-model routing, assert which one answered:
217
+ uv run pytest apps/proxytool/tests/test_routing_integration.py -q
218
+ ```
219
+
220
+ ## Quickstart (manual)
221
+
222
+ ```bash
223
+ uv sync --all-packages # one shared root .venv (workspace members editable)
224
+
225
+ # Terminal 1: fake upstream (or a real backend on 8181: `llama serve` / mlx_lm.server)
226
+ uv run uvicorn fake_upstream:app --host 127.0.0.1 --port 8181
227
+
228
+ # Terminal 2: the proxytool
229
+ PTP_TRACE=1 uv run uvicorn proxytool.main:app --host 127.0.0.1 --port 8180
230
+
231
+ # Terminal 3: a client
232
+ curl -s http://127.0.0.1:8180/v1/chat/completions \
233
+ -H 'Content-Type: application/json' \
234
+ -d '{"model":"qwen","stream":false,
235
+ "messages":[{"role":"user","content":"list the files"}],
236
+ "tools":[{"type":"function","function":{"name":"list_dir","parameters":{"type":"object","properties":{"path":{}}}}}]}' \
237
+ | jq '.choices[0].message'
238
+ # → tool_calls populated; content clean (no leaked JSON)
239
+ ```
240
+
241
+ ## Real backends (port 8181)
242
+
243
+ ```bash
244
+ # GGUF
245
+ llama serve --host localhost --port 8181 \
246
+ --model $MPATH/qwen2.5-coder-14b-instruct-q4_k_m.gguf \
247
+ --ctx-size 16384 --jinja
248
+
249
+ # MLX
250
+ python -m mlx_lm.server --model mlx-community/Qwen2.5-Coder-14B-Instruct-4bit --port 8181
251
+ ```
252
+
253
+ `--jinja` matters — it makes llama-server use the chat template with tool
254
+ support, which keeps most Hermes-tag leaks local to the server. What it does
255
+ *not* cover (fenced/bare-JSON leaks from other models, malformed JSON,
256
+ history-shape `400`s) is exactly what this proxy adds — see
257
+ [`packages/toolpatch/README.md`](../../packages/toolpatch/README.md).
258
+
259
+ ## End-to-end: the real stack (llama / mlx_lm + llama-swap + opencode or pi)
260
+
261
+ A complete worked setup — two models behind llama-swap, proxytool in front,
262
+ `opencode`/`pi` as the agent. For a staged, byte-level walkthrough of a single
263
+ request through those four hops (what each stage adds, rewrites and drops), see
264
+ `dev/PAYLOADS.md` (local-only, `../../dev/PAYLOADS.md` from here, not in this
265
+ repo).
266
+
267
+ **1. Declare the models in llama-swap's config** (it starts each backend on
268
+ demand for the first request, unloads it after `ttl` seconds idle):
269
+
270
+ ```yaml
271
+ # ~/.llama-swap/config.yml
272
+ health-check: true
273
+ models:
274
+ qwen-gguf:
275
+ cmd: llama serve --host 127.0.0.1 --port ${PORT} --ctx-size 16384 --jinja --model /models/qwen2.5-coder-14b.gguf
276
+ ttl: 300
277
+ qwen-mlx:
278
+ cmd: python -m mlx_lm.server --model mlx-community/Qwen2.5-Coder-14B-Instruct-4bit --port ${PORT}
279
+ ttl: 300
280
+ ```
281
+
282
+ **2. Start the gateway, then the proxytool:**
283
+
284
+ ```bash
285
+ llama-swap --config ~/.llama-swap/config.yml # listens on :8080
286
+ proxytool serve --upstream-url http://127.0.0.1:8080 \
287
+ --upstream-timeout 600 # covers cold model loads
288
+ # or daemonized instead of a foreground terminal:
289
+ # proxytool start --upstream-url http://127.0.0.1:8080 --upstream-timeout 600
290
+ ```
291
+
292
+ **3. Point the clients at the proxytool's `/v1` — never at llama-swap
293
+ directly:**
294
+
295
+ ```bash
296
+ export OPENCODE_CONFIG=./examples/opencode-gguf.jsonc # baseURL http://127.0.0.1:8180/v1
297
+ opencode # interactive agent
298
+ pi --provider proxytool --model qwen-mlx -p "list the files in ." # one-shot turn
299
+ proxytool health && proxytool models # sanity check
300
+ ```
301
+
302
+ **4. Without llama-swap**, start the server yourself (`llama serve` /
303
+ `mlx_lm.server`) and point proxytool at it — proxytool fronts whatever is
304
+ already listening:
305
+
306
+ ```bash
307
+ llama serve --host 127.0.0.1 --port 8181 --jinja --model "$MPATH/qwen.gguf" &
308
+ proxytool serve --upstream-url http://127.0.0.1:8181
309
+ ```
310
+
311
+ Steps 3 onwards are identical in all variants.
312
+
313
+ ## opencode configs
314
+
315
+ Two ready-made client configs (point opencode at the proxytool's `/v1` so
316
+ the repair path is engaged — see the `/v1` suffix note inside each file):
317
+
318
+ | Config | Model id | Backend |
319
+ |---|---|---|
320
+ | `examples/opencode-gguf.jsonc` | `proxytool/qwen-gguf` | llama-server (GGUF) |
321
+ | `examples/opencode-mlx.jsonc` | `proxytool/qwen-mlx` | mlx_lm.server (MLX) |
322
+
323
+ ```bash
324
+ export OPENCODE_CONFIG=./examples/opencode-gguf.jsonc # or opencode-mlx.jsonc
325
+ opencode
326
+ ```
327
+
328
+ ## pi (pi.dev) as a client
329
+
330
+ [pi](https://pi.dev/) is a second supported agent client (alongside
331
+ opencode). Both point at the same proxytool `/v1` endpoint — same repair
332
+ path, same LoopGuard.
333
+
334
+ 1. Install: `curl -fsSL https://pi.dev/install.sh | sh` (Node 22.19+)
335
+ 2. Copy `examples/pi/models.json` into `~/.pi/agent/models.json`
336
+ 3. Run:
337
+
338
+ ```bash
339
+ pi --provider proxytool --model qwen-mlx # interactive
340
+ pi --provider proxytool --model qwen-mlx -p "..." # one-shot print mode
341
+ pi --list-models proxytool # verify the provider is visible
342
+ ```
343
+
344
+ Flags worth knowing: `/model` inside a session switches models without a
345
+ restart (models.json is re-read every time). The `compat` flags in
346
+ `examples/pi/models.json` (`supportsDeveloperRole`, `supportsReasoningEffort`)
347
+ keep pi's request dialect compatible with local inference servers.
348
+
349
+ ## llama-swap / gateway upstreams
350
+
351
+ The proxytool fronts llama-swap (or any OpenAI-compatible gateway) with:
352
+
353
+ ```bash
354
+ proxytool serve --upstream-url http://127.0.0.1:8080
355
+ ```
356
+
357
+ (model ids/aliases: llama-swap routes by the `model` field of each request;
358
+ see the routing bullets below for alias-to-id mapping.)
359
+
360
+ - **Model routing** — llama-swap spins the backend from its own YAML config
361
+ based on the `model` field our proxy sends. Client aliases (`qwen-gguf`,
362
+ `qwen-mlx`) are resolved first via `PTP_MODEL_MAP`, then by probing
363
+ upstream `/v1/models`; when several models are loaded, alias token overlap
364
+ picks the closest match, preferring the artifact the server was launched
365
+ with (a local snapshot path beats a same-name HF repo id).
366
+ - **Timeouts / no buffering** — `PTP_UPSTREAM_TIMEOUT` (default `300`s)
367
+ covers llama-swap's on-demand model loads; SSE responses stream through
368
+ unbuffered (the healer emits as chunks arrive, preserving TTFT) — the
369
+ only accumulation is frame reassembly of chunk-boundary-split SSE lines
370
+ and content buffers while a leak might be forming.
371
+ - **Header preservation** — client `Authorization` and routing headers are
372
+ forwarded per-request; additionally, `PTP_UPSTREAM_API_KEY` sends a static
373
+ bearer token upstream (for `llama serve --api-key` or gated gateways).
374
+ - **Cold starts** — the first request after an idle period may take a while
375
+ while llama-swap loads the model; that's what the timeout covers. If you
376
+ set `PTP_LOOP_NUDGE`/`PTP_LOOP_ABORT` too low, a cold-start retry storm
377
+ could look like a loop — keep the abort threshold above 3–4.
378
+
379
+ ## Environment
380
+
381
+ | Var | Default | Purpose |
382
+ |---|---|---|
383
+ | `PTP_UPSTREAM_URL` | `http://127.0.0.1:8181` | backend the proxy forwards to |
384
+ | `PTP_UPSTREAM_TIMEOUT` | `300` | seconds to wait on an upstream request (SSE + cold starts) |
385
+ | `PTP_UPSTREAM_API_KEY` | *(empty)* | static bearer token sent as `Authorization` upstream |
386
+ | `PTP_MODEL_MAP` | *(empty)* | inline JSON `{"alias": "real-id"}` map |
387
+ | `PTP_MODEL_MAP_FILE` | *(empty)* | path to the same map as a JSON file |
388
+ | `PTP_LOOP_NUDGE` | `3` | identical requests before a nudge is appended (`0` = off) |
389
+ | `PTP_LOOP_ABORT` | `6` | identical requests before the request is aborted (`0` = off) |
390
+ | `PTP_TRACE` | off | `1` = dump every payload transit point to stderr |
391
+ | `PTP_TRACE_FILE` | *(stderr)* | path to append trace output to a file instead |
392
+ | `PTP_LOG_LEVEL` / `PTP_LOG_LEVEL` | `info` | app log tier: `debug` shows repair/routing decisions from the app **and** the toolpatch library; `--quiet` ≡ `error` |
393
+ | `PTP_CONFIG` | — | explicit TOML config-file path (skips cwd/XDG discovery) |
394
+
395
+ (`PTP_*` equivalents exist for every var — see the flags table above; they
396
+ take precedence when both are set.)
397
+
398
+ ## Config file
399
+
400
+ Drop a TOML file and run bare commands — the llama-swap experience:
401
+
402
+ ```bash
403
+ # one-time
404
+ cp examples/proxytool/proxytool.toml ~/.config/proxytool/config.toml # or ./proxytool.toml
405
+
406
+ # day to day — no flags, no env:
407
+ proxytool start # or: proxytool serve
408
+ proxytool health && proxytool models
409
+ proxytool status && proxytool stop
410
+ ```
411
+
412
+ Discovery (first hit wins): `--config PATH` → `$PTP_CONFIG` →
413
+ `./proxytool.toml` → `./.proxytool.toml` →
414
+ `$XDG_CONFIG_HOME/proxytool/config.toml` → `~/.config/proxytool/config.toml`.
415
+ Sections: `host`/`port`/`log_level`/`default_backend` (top-level), `[upstream]`
416
+ (single backend URL), `[routing.<name>]` (multi-backend table; `default = true`
417
+ on exactly one entry, or top-level `default_backend`), `[model_map]`,
418
+ `[loopguard]`, `[trace]`.
419
+ See the annotated `examples/proxytool/proxytool.toml` for the full schema.
420
+
421
+ Precedence per key — **CLI flag > env var > config file > built-in default**.
422
+ The bridge only fills env vars (`PTP_*`) that neither a flag nor the
423
+ environment expressed, so flags/env stay authoritative for overrides and
424
+ scripting. Parse errors fail fast: `proxytool: config error: <file>: <detail>`.
425
+
426
+ Run it from another directory with an explicit file:
427
+
428
+ ```bash
429
+ proxytool start --config /etc/proxytool/config.toml
430
+ ```
431
+
432
+ #### Strict validation
433
+
434
+ The file is validated at load time — unknown keys/sections and wrong types
435
+ fail fast with pointed messages instead of becoming runtime symptoms:
436
+
437
+ ```
438
+ proxytool: config error: proxytool.toml:
439
+ - unknown key 'prot' (top level) — did you mean 'port'?
440
+ - unknown key 'timeot' in [upstream] — did you mean 'timeout'?
441
+ - [upstream.port] Input should be a valid integer
442
+ - default = true on more than one routing entry: gguf, mlx
443
+ ```
444
+
445
+ Parsed errors (invalid TOML syntax) fail the same way. `routing.<name>`
446
+ entries need a `url`; `[upstream].url` must be a real `http(s)://` URL.
447
+
448
+ #### Diagnose without starting anything
449
+
450
+ When a setting doesn't land the way you expected, ask the CLI what it would
451
+ actually resolve:
452
+
453
+ ```bash
454
+ proxytool config print # discovery + validation + every effective setting
455
+ proxytool config check # discovery + validation only (CI/script-friendly)
456
+ proxytool config print --json
457
+
458
+ # example print output
459
+ $ proxytool config print
460
+ config file: ./proxytool.toml
461
+ searched ./proxytool.toml <- found
462
+ validation: ok
463
+ precedence: CLI flag > env var > config file > built-in default
464
+
465
+ effective settings (what serve/start would run with):
466
+ PTP_PORT 8180 <- config file (port)
467
+ PTP_LOOP_ABORT 6 <- built-in default
468
+ ...
469
+ ```
470
+
471
+ A row marked `env (pre-existing)` means the environment already set that key,
472
+ so the config file did not (and will not) override it; `config file (key)`
473
+ shows which TOML key fed it. CLI flags still beat both — this subcommand
474
+ takes no flags, so it shows the env-vs-file layer.
475
+
476
+ LoopGuard: consecutive byte-identical requests (same sanitized messages +
477
+ tools) are detected; after N repeats a nudge is appended telling the model to
478
+ stop, after M the proxytool stops forwarding and returns a clean completion
479
+ explaining the cut-off ("LoopGuard: request aborted after N identical
480
+ attempts"). A different request resets the count.
481
+
482
+ ## Tests & checks
483
+
484
+ ```bash
485
+ uv run pytest # unit + regression + proxy integration (fixtures in packages/toolpatch/tests/regression)
486
+ uv run ruff check .
487
+ uv run pyright
488
+ uv run nox -s coverage # branch coverage over both modules (report printed)
489
+ ```
490
+
491
+ New real-world misformattings captured from your model should be added as
492
+ regression fixture pairs — see
493
+ `packages/toolpatch/tests/regression/fixtures/README.md`.
494
+
495
+ ### Live model test (opt-in)
496
+
497
+ Runs real requests through the proxytool against a **live** Qwen backend,
498
+ asserts nothing leaks (no tag artifacts, no raw JSON next to repaired
499
+ tool_calls, stream and non-stream), and prints a capture block ready to paste
500
+ into the regression-fixture dir if the model produces a novel misformatting:
501
+
502
+ ```bash
503
+ # with the proxytool serving a real model (see the quickstart above):
504
+ PTP_LIVE=1 uv run pytest apps/proxytool/tests/test_live_qwen.py -q
505
+ ```
506
+
507
+ Env: `PTP_LIVE=1` to enable, `PTP_PROXY_URL` (default
508
+ `http://127.0.0.1:8180`) and `PTP_LIVE_MODEL` (override the `model` string
509
+ sent in requests). Skipped (2 tests) unless it is set and the upstream health
510
+ check passes, so `pytest` stays green without it.
511
+
512
+ ### Real client test (opencode, opt-in)
513
+
514
+ Beyond curl: runs the **actual `opencode` CLI** against the proxytool — an
515
+ isolated temp config (via `OPENCODE_CONFIG`, so your `~/.config/opencode` is
516
+ never touched) defines a custom `@ai-sdk/openai-compatible` provider pointed
517
+ at `http://127.0.0.1:8180/v1`, then `opencode run --format json` executes in
518
+ a throwaway sandbox dir and we assert that a real **structured tool call**
519
+ survived opencode's stream parser (`tool_use` events completed) with zero
520
+ leakage in the rendered output:
521
+
522
+ ```bash
523
+ # rig on 8181 + proxytool on 8180, then:
524
+ PTP_LIVE=1 uv run pytest apps/proxytool/tests/test_live_opencode.py -q
525
+ ```
526
+
527
+ Also auto-skips when `opencode` is not on PATH. Extra env:
528
+ `PTP_OPENCODE_BIN` (defaults to `opencode`).
529
+
530
+ ### Real client test (pi, opt-in)
531
+
532
+ Same idea as the opencode client test but for **pi**: an isolated temp
533
+ `PI_CODING_AGENT_DIR` (whose `models.json` is a throwaway, never touching
534
+ `~/.pi/agent/models.json`) registers an `openai-completions` provider pointed
535
+ at `http://127.0.0.1:8180/v1`, then
536
+ `pi --provider proxytool --model qwen-mlx -p "..."` runs one-shot in a
537
+ throwaway sandbox dir. We assert the run completes, a structured tool call
538
+ survived pi's stream parser, and zero tag-artifact or raw-JSON leakage in the
539
+ rendered output:
540
+
541
+ ```bash
542
+ # rig on 8181 + proxytool on 8180, then:
543
+ PTP_LIVE=1 uv run pytest apps/proxytool/tests/test_live_pi.py -q
544
+ ```
545
+
546
+ Also auto-skips when `pi` is not on PATH. Extra env: `PTP_PI_BIN` (defaults
547
+ to `pi`), `PTP_LIVE_MODEL_PI` (defaults to `qwen-mlx`).
548
+
549
+ ```bash
550
+ # End-to-end one-shots with a real model behind the proxytool on 8180:
551
+ PTP_LIVE=1 uv run pytest apps/proxytool/tests/test_live_qwen.py -q # qwen suite
552
+ PTP_LIVE=1 uv run pytest apps/proxytool/tests/test_live_opencode.py -q # + opencode
553
+ PTP_LIVE=1 uv run pytest apps/proxytool/tests/test_live_pi.py -q # + pi
554
+ ```
555
+
556
+ PyCharm Run/Debug (watch payloads transit, breakpoints, fake-upstream
557
+ config): see **PLAN.md §6** in the repository root.