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