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.
proxytool/cli.py ADDED
@@ -0,0 +1,617 @@
1
+ """``proxytool`` CLI — a thin launcher around the FastAPI app.
2
+
3
+ It does NOT replace Uvicorn: it builds a uvicorn ``Config``/``Server``
4
+ in-process. Every setting can come from either a CLI flag or an env var
5
+ (``LTP_*``, formerly ``LMW_*`` — both are read, ``LTP_`` wins).
6
+
7
+ proxytool only looks after itself: it fronts an *existing* OpenAI-compatible
8
+ upstream. Managing model servers' process lifecycle is out of scope here —
9
+ use llama-swap, compose, or whatever launcher you already have.
10
+
11
+ Usage::
12
+
13
+ # external backend already running (llama-swap / your own launcher)
14
+ proxytool serve --upstream-url http://127.0.0.1:8181
15
+
16
+ # two backends routed by model id
17
+ proxytool serve \
18
+ --upstream gguf=http://127.0.0.1:8181 --upstream-models gguf=qwen-gguf \
19
+ --upstream mlx=http://127.0.0.1:8281 --upstream-models mlx=qwen-mlx \
20
+ --default-backend mlx
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import json
27
+ import os
28
+ import pathlib
29
+ import signal
30
+ import sys
31
+ import tempfile
32
+ import time
33
+ from typing import Any
34
+
35
+ import uvicorn
36
+
37
+ from proxytool.config import apply_config, config_help_line, describe_config
38
+ from proxytool.routing import Backend, backends_from_args, backends_from_env
39
+
40
+
41
+ def _apply_toml_config(args: Any, *, serve: bool, routing: bool = False
42
+ ) -> tuple[pathlib.Path | None, dict[str, Any], dict[str, Any]]:
43
+ """Bridge the TOML config file into env; exit(2) with a clear message on error.
44
+
45
+ Precedence everywhere: CLI flag > env var > config file > default —
46
+ the bridge only fills vars neither a flag nor the env expressed.
47
+ """
48
+ try:
49
+ return apply_config(args, serve=serve, routing=routing)
50
+ except ValueError as e:
51
+ print(f"proxytool: config error: {e}", file=sys.stderr)
52
+ raise SystemExit(2) from e
53
+
54
+
55
+ def _flag_or_env(args: Any, env_keys: list[str], attr: str) -> str:
56
+ """First truthy CLI attr wins; else first truthy env var; else default."""
57
+ if getattr(args, attr, None):
58
+ return getattr(args, attr)
59
+ for key in env_keys:
60
+ if os.environ.get(key):
61
+ return os.environ[key]
62
+ return ""
63
+
64
+
65
+ def build_parser() -> argparse.ArgumentParser:
66
+ p = argparse.ArgumentParser(
67
+ prog="proxytool",
68
+ description="Launch the proxytool middleware (FastAPI) — a "
69
+ "self-only OpenAI-compatible proxy that fronts an "
70
+ "existing upstream and repairs its broken tool-call "
71
+ "output (backend process management is out of scope).",
72
+ )
73
+ sub = p.add_subparsers(dest="command", required=True)
74
+
75
+ s = sub.add_parser(
76
+ "serve", help="run the middleware in the foreground",
77
+ description="Run the middleware in the foreground: an OpenAI-compatible "
78
+ "proxy that routes requests to the configured upstream "
79
+ "backend(s) and repairs broken tool-call JSON on their "
80
+ "responses. Runs until interrupted (Ctrl-C); server and "
81
+ "request logs go to this terminal.",
82
+ )
83
+ s.add_argument("--host", default=None, help="bind host (LTP_HOST, default 127.0.0.1)")
84
+ s.add_argument("--port", type=int, default=None, help="bind port (LTP_PORT, default 8180)")
85
+
86
+ # Routing: which backend(s) to front.
87
+ s.add_argument(
88
+ "--upstream", action="append", default=[], metavar="NAME=URL",
89
+ help="register a backend, repeatable (LTP_UPSTREAM_<NAME>=URL)",
90
+ )
91
+ s.add_argument(
92
+ "--upstream-models", action="append", default=[], metavar="NAME=m1,m2",
93
+ help="model ids that route to a backend, repeatable "
94
+ "(LTP_UPSTREAM_<NAME>_MODELS=...)",
95
+ )
96
+ s.add_argument(
97
+ "--default-backend", default=None,
98
+ help="backend used when no model list matches (LTP_DEFAULT_BACKEND)",
99
+ )
100
+ s.add_argument(
101
+ "--upstream-url", default=None,
102
+ help="standalone single-backend URL (LMW_UPSTREAM_URL/LTP_UPSTREAM_URL)",
103
+ )
104
+
105
+ # Request-side knobs forwarded to Settings.
106
+ s.add_argument("--model-map", default=None, help="inline JSON map (LMW_MODEL_MAP)")
107
+ s.add_argument("--model-map-file", default=None,
108
+ help="JSON file map (LMW_MODEL_MAP_FILE)")
109
+ s.add_argument("--upstream-api-key", default=None, help="bearer token upstream")
110
+ s.add_argument("--upstream-timeout", type=float, default=None,
111
+ help="seconds (LMW_UPSTREAM_TIMEOUT)")
112
+ s.add_argument("--trace", action="store_true", help="LMW_TRACE=1 / LTP_TRACE=1")
113
+ s.add_argument(
114
+ "--loop-nudge", type=int, default=None, help="LMW_LOOP_NUDGE (0 = off)"
115
+ )
116
+ s.add_argument(
117
+ "--loop-abort", type=int, default=None, help="LMW_LOOP_ABORT (0 = off)"
118
+ )
119
+ s.add_argument(
120
+ "--log-level", default=None,
121
+ choices=("debug", "info", "warning", "error"),
122
+ help="app log level (LTP_LOG_LEVEL / LMW_LOG_LEVEL)"
123
+ )
124
+ s.add_argument("--quiet", action="store_true",
125
+ help="only log errors; equivalent to --log-level error")
126
+ s.add_argument("--config", default=None, help=config_help_line())
127
+
128
+ # Lifecycle subcommands operate on an already-running (or not) server.
129
+ lifecycle_descriptions = {
130
+ "status":
131
+ "Report the state of a server launched via `start`: RUNNING with "
132
+ "pid and URL, STALE if the pid is gone, or STOPPED if no state "
133
+ "file exists. Exit code reflects the state; starts nothing.",
134
+ "stop":
135
+ "Stop the server started by `start`: sends SIGTERM and waits up "
136
+ "to 15s for it to exit, then SIGKILL. Clears the state file "
137
+ "whether the pid was alive or already stale. No effect on "
138
+ "foreground `serve` processes — Ctrl-C those yourself.",
139
+ "health":
140
+ "Probe /health on the middleware and on each registered upstream "
141
+ "backend and print a JSON report. Read-only diagnostic; never "
142
+ "starts a server (a backend without /health is probed via "
143
+ "/v1/models instead).",
144
+ "models":
145
+ "Ask each registered upstream for /v1/models and list the model "
146
+ "ids it serves. Read-only diagnostic used to pick routing model "
147
+ "names and to sanity-check a backend's loaded artifacts.",
148
+ }
149
+ for name, help_text in (
150
+ ("status", "show the running/not-running state of the middleware"),
151
+ ("stop", "stop a proxytool started with `start` (graceful SIGTERM)"),
152
+ ("health", "probe /health on the middleware and each registered backend"),
153
+ ("models", "list the model ids each upstream reports (/v1/models)"),
154
+ ):
155
+ sp = sub.add_parser(name, help=help_text,
156
+ description=lifecycle_descriptions[name])
157
+ sp.add_argument("--host", default=None, help="middleware host (LTP_HOST)")
158
+ sp.add_argument("--port", type=int, default=None, help="middleware port (LTP_PORT)")
159
+ sp.add_argument("--config", default=None, help=config_help_line())
160
+
161
+ start = sub.add_parser(
162
+ "start",
163
+ help="daemonize: launch `serve` in the background, return immediately",
164
+ description="Launch `serve` as a detached background daemon with the "
165
+ "same flags and return once the server answers /health "
166
+ "(up to 30s). The daemon's output goes to a log file "
167
+ "instead of this terminal — proxytool-serve.log beside "
168
+ "the state file. Manage it with `status` and `stop`.",
169
+ )
170
+ # `start` forwards every serve flag; build it by mirroring serve's args.
171
+ _mirror_serve_args(start)
172
+ start.add_argument("--host", default=None, help="bind host (LTP_HOST)")
173
+ start.add_argument("--port", type=int, default=None, help="bind port (LTP_PORT)")
174
+ start.add_argument("--log-level", default=None,
175
+ choices=("debug", "info", "warning", "error"),
176
+ help="app log level for the daemonized server")
177
+ start.add_argument("--quiet", action="store_true",
178
+ help="only log errors in the daemonized server")
179
+ start.add_argument("--config", default=None, help=config_help_line())
180
+
181
+ c = sub.add_parser(
182
+ "config",
183
+ help="diagnose the TOML config: discovery, validation, resolved settings",
184
+ description="Show which config file discovery finds, validate it "
185
+ "strictly (unknown keys, bad types), and print every "
186
+ "setting `serve`/`start` would run with — value + "
187
+ "source. Never starts a server.",
188
+ )
189
+ c.add_argument(
190
+ "action", nargs="?", choices=("print", "check"), default="print",
191
+ help="print = discovery + validation + full settings table "
192
+ "(default); check = discovery + validation only",
193
+ )
194
+ c.add_argument("--json", action="store_true",
195
+ help="machine-readable output instead of the table")
196
+ c.add_argument("--config", default=None, help=config_help_line())
197
+
198
+ sub.add_parser(
199
+ "version", help="print the proxytool version",
200
+ description="Print the installed proxytool package version (or a "
201
+ "dev-checkout marker when it is not installed), then exit.",
202
+ )
203
+ return p
204
+
205
+
206
+ def _merge_env_into_process_env(args: Any,
207
+ backends: dict[str, Backend]) -> None:
208
+ """Ensure settings_* env vars are visible to a reloaded ProxySettings."""
209
+ if args.upstream_api_key and not os.environ.get("LMW_UPSTREAM_API_KEY"):
210
+ os.environ["LMW_UPSTREAM_API_KEY"] = args.upstream_api_key
211
+ if args.upstream_timeout and not os.environ.get("LMW_UPSTREAM_TIMEOUT"):
212
+ os.environ["LMW_UPSTREAM_TIMEOUT"] = str(args.upstream_timeout)
213
+ if args.model_map and not os.environ.get("LMW_MODEL_MAP"):
214
+ os.environ["LMW_MODEL_MAP"] = args.model_map
215
+ if args.model_map_file and not os.environ.get("LMW_MODEL_MAP_FILE"):
216
+ os.environ["LMW_MODEL_MAP_FILE"] = args.model_map_file
217
+ if args.trace and not os.environ.get("LMW_TRACE"):
218
+ os.environ["LMW_TRACE"] = "1"
219
+ if args.loop_nudge is not None and not os.environ.get("LMW_LOOP_NUDGE"):
220
+ os.environ["LMW_LOOP_NUDGE"] = str(args.loop_nudge)
221
+ if args.loop_abort is not None and not os.environ.get("LMW_LOOP_ABORT"):
222
+ os.environ["LMW_LOOP_ABORT"] = str(args.loop_abort)
223
+ if backends and not os.environ.get("LTP_DEFAULT_BACKEND"):
224
+ d = next((n for n, b in backends.items() if b.default), None)
225
+ if d:
226
+ os.environ["LTP_DEFAULT_BACKEND"] = d
227
+
228
+
229
+ def _pick_single_backend_url(backends: dict[str, Backend], args: Any,
230
+ env_upstream: str) -> str:
231
+ """When the app-level Settings only supports one upstream, pick it here."""
232
+ explicit = _flag_or_env(args, ["LTP_UPSTREAM_URL", "LMW_UPSTREAM_URL"], "upstream_url")
233
+ if explicit:
234
+ return explicit
235
+ if backends:
236
+ d = next((b for b in backends.values() if b.default), None)
237
+ return (d or next(iter(backends.values()))).url
238
+ return env_upstream or "http://127.0.0.1:8181"
239
+
240
+
241
+ def cmd_serve(args: Any) -> int:
242
+ # 0. TOML config bridge (precedence: flag > env > file > default).
243
+ cfg_path, _cfg, cfg_info = _apply_toml_config(args, serve=True, routing=True)
244
+ if cfg_path:
245
+ print(f"[proxytool] config: using {cfg_path} "
246
+ f"({len(cfg_info['applied'])} setting(s) bridged)", file=sys.stderr,
247
+ flush=True)
248
+
249
+ # 1. Resolve backends: explicit flags, then env, then single --upstream-url.
250
+ backends = backends_from_args(
251
+ args.upstream, dict(args.upstream_models or {}), args.default_backend
252
+ )
253
+ if not backends:
254
+ backends = backends_from_env()
255
+ if args.upstream_url:
256
+ backends.setdefault("default", Backend(
257
+ name="default", url=args.upstream_url.rstrip("/"),
258
+ default=len(backends) == 0,
259
+ ))
260
+
261
+ # 3. Settings env bridge (flags → env → Settings defaults).
262
+ _merge_env_into_process_env(args, backends)
263
+ # App-level logging is configured once here (CLI flags / env). It is
264
+ # intentionally separate from Uvicorn's log_level below, which only
265
+ # controls the server/framework log output.
266
+ from proxytool import configure_logging as _cfg
267
+ _cfg(level=args.log_level, quiet=args.quiet)
268
+ # proxytool.main builds its own Settings from env at import time (inside
269
+ # uvicorn.run), so resolved values must be exported as env, not mutated
270
+ # on a local instance uvicorn never sees.
271
+ resolved_url = _pick_single_backend_url(
272
+ backends, args, os.environ.get("LMW_UPSTREAM_URL", "")
273
+ )
274
+ os.environ["LMW_UPSTREAM_URL"] = resolved_url
275
+ os.environ.setdefault("LTP_UPSTREAM_URL", resolved_url)
276
+ # Multi-backend table: only export when the user declared >1 backend (or
277
+ # declared one by name) — a single default backend would otherwise make
278
+ # proxytool.main build a router where single-conduit logic already suffices.
279
+ named = [b for b in backends.values() if b.name != "default"]
280
+ if len(named) >= 2 or (len(named) == 1 and not args.upstream_url):
281
+ for b in named:
282
+ os.environ[f"LTP_UPSTREAM_{b.name.upper()}"] = b.url
283
+ if b.models:
284
+ os.environ[f"LTP_UPSTREAM_{b.name.upper()}_MODELS"] = ",".join(b.models)
285
+ d = next((b for b in backends.values() if b.default and b.name != "default"), None)
286
+ if d:
287
+ os.environ["LTP_DEFAULT_BACKEND"] = d.name
288
+ if args.trace:
289
+ os.environ["LMW_TRACE"] = "1"
290
+ print(f"[proxytool] middleware on {args.host or '127.0.0.1'}:{args.port or 8180}"
291
+ f" → upstream {resolved_url}", file=sys.stderr, flush=True)
292
+
293
+ # 4. Run uvicorn in-process; SIGINT/SIGTERM propagate normally.
294
+ uvicorn.run(
295
+ "proxytool.main:app",
296
+ host=args.host or os.environ.get("LTP_HOST", "127.0.0.1"),
297
+ port=args.port if args.port is not None else int(os.environ.get("LTP_PORT", "8180")),
298
+ log_level=os.environ.get("LTP_LOG_LEVEL", "info"),
299
+ ) # blocking
300
+ return 0
301
+
302
+
303
+ def cmd_config(args: Any) -> int:
304
+ """Diagnose the TOML config without touching the environment."""
305
+ info = describe_config(args)
306
+ if getattr(args, "json", False):
307
+ print(json.dumps(info, indent=2))
308
+ return 0 if info["valid"] else 2
309
+ if not info["valid"]:
310
+ print("proxytool: config error:", file=sys.stderr)
311
+ for line in info["errors"]:
312
+ print(f" {line}", file=sys.stderr)
313
+ return 2
314
+ print(f"config file: {info['file'] or '(none found)'}")
315
+ for cand in info["searched"]:
316
+ mark = " <- found" if info["file"] and cand == info["file"] else ""
317
+ print(f" searched {cand}{mark}")
318
+ if getattr(args, "action", "print") == "check":
319
+ applied = len(info["applied"])
320
+ print(f"validation: ok ({applied} setting(s) would be bridged)")
321
+ return 0
322
+ print("validation: ok")
323
+ print(f"precedence: {info['precedence']}")
324
+ if not info["settings"]:
325
+ print("effective settings: (none — everything stays at defaults)")
326
+ return 0
327
+ width = max(len(r["key"]) for r in info["settings"])
328
+ print("\neffective settings (what serve/start would run with):")
329
+ for r in info["settings"]:
330
+ src = r["source"] + (f" ({r['detail']})" if r["detail"] else "")
331
+ print(f" {r['key']:<{width}} {r['value']} <- {src}")
332
+ return 0
333
+
334
+
335
+ def cmd_version(_args: Any) -> int:
336
+ from importlib.metadata import PackageNotFoundError, version
337
+
338
+ try:
339
+ print(f"proxytool {version('proxytool')}")
340
+ except PackageNotFoundError:
341
+ print("proxytool (unversioned dev checkout)")
342
+ return 0
343
+
344
+
345
+ # Flags `start` shares with `serve`: (dest, cli-flag, type, metavar/help).
346
+ # Kept explicit instead of introspecting serve's parser — the "recursive
347
+ # mirror" trick ran build_parser() unconditionally, which recursed forever.
348
+ _SERVE_FLAGS: tuple[tuple[str, str, str], ...] = (
349
+ ("upstream_url", "--upstream-url", "standalone single-backend URL"),
350
+ ("model_map", "--model-map", "inline JSON alias map"),
351
+ ("model_map_file", "--model-map-file", "JSON alias-map file"),
352
+ ("upstream_api_key", "--upstream-api-key", "bearer token upstream"),
353
+ ("upstream_timeout", "--upstream-timeout", "upstream read timeout (seconds)"),
354
+ ("default_backend", "--default-backend", "fallback backend name"),
355
+ )
356
+
357
+
358
+ def _mirror_serve_args(parser: argparse.ArgumentParser) -> None:
359
+ """Give `start` the same flags as `serve` (it wraps `serve`)."""
360
+ for dest, flag, help_text in _SERVE_FLAGS:
361
+ parser.add_argument(flag, dest=dest, default=None, help=help_text)
362
+ parser.add_argument("--upstream", action="append", default=[],
363
+ metavar="NAME=URL", help="register a backend, repeatable")
364
+ parser.add_argument("--upstream-models", action="append", default=[],
365
+ metavar="NAME=m1,m2", help="model ids routed to a backend")
366
+ parser.add_argument("--trace", action="store_true", help="payload trace on")
367
+ parser.add_argument("--loop-nudge", type=int, default=None, help="loop nudge threshold")
368
+ parser.add_argument("--loop-abort", type=int, default=None, help="loop abort threshold")
369
+
370
+
371
+ def _state_path() -> pathlib.Path:
372
+ if p := os.environ.get("LTP_STATE_PATH"):
373
+ return pathlib.Path(p)
374
+ return pathlib.Path(tempfile.gettempdir()) / "proxytool-serve-state.json"
375
+
376
+
377
+ def _read_state() -> dict[str, Any] | None:
378
+ try:
379
+ return json.loads(_state_path().read_text(encoding="utf-8"))
380
+ except (OSError, ValueError):
381
+ return None
382
+
383
+
384
+ def _write_state(state: dict[str, Any] | None) -> None:
385
+ path = _state_path()
386
+ if state is None:
387
+ path.unlink(missing_ok=True)
388
+ return
389
+ path.write_text(json.dumps(state, indent=2), encoding="utf-8")
390
+
391
+
392
+ def _pid_alive(pid: int) -> bool:
393
+ if pid <= 0:
394
+ return False
395
+ try:
396
+ os.kill(pid, 0) # signal 0 = existence probe only
397
+ return True
398
+ except (OSError, ProcessLookupError):
399
+ return False
400
+
401
+
402
+ def _host_port(args: Any) -> tuple[str, int]:
403
+ host = args.host or os.environ.get("LTP_HOST", "127.0.0.1")
404
+ port = args.port if args.port is not None else int(os.environ.get("LTP_PORT", "8180"))
405
+ return str(host), port
406
+
407
+
408
+ def cmd_status(_args: Any) -> int:
409
+ """Check whether the server (started via ``start``) is alive and healthy."""
410
+ _apply_toml_config(_args, serve=False) # config host/port for the probe
411
+ state = _read_state()
412
+ host, port = (_host_port(_args) if hasattr(_args, "host") else ("127.0.0.1", 8180))
413
+ if state and _pid_alive(int(state.get("pid", 0))):
414
+ print(f"proxytool status: RUNNING (pid {state['pid']}, http://{host}:{port})")
415
+ return 0
416
+ if state:
417
+ print(f"proxytool status: STALE (pid {state.get('pid')} gone) — clearing state")
418
+ _write_state(None)
419
+ return 1
420
+ print(f"proxytool status: STOPPED (no state file at {_state_path()})")
421
+ return 3 # conventional "stopped but fine" exit
422
+
423
+
424
+ def cmd_stop(_args: Any) -> int:
425
+ """Gracefully stop a daemonized server: SIGTERM, wait, then SIGKILL."""
426
+ _apply_toml_config(_args, serve=False) # no-op unless config sets state/port
427
+ state = _read_state()
428
+ if not state:
429
+ print("proxytool stop: not running (no state file)", file=sys.stderr)
430
+ return 3
431
+ pid = int(state.get("pid", 0))
432
+ if not _pid_alive(pid):
433
+ print(f"proxytool stop: pid {pid} already gone — clearing stale state")
434
+ _write_state(None)
435
+ return 0
436
+ os.kill(pid, signal.SIGTERM)
437
+ deadline = time.monotonic() + 15.0
438
+ while time.monotonic() < deadline:
439
+ if not _pid_alive(pid):
440
+ _write_state(None)
441
+ print("proxytool stop: stopped")
442
+ return 0
443
+ time.sleep(0.2)
444
+ os.kill(pid, signal.SIGKILL)
445
+ _write_state(None)
446
+ print("proxytool stop: SIGKILL after grace window (force-stopped)")
447
+ return 0
448
+
449
+
450
+ def cmd_start(args: Any) -> int:
451
+ """Daemonize ``serve``: same flags, detached, PID recorded for stop/status."""
452
+ import subprocess
453
+
454
+ # Config host/port must shape the child argv; the child re-reads the
455
+ # file itself (same cwd discovery or the forwarded --config below).
456
+ _apply_toml_config(args, serve=False)
457
+ host, port = _host_port(args)
458
+ state = _read_state()
459
+ if state and _pid_alive(int(state.get("pid", 0))):
460
+ print(f"proxytool start: already running (pid {state['pid']}) — use stop first")
461
+ return 1
462
+ # Rebuild the exact serve invocation from the parsed flags.
463
+ argv = [sys.executable, "-m", "proxytool", "serve", "--host", host, "--port", str(port)]
464
+ for name in ("upstream_url", "model_map", "model_map_file",
465
+ "upstream_api_key", "upstream_timeout"):
466
+ v = getattr(args, name, None)
467
+ if v:
468
+ argv += [f"--{name.replace('_', '-')}", str(v)]
469
+ for spec in (args.upstream or []):
470
+ argv += ["--upstream", spec]
471
+ for spec in (args.upstream_models or []):
472
+ argv += ["--upstream-models", spec]
473
+ if args.default_backend:
474
+ argv += ["--default-backend", args.default_backend]
475
+ if args.trace:
476
+ argv.append("--trace")
477
+ if args.loop_nudge is not None:
478
+ argv += ["--loop-nudge", str(args.loop_nudge)]
479
+ if args.loop_abort is not None:
480
+ argv += ["--loop-abort", str(args.loop_abort)]
481
+ if getattr(args, "log_level", None):
482
+ argv += ["--log-level", args.log_level]
483
+ if getattr(args, "quiet", False):
484
+ argv.append("--quiet")
485
+ if getattr(args, "config", None):
486
+ argv += ["--config", args.config]
487
+
488
+ log_path = _state_path().with_suffix(".log")
489
+ with open(log_path, "ab") as log:
490
+ child = subprocess.Popen(
491
+ argv, stdout=log, stderr=log, stdin=subprocess.DEVNULL,
492
+ start_new_session=True, # detach from our terminal's process group
493
+ )
494
+ # Wait for /health so `start` returns only when the server truly answers.
495
+ import urllib.request
496
+ deadline = time.monotonic() + 30.0
497
+ while time.monotonic() < deadline:
498
+ try:
499
+ with urllib.request.urlopen(f"http://{host}:{port}/health", timeout=2) as r:
500
+ if r.status < 400:
501
+ break
502
+ except Exception:
503
+ if child.poll() is not None:
504
+ print(f"proxytool start: process died during startup — see {log_path}",
505
+ file=sys.stderr)
506
+ return 1
507
+ time.sleep(0.3)
508
+ else:
509
+ child.terminate()
510
+ print(f"proxytool start: /health never came up within 30s — see {log_path}",
511
+ file=sys.stderr)
512
+ return 1
513
+ _write_state({"pid": child.pid, "host": host, "port": port,
514
+ "log": str(log_path), "argv": argv[2:]})
515
+ print(f"proxytool start: running (pid {child.pid}, http://{host}:{port}, log {log_path})")
516
+ return 0
517
+
518
+
519
+ def cmd_health(args: Any) -> int:
520
+ """Probe the middleware and every registered backend; print a JSON report."""
521
+ import urllib.request
522
+
523
+ # Config can define the middleware host/port and the [routing.*] table so
524
+ # `proxytool health` "just works" without repeating flags/env.
525
+ _apply_toml_config(args, serve=False, routing=True)
526
+
527
+ host, port = _host_port(args)
528
+ report: dict[str, Any] = {"middleware": {"url": f"http://{host}:{port}", "ok": False}}
529
+ try:
530
+ with urllib.request.urlopen(f"http://{host}:{port}/health", timeout=3) as r:
531
+ report["middleware"]["ok"] = r.status < 400
532
+ except Exception as e:
533
+ report["middleware"]["error"] = str(e)
534
+
535
+ # Backends registered in env (routed setups); probe each, plus /v1/models.
536
+ from proxytool.routing import backends_from_env
537
+
538
+ backends = backends_from_env()
539
+ probe_list: list[dict[str, Any]] = []
540
+ for b in backends.values():
541
+ entry: dict[str, Any] = {"name": b.name, "url": b.url, "ok": False}
542
+ try:
543
+ with urllib.request.urlopen(b.url.rstrip("/") + "/health", timeout=3) as r:
544
+ entry["ok"] = r.status < 400
545
+ except Exception as e:
546
+ try:
547
+ with urllib.request.urlopen(b.url.rstrip("/") + "/v1/models", timeout=3) as r:
548
+ entry["ok"] = r.status < 400 # mlx_lm.server has no /health
549
+ entry["via"] = "/v1/models fallback"
550
+ except Exception:
551
+ entry["error"] = str(e)
552
+ probe_list.append(entry)
553
+ report["backends"] = probe_list
554
+ print(json.dumps(report, indent=2))
555
+ return 0 if report["middleware"]["ok"] else 1
556
+
557
+
558
+ def cmd_models(_args: Any) -> int:
559
+ """List each upstream's /v1/models ids (what routing matches on)."""
560
+ import urllib.request
561
+
562
+ # Same as health: the config's [routing.*] table feeds the probes.
563
+ _apply_toml_config(_args, serve=False, routing=True)
564
+
565
+ from proxytool.routing import backends_from_env
566
+
567
+ out: list[dict[str, Any]] = []
568
+ exit_code = 0
569
+ for b in backends_from_env().values():
570
+ entry: dict[str, Any] = {"name": b.name, "url": b.url}
571
+ try:
572
+ with urllib.request.urlopen(b.url.rstrip("/") + "/v1/models", timeout=5) as r:
573
+ data = json.load(r)
574
+ ids = [m.get("id") for m in data.get("data", []) if isinstance(m, dict)]
575
+ entry["models"] = ids
576
+ except Exception as e:
577
+ entry["error"] = str(e)
578
+ exit_code = 1
579
+ out.append(entry)
580
+ print(json.dumps(out, indent=2))
581
+ return exit_code
582
+
583
+
584
+ def main(argv: list[str] | None = None) -> int:
585
+ parser = build_parser()
586
+ args = parser.parse_args(argv)
587
+ handler = {
588
+ "serve": cmd_serve,
589
+ "start": cmd_start,
590
+ "stop": cmd_stop,
591
+ "status": cmd_status,
592
+ "health": cmd_health,
593
+ "models": cmd_models,
594
+ "config": cmd_config,
595
+ "version": cmd_version,
596
+ }.get(args.command)
597
+ if handler is None:
598
+ parser.print_usage(sys.stderr)
599
+ return 2
600
+ return handler(args)
601
+
602
+
603
+ def _configure_logging_cli(*, level: str | None, quiet: bool) -> None:
604
+ """Configure app-level logging from the CLI (serve / start).
605
+
606
+ This is the CLI-facing entry point for app logging. It deliberately does
607
+ not import ``proxytool.__init__``'s ``configure_logging`` so that
608
+ ``proxytool.cli`` can be parsed/validated without triggering the
609
+ proxytool package import path that expects a built distribution.
610
+ """
611
+ from proxytool import configure_logging as _configure
612
+
613
+ _configure(level=level, quiet=quiet)
614
+
615
+
616
+ if __name__ == "__main__":
617
+ sys.exit(main())
proxytool/conduit.py ADDED
@@ -0,0 +1,67 @@
1
+ """httpx conduit to the upstream (llama-server / mlx server / llama-swap)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ # hop-by-hop/noise headers never forwarded upstream.
10
+ _DROP_HEADERS = {"host", "content-length", "accept-encoding", "connection"}
11
+
12
+
13
+ class UpstreamConduit:
14
+ def __init__(self, base_url: str,
15
+ timeout: float = 300.0,
16
+ api_key: str = "") -> None:
17
+ self._base_url = base_url
18
+ # SSE generations are long-lived: a single generous timeout applies to
19
+ # the whole request (llama-swap cold-start model loads included).
20
+ self._timeout = httpx.Timeout(timeout)
21
+ self._api_key = api_key
22
+
23
+ def client(self) -> httpx.AsyncClient:
24
+ return httpx.AsyncClient(base_url=self._base_url, timeout=self._timeout)
25
+
26
+ def _auth_headers(self) -> dict[str, str]:
27
+ if self._api_key:
28
+ return {"Authorization": f"Bearer {self._api_key}"}
29
+ return {}
30
+
31
+ @staticmethod
32
+ def forwardable(headers: dict[str, str]) -> dict[str, str]:
33
+ """Client headers worth forwarding (auth, routing, trace ids)."""
34
+ return {
35
+ k: v for k, v in headers.items()
36
+ if k.lower() not in _DROP_HEADERS and v
37
+ }
38
+
39
+ async def forward_json(self, path: str, payload: dict[str, Any],
40
+ headers: dict[str, str] | None = None
41
+ ) -> httpx.Response:
42
+ async with self.client() as client:
43
+ return await client.post(
44
+ path,
45
+ json=payload,
46
+ headers={**self._auth_headers(), **self.forwardable(headers or {})},
47
+ )
48
+
49
+ async def forward_stream(
50
+ self, path: str, payload: dict[str, Any],
51
+ headers: dict[str, str] | None = None,
52
+ ) -> httpx.Response:
53
+ client = self.client()
54
+ request = client.build_request(
55
+ "POST",
56
+ path,
57
+ json=payload,
58
+ headers={**self._auth_headers(), **self.forwardable(headers or {})},
59
+ )
60
+ return await client.send(request, stream=True)
61
+
62
+ async def passthrough(self, method: str, path: str,
63
+ headers: dict[str, str],
64
+ content: bytes) -> httpx.Response:
65
+ async with self.client() as client:
66
+ merged = {**self._auth_headers(), **self.forwardable(headers)}
67
+ return await client.request(method, path, headers=merged, content=content)