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/__init__.py +108 -0
- proxytool/__main__.py +14 -0
- proxytool/cli.py +617 -0
- proxytool/conduit.py +67 -0
- proxytool/config.py +488 -0
- proxytool/loopguard.py +92 -0
- proxytool/main.py +558 -0
- proxytool/routing.py +106 -0
- proxytool/settings.py +66 -0
- proxytool/trace.py +81 -0
- proxytool-2.0.1.dist-info/METADATA +552 -0
- proxytool-2.0.1.dist-info/RECORD +14 -0
- proxytool-2.0.1.dist-info/WHEEL +4 -0
- proxytool-2.0.1.dist-info/entry_points.txt +3 -0
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)
|