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