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.
- proxytool-0.4.0/PKG-INFO +557 -0
- proxytool-0.4.0/README.md +542 -0
- proxytool-0.4.0/pyproject.toml +31 -0
- proxytool-0.4.0/pyproject.toml.orig +28 -0
- proxytool-0.4.0/src/proxytool/__init__.py +107 -0
- proxytool-0.4.0/src/proxytool/__main__.py +14 -0
- proxytool-0.4.0/src/proxytool/cli.py +634 -0
- proxytool-0.4.0/src/proxytool/conduit.py +88 -0
- proxytool-0.4.0/src/proxytool/config.py +496 -0
- proxytool-0.4.0/src/proxytool/loopguard.py +92 -0
- proxytool-0.4.0/src/proxytool/main.py +572 -0
- proxytool-0.4.0/src/proxytool/routing.py +113 -0
- proxytool-0.4.0/src/proxytool/settings.py +65 -0
- proxytool-0.4.0/src/proxytool/trace.py +81 -0
proxytool-0.4.0/PKG-INFO
ADDED
|
@@ -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
|
+
[](https://github.com/dave-holden/proxytoolpatch/actions/workflows/test.yml)
|
|
23
|
+
[](https://www.python.org)
|
|
24
|
+
[](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.
|