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/__init__.py +107 -0
- proxytool/__main__.py +14 -0
- proxytool/cli.py +634 -0
- proxytool/conduit.py +88 -0
- proxytool/config.py +496 -0
- proxytool/loopguard.py +92 -0
- proxytool/main.py +572 -0
- proxytool/routing.py +113 -0
- proxytool/settings.py +65 -0
- proxytool/trace.py +81 -0
- proxytool-0.4.0.dist-info/METADATA +557 -0
- proxytool-0.4.0.dist-info/RECORD +14 -0
- proxytool-0.4.0.dist-info/WHEEL +4 -0
- proxytool-0.4.0.dist-info/entry_points.txt +3 -0
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())
|