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/conduit.py ADDED
@@ -0,0 +1,88 @@
1
+ """httpx conduit to the upstream (llama-server / mlx_lm.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
+ def upstream_path(base_url: str, path: str) -> str:
14
+ """Canonical app path → path relative to the configured upstream base.
15
+
16
+ Base URLs may be bare server roots (`http://host:8080`) or OpenAI-rooted
17
+ (`http://host:8080/v1`, as llama-swap documents them). With an OpenAI
18
+ root the canonical `/v1/...` prefix is stripped, so a base of neither
19
+ spelling can ever produce a doubled `/v1/v1/chat/completions` upstream.
20
+ """
21
+ p = path.lstrip("/")
22
+ b = base_url.rstrip("/")
23
+ if b.endswith("/v1") and (p == "v1" or p.startswith("v1/")):
24
+ p = p[len("v1/"):]
25
+ return p
26
+
27
+
28
+ def upstream_url(base_url: str, path: str) -> str:
29
+ """Absolute form of `upstream_path` — for urllib probes outside httpx."""
30
+ return f"{base_url.rstrip('/')}/{upstream_path(base_url, path)}"
31
+
32
+
33
+ class UpstreamConduit:
34
+ def __init__(self, base_url: str,
35
+ timeout: float = 300.0,
36
+ api_key: str = "") -> None:
37
+ self._base_url = base_url
38
+ # SSE generations are long-lived: a single generous timeout applies to
39
+ # the whole request (llama-swap cold-start model loads included).
40
+ self._timeout = httpx.Timeout(timeout)
41
+ self._api_key = api_key
42
+
43
+ def client(self) -> httpx.AsyncClient:
44
+ return httpx.AsyncClient(base_url=self._base_url, timeout=self._timeout)
45
+
46
+ def _auth_headers(self) -> dict[str, str]:
47
+ if self._api_key:
48
+ return {"Authorization": f"Bearer {self._api_key}"}
49
+ return {}
50
+
51
+ @staticmethod
52
+ def forwardable(headers: dict[str, str]) -> dict[str, str]:
53
+ """Client headers worth forwarding (auth, routing, trace ids)."""
54
+ return {
55
+ k: v for k, v in headers.items()
56
+ if k.lower() not in _DROP_HEADERS and v
57
+ }
58
+
59
+ async def forward_json(self, path: str, payload: dict[str, Any],
60
+ headers: dict[str, str] | None = None
61
+ ) -> httpx.Response:
62
+ async with self.client() as client:
63
+ return await client.post(
64
+ upstream_path(self._base_url, path),
65
+ json=payload,
66
+ headers={**self._auth_headers(), **self.forwardable(headers or {})},
67
+ )
68
+
69
+ async def forward_stream(
70
+ self, path: str, payload: dict[str, Any],
71
+ headers: dict[str, str] | None = None,
72
+ ) -> httpx.Response:
73
+ client = self.client()
74
+ request = client.build_request(
75
+ "POST",
76
+ upstream_path(self._base_url, path),
77
+ json=payload,
78
+ headers={**self._auth_headers(), **self.forwardable(headers or {})},
79
+ )
80
+ return await client.send(request, stream=True)
81
+
82
+ async def passthrough(self, method: str, path: str,
83
+ headers: dict[str, str],
84
+ content: bytes) -> httpx.Response:
85
+ async with self.client() as client:
86
+ merged = {**self._auth_headers(), **self.forwardable(headers)}
87
+ return await client.request(
88
+ method, upstream_path(self._base_url, path), headers=merged, content=content)
proxytool/config.py ADDED
@@ -0,0 +1,496 @@
1
+ """TOML config file support for the ``proxytool`` CLI.
2
+
3
+ Precedence for every setting:
4
+
5
+ CLI flag > environment (PTP_*) > config file > default
6
+
7
+ Config discovery: ``--config PATH``, then ``PTP_CONFIG``, then
8
+ ``./proxytool.toml``, ``./.proxytool.toml``, and finally
9
+ ``$XDG_CONFIG_HOME/proxytool/config.toml`` (default ``~/.config/...``).
10
+
11
+ The config file is a *bridge*, not a parallel system: recognized values are
12
+ exported as the same ``PTP_*`` env vars the rest of the stack already reads
13
+ (``settings``, routing, the daemonized child), but only when neither a CLI
14
+ flag nor the environment already expressed them. That makes the CLI stay tiny
15
+ (``proxytool start`` finds the file and goes) while flags/env keep winning.
16
+
17
+ Schema (all keys optional)::
18
+
19
+ host = "127.0.0.1"
20
+ port = 8180
21
+ log_level = "info" # debug | info | warning | error
22
+ default_backend = "mlx" # fallback route name for multi-routing
23
+
24
+ [upstream] # single-backend setup (llama-swap etc.)
25
+ url = "http://127.0.0.1:8080"
26
+ timeout = 600
27
+ api_key = "sk-..."
28
+
29
+ [routing.gguf] # multi-backend routing table
30
+ url = "http://127.0.0.1:8181"
31
+ models = ["qwen-gguf", "qwen"]
32
+ default = true
33
+
34
+ [model_map]
35
+ "qwen-gguf" = "qwen2.5-coder-14b-instruct"
36
+
37
+ [loopguard]
38
+ nudge = 3
39
+ abort = 6
40
+
41
+ [trace]
42
+ enabled = false
43
+ file = "/tmp/proxytool-trace.log"
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import json
49
+ import os
50
+ import re
51
+ import sys
52
+ from difflib import get_close_matches
53
+ from pathlib import Path
54
+ from typing import Any, Literal
55
+
56
+ from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field, ValidationError, field_validator
57
+
58
+ from proxytool.routing import RESERVED_ROUTE_SUFFIXES
59
+
60
+ if sys.version_info >= (3, 11):
61
+ import tomllib
62
+ else: # Python 3.10 — tomli is the stdlib tomllib backport
63
+ import tomli as tomllib
64
+
65
+ CWD_NAMES = ("proxytool.toml", ".proxytool.toml")
66
+
67
+
68
+ def find_config(explicit: str | None = None) -> Path | None:
69
+ """Resolve the active config file path, or None when none exists."""
70
+ if explicit:
71
+ p = Path(explicit).expanduser()
72
+ if not p.is_file():
73
+ raise ValueError(f"config file not found: {explicit}")
74
+ return p
75
+ env = os.environ.get("PTP_CONFIG")
76
+ if env:
77
+ p = Path(env).expanduser()
78
+ if not p.is_file():
79
+ raise ValueError(f"PTP_CONFIG points to a missing file: {env}")
80
+ return p
81
+ xdg = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
82
+ cwd = Path.cwd()
83
+ for cand in (cwd / CWD_NAMES[0], cwd / CWD_NAMES[1],
84
+ Path(xdg) / "proxytool" / "config.toml"):
85
+ if cand.is_file():
86
+ return cand
87
+ return None
88
+
89
+
90
+ def load_config(path: Path | None) -> dict[str, Any]:
91
+ """Parse the TOML config into a dict; {} when path is None."""
92
+ if path is None:
93
+ return {}
94
+ try:
95
+ with path.open("rb") as f:
96
+ data = tomllib.load(f)
97
+ except OSError as e:
98
+ raise ValueError(f"cannot read config {path}: {e}") from e
99
+ except tomllib.TOMLDecodeError as e:
100
+ raise ValueError(f"invalid TOML in {path}: {e}") from e
101
+ if not isinstance(data, dict):
102
+ raise ValueError(f"config {path}: top level must be a table")
103
+ validate_config(data, label=str(path))
104
+ return data
105
+
106
+
107
+ # ── strict schema validation ─────────────────────────────────────────────────
108
+ # The bridge below is permissive by design (config only FILLS unset env vars),
109
+ # but the file itself is validated strictly here: unknown keys/sections and
110
+ # wrong types fail fast at startup with a pointed message instead of surfacing
111
+ # later as an indirect runtime symptom (a typo'd port, a bad URL …).
112
+
113
+
114
+ class _CfgModel(BaseModel):
115
+ """Base: forbid unknown keys so typos become config errors, not no-ops."""
116
+
117
+ model_config = ConfigDict(extra="forbid")
118
+
119
+
120
+ class _Upstream(_CfgModel):
121
+ url: AnyHttpUrl | None = None
122
+ timeout: float | None = Field(default=None, gt=0)
123
+ api_key: str | None = None
124
+ model_map_file: str | None = None
125
+
126
+
127
+ class _Routing(_CfgModel):
128
+ # url presence is enforced with a friendlier message in validate_config.
129
+ url: AnyHttpUrl | None = None
130
+ models: list[str] | str | None = None
131
+ default: bool = False
132
+
133
+
134
+ def _int_not_bool(v: Any) -> Any:
135
+ """Lax int coercion would happily turn TOML ``port = true`` into 1."""
136
+ if isinstance(v, bool):
137
+ raise ValueError("expected an integer, not a boolean")
138
+ return v
139
+
140
+
141
+ class _Loopguard(_CfgModel):
142
+ nudge: int | None = Field(default=None, ge=0)
143
+ abort: int | None = Field(default=None, ge=0)
144
+
145
+
146
+ class _Trace(_CfgModel):
147
+ enabled: bool = False
148
+ file: str | None = None
149
+
150
+
151
+ class _Config(_CfgModel):
152
+ host: str | None = None
153
+ port: int | None = Field(default=None, ge=1, le=65535)
154
+ log_level: Literal["debug", "info", "warning", "error"] | None = None
155
+ default_backend: str | None = None
156
+ upstream: _Upstream | None = None
157
+ routing: dict[str, _Routing] | None = None
158
+ model_map: dict[str, str] | None = None
159
+ loopguard: _Loopguard | None = None
160
+ trace: _Trace | None = None
161
+
162
+ @field_validator("port", mode="before")
163
+ @classmethod
164
+ def _port_not_bool(cls, v: Any) -> Any:
165
+ return _int_not_bool(v)
166
+
167
+
168
+ _TOP_KEYS = tuple(_Config.model_fields)
169
+ _SECTION_FIELDS = {
170
+ "upstream": tuple(_Upstream.model_fields),
171
+ "routing": tuple(_Routing.model_fields),
172
+ "loopguard": tuple(_Loopguard.model_fields),
173
+ "trace": tuple(_Trace.model_fields),
174
+ }
175
+
176
+
177
+ def _did_you_mean(key: str, candidates: tuple[str, ...] | None) -> str:
178
+ if not candidates:
179
+ return ""
180
+ m = get_close_matches(key, list(candidates), n=1, cutoff=0.6)
181
+ return f" — did you mean '{m[0]}'?" if m else ""
182
+
183
+
184
+ def _pydantic_error_lines(exc: ValidationError) -> list[str]:
185
+ """Turn pydantic errors into short, pointed config-error lines."""
186
+ lines: list[str] = []
187
+ for err in exc.errors(include_url=False):
188
+ loc = tuple(str(p) for p in err["loc"])
189
+ if err.get("type") == "extra_forbidden" and loc:
190
+ key = loc[-1]
191
+ if len(loc) == 1:
192
+ lines.append("unknown key '" + key + "' (top level)"
193
+ + _did_you_mean(key, _TOP_KEYS))
194
+ else:
195
+ section = loc[0]
196
+ lines.append("unknown key '" + key + "' in [" + section + "]"
197
+ + _did_you_mean(key, _SECTION_FIELDS.get(section)))
198
+ continue
199
+ msg = str(err.get("msg", "invalid value"))
200
+ prefix = "[" + ".".join(loc or ()) + "] " if loc else ""
201
+ lines.append(prefix + msg)
202
+ return lines
203
+
204
+
205
+ def validate_config(data: dict[str, Any], label: str = "config") -> None:
206
+ """Raise ValueError with every problem found, or return silently."""
207
+ errors: list[str] = []
208
+
209
+ routing = data.get("routing")
210
+ if routing is not None:
211
+ if not isinstance(routing, dict):
212
+ errors.append("[routing] must be a table of [routing.<name>] entries")
213
+ else:
214
+ for name, t in routing.items():
215
+ if not isinstance(t, dict):
216
+ errors.append(f"routing.{name} must be a table")
217
+ continue
218
+ if not t.get("url"):
219
+ errors.append(f"routing.{name} needs at least a url")
220
+ if str(name).upper() in RESERVED_ROUTE_SUFFIXES:
221
+ errors.append(
222
+ f"routing.{name}: reserved route name "
223
+ f"({'/'.join(s.lower() for s in RESERVED_ROUTE_SUFFIXES)})")
224
+ defaults = [str(n) for n, t in routing.items()
225
+ if isinstance(t, dict) and t.get("default")]
226
+ if len(defaults) > 1:
227
+ errors.append(
228
+ "default = true on more than one routing entry: "
229
+ + ", ".join(defaults))
230
+
231
+ try:
232
+ _Config.model_validate(data)
233
+ except ValidationError as exc:
234
+ errors.extend(_pydantic_error_lines(exc))
235
+
236
+ if errors:
237
+ raise ValueError(label + ":\n - " + "\n - ".join(errors))
238
+
239
+
240
+ def _env_set(*names: str) -> bool:
241
+ return any(os.environ.get(n) for n in names)
242
+
243
+
244
+ def _set_if_unset(env_key: str, value: str) -> bool:
245
+ """Set ``env_key`` unless it is already set."""
246
+ if _env_set(env_key) or not value:
247
+ return False
248
+ os.environ[env_key] = value
249
+ return True
250
+
251
+
252
+ def _flag(attr: str, args: Any) -> Any:
253
+ return getattr(args, attr, None) if args is not None else None
254
+
255
+
256
+ def _flag_unset(value: Any) -> bool:
257
+ return value is None or value is False
258
+
259
+
260
+ def _env_backends_present() -> bool:
261
+ """True when the env already declares a *named* routed backend.
262
+
263
+ Settings-valued suffixes under the same namespace (``PTP_UPSTREAM_URL`` /
264
+ ``_DEFAULT`` / ``_API_KEY`` / ``_TIMEOUT``) are single-upstream settings,
265
+ not named backends.
266
+ """
267
+ for var in os.environ:
268
+ if not var.startswith("PTP_UPSTREAM_"):
269
+ continue
270
+ rest = var[len("PTP_UPSTREAM_"):]
271
+ if rest in RESERVED_ROUTE_SUFFIXES:
272
+ continue
273
+ return True
274
+ return False
275
+
276
+
277
+ def _bridge_routing(cfg: dict[str, Any], args: Any, applied: list[str]) -> None:
278
+ """[routing.<name>] + default_backend → PTP_UPSTREAM_* env."""
279
+ flag_backends = list(getattr(args, "upstream", None) or [])
280
+ if flag_backends or _env_backends_present():
281
+ return
282
+ for name, table in (cfg.get("routing") or {}).items():
283
+ if not isinstance(table, dict) or not table.get("url"):
284
+ raise ValueError(f"routing.{name} needs at least a url")
285
+ upper = str(name).upper().replace("-", "_")
286
+ if _set_if_unset(f"PTP_UPSTREAM_{upper}", str(table["url"]).rstrip("/")):
287
+ applied.append(f"PTP_UPSTREAM_{upper}")
288
+ models = table.get("models") or []
289
+ if isinstance(models, str):
290
+ models = [m.strip() for m in models.split(",") if m.strip()]
291
+ if models:
292
+ joined = ",".join(str(m) for m in models)
293
+ if _set_if_unset(f"PTP_UPSTREAM_{upper}_MODELS", joined):
294
+ applied.append(f"PTP_UPSTREAM_{upper}_MODELS")
295
+ flag_default = _flag("default_backend", args)
296
+ if flag_default is None and not _env_set("PTP_DEFAULT_BACKEND"):
297
+ cfg_default = cfg.get("default_backend")
298
+ if cfg_default:
299
+ os.environ["PTP_DEFAULT_BACKEND"] = str(cfg_default)
300
+ applied.append("PTP_DEFAULT_BACKEND")
301
+ else:
302
+ # top-level default_backend unset → accept `default = true` on
303
+ # exactly one [routing.*] entry (the llama-swap-style spelling).
304
+ defaults = [str(n) for n, t in (cfg.get("routing") or {}).items()
305
+ if isinstance(t, dict) and t.get("default")]
306
+ if len(defaults) > 1:
307
+ raise ValueError(
308
+ "default = true on more than one routing entry: "
309
+ + ", ".join(defaults))
310
+ if defaults:
311
+ os.environ["PTP_DEFAULT_BACKEND"] = defaults[0]
312
+ applied.append("PTP_DEFAULT_BACKEND")
313
+
314
+
315
+ def _bridge_settings(cfg: dict[str, Any], args: Any, applied: list[str]) -> None:
316
+ """Top-level + [upstream] + [model_map] + [loopguard] + [trace] settings."""
317
+ if _flag_unset(_flag("host", args)) and "host" in cfg and _set_if_unset(
318
+ "PTP_HOST", str(cfg["host"])):
319
+ applied.append("PTP_HOST")
320
+ if _flag("port", args) is None and "port" in cfg and _set_if_unset(
321
+ "PTP_PORT", str(int(cfg["port"]))):
322
+ applied.append("PTP_PORT")
323
+ if _flag("log_level", args) is None and not _flag("quiet", args):
324
+ raw = str(cfg.get("log_level", "") or "")
325
+ if raw and _set_if_unset("PTP_LOG_LEVEL", raw):
326
+ applied.append("PTP_LOG_LEVEL")
327
+
328
+ up = cfg.get("upstream") or {}
329
+ if _flag_unset(_flag("upstream_url", args)) and up.get("url") and _set_if_unset(
330
+ "PTP_UPSTREAM_URL", str(up["url"]).rstrip("/")):
331
+ applied.append("PTP_UPSTREAM_URL")
332
+ for attr, key, envk in (
333
+ ("upstream_api_key", "api_key", "PTP_UPSTREAM_API_KEY"),
334
+ ("upstream_timeout", "timeout", "PTP_UPSTREAM_TIMEOUT"),
335
+ ):
336
+ if _flag_unset(_flag(attr, args)) and up.get(key) and _set_if_unset(
337
+ envk, str(up[key])):
338
+ applied.append(envk)
339
+
340
+ mm = None
341
+ if _flag_unset(_flag("model_map", args)) and _flag_unset(
342
+ _flag("model_map_file", args)):
343
+ table = cfg.get("model_map")
344
+ if isinstance(table, dict) and table:
345
+ mm = json.dumps({str(k): str(v) for k, v in table.items()})
346
+ elif up.get("model_map_file"):
347
+ mm = "@file:" + str(up["model_map_file"])
348
+ if mm and _set_if_unset("PTP_MODEL_MAP", mm):
349
+ applied.append("PTP_MODEL_MAP")
350
+
351
+ lg = cfg.get("loopguard") or {}
352
+ for attr, key, envk in (
353
+ ("loop_nudge", "nudge", "PTP_LOOP_NUDGE"),
354
+ ("loop_abort", "abort", "PTP_LOOP_ABORT"),
355
+ ):
356
+ if _flag(attr, args) is None and lg.get(key) is not None and _set_if_unset(
357
+ envk, str(int(lg[key]))):
358
+ applied.append(envk)
359
+
360
+ tr = cfg.get("trace") or {}
361
+ if not _flag("trace", args) and tr.get("enabled") and _set_if_unset(
362
+ "PTP_TRACE", "1"):
363
+ applied.append("PTP_TRACE")
364
+ if tr.get("file") and not _env_set("PTP_TRACE_FILE"):
365
+ os.environ["PTP_TRACE_FILE"] = str(tr["file"])
366
+ applied.append("PTP_TRACE_FILE")
367
+
368
+
369
+ def apply_config(args: Any, *, serve: bool,
370
+ routing: bool = False) -> tuple[Path | None, dict[str, Any],
371
+ dict[str, Any]]:
372
+ """Bridge the resolved config into env.
373
+
374
+ Returns ``(path, cfg, info)`` where ``info`` carries ``applied``
375
+ (env keys set by the bridge).
376
+ """
377
+ path = find_config(getattr(args, "config", None) if args is not None else None)
378
+ cfg = load_config(path)
379
+ info: dict[str, Any] = {"applied": []}
380
+ if not cfg:
381
+ return path, cfg, info
382
+ applied: list[str] = info["applied"]
383
+ _bridge_settings(cfg, args, applied)
384
+ if routing:
385
+ _bridge_routing(cfg, args, applied)
386
+ return path, cfg, info
387
+
388
+
389
+ def config_help_line() -> str:
390
+ """Short discovery summary for --help text."""
391
+ home = Path.home() / ".config" / "proxytool" / "config.toml"
392
+ return (f"TOML config file (PTP_CONFIG; default search: ./proxytool.toml, "
393
+ f"./.proxytool.toml, {home})")
394
+
395
+
396
+ # ── diagnostics (`proxytool config print|check`) ────────────────────────────
397
+
398
+ _SEARCH_DETAIL = {
399
+ "PTP_HOST": "host",
400
+ "PTP_PORT": "port",
401
+ "PTP_LOG_LEVEL": "log_level",
402
+ "PTP_UPSTREAM_URL": "upstream.url",
403
+ "PTP_UPSTREAM_API_KEY": "upstream.api_key",
404
+ "PTP_UPSTREAM_TIMEOUT": "upstream.timeout",
405
+ "PTP_MODEL_MAP": "model_map",
406
+ "PTP_LOOP_NUDGE": "loopguard.nudge",
407
+ "PTP_LOOP_ABORT": "loopguard.abort",
408
+ "PTP_TRACE": "trace.enabled",
409
+ "PTP_TRACE_FILE": "trace.file",
410
+ }
411
+
412
+
413
+ def _search_candidates(explicit: str | None) -> list[Path]:
414
+ """The paths find_config consults, in order (for diagnostic display)."""
415
+ if explicit:
416
+ return [Path(explicit).expanduser()]
417
+ env = os.environ.get("PTP_CONFIG")
418
+ if env:
419
+ return [Path(env).expanduser()]
420
+ xdg = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
421
+ cwd = Path.cwd()
422
+ return [cwd / name for name in CWD_NAMES] + [Path(xdg) / "proxytool" / "config.toml"]
423
+
424
+
425
+ def _detail_for(key: str) -> str:
426
+ """Which config-file key would supply ``env key`` (for sourced rows)."""
427
+ if key in _SEARCH_DETAIL:
428
+ return _SEARCH_DETAIL[key]
429
+ m = re.fullmatch(r"PTP_UPSTREAM_([A-Z0-9_]+)_MODELS", key)
430
+ if m:
431
+ return f"routing.{m.group(1).lower()}.models"
432
+ m = re.fullmatch(r"PTP_UPSTREAM_([A-Z0-9_]+)", key)
433
+ if m:
434
+ return f"routing.{m.group(1).lower()}.url"
435
+ return ""
436
+
437
+
438
+ def _relevant_env_keys(keys: Any) -> list[str]:
439
+ """Env vars the bridge could read or fill — diagnostic row candidates."""
440
+ out = []
441
+ for key in keys:
442
+ if key == "PTP_CONFIG":
443
+ continue # it *selects* the file; shown in the discovery block
444
+ if key.startswith("PTP_"):
445
+ out.append(key)
446
+ return sorted(out)
447
+
448
+
449
+ def describe_config(args: Any | None, *, routing: bool = True,
450
+ serve: bool = True) -> dict[str, Any]:
451
+ """Dry-run the config bridge: resolve, validate, attribute — no env edits.
452
+
453
+ Returns a JSON-friendly dict with the discovered file, the candidates
454
+ consulted, validation errors when any, and one row per relevant env var
455
+ (PTP_*) with its value and where it would come from at serve/start time:
456
+ ``config file`` (bridged now) or ``env (pre-existing)`` (already set, so
457
+ the bridge would leave it). Flags are intentionally not shown —
458
+ precedence is CLI flag > env > config file > default, and this
459
+ subcommand takes no flags.
460
+ """
461
+ explicit = getattr(args, "config", None) if args is not None else None
462
+ candidates = [str(p) for p in _search_candidates(explicit)]
463
+ snapshot = dict(os.environ)
464
+ out: dict[str, Any] = {
465
+ "file": None, "searched": candidates, "valid": True, "errors": [],
466
+ "applied": [], "settings": [],
467
+ "precedence": "CLI flag > env var > config file > built-in default",
468
+ }
469
+ in_file = next((c for c in candidates if Path(c).is_file()), None)
470
+ try:
471
+ path = find_config(explicit)
472
+ if path is not None:
473
+ load_config(path) # validates strictly (unknown keys, types, ...)
474
+ _path, _cfg, _info = apply_config(args, serve=serve, routing=routing)
475
+ changed = {k: v for k, v in os.environ.items() if snapshot.get(k) != v}
476
+ except ValueError as exc:
477
+ out["valid"] = False
478
+ out["errors"] = str(exc).splitlines()
479
+ out["file"] = in_file
480
+ return out
481
+ finally:
482
+ os.environ.clear()
483
+ os.environ.update(snapshot)
484
+
485
+ rows: list[dict[str, str]] = []
486
+ for key in sorted(set(changed) | set(_relevant_env_keys(snapshot.keys()))):
487
+ if key in changed:
488
+ src, value = "config file", changed[key]
489
+ else:
490
+ src, value = "env (pre-existing)", snapshot[key]
491
+ detail = _detail_for(key) if src == "config file" else ""
492
+ rows.append({"key": key, "value": value, "source": src, "detail": detail})
493
+ out["file"] = str(path) if path is not None else None
494
+ out["applied"] = sorted(changed)
495
+ out["settings"] = rows
496
+ return out
proxytool/loopguard.py ADDED
@@ -0,0 +1,92 @@
1
+ """LoopGuard: detect and break identical-prompt repetition loops.
2
+
3
+ When a client re-prompts with a byte-identical message history (after
4
+ sanitization), llama-server tends to replay the same continuation — a loop.
5
+ The proxytool counters this in two steps:
6
+
7
+ 1. **nudge** after ``nudge_after`` identical consecutive requests: append a
8
+ short user message asking the model to finish the turn and stop.
9
+ 2. **abort** after ``abort_after``: stop forwarding upstream entirely and
10
+ return a clean, short OpenAI-style completion telling the user how to
11
+ break the loop (rephrase or start a new session). This bounds the loop
12
+ at ``abort_after`` requests instead of 100+.
13
+
14
+ State is per-process and deliberately simple: a counter keyed by the request
15
+ fingerprint, reset whenever a *different* request (a new user message or
16
+ changed history) arrives. Long-identity loops are the only failure mode we
17
+ guard against.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import hashlib
23
+ import json
24
+ from dataclasses import dataclass
25
+ from typing import Any
26
+
27
+ NUDGE_TEXT = (
28
+ "[system-reminder] You have produced the same answer several times in a row. "
29
+ "That previous answer is already recorded. Do NOT repeat it. Either call a "
30
+ "tool you have not called yet, or give a short final answer that completes "
31
+ "the user's request, then stop."
32
+ )
33
+
34
+ ABORT_TEXT = (
35
+ "**LoopGuard: request aborted after {count} identical attempts.**\n\n"
36
+ "The model kept producing the same reply for the same prompt, so the "
37
+ "proxytool stopped forwarding to avoid burning tokens. To continue:\n"
38
+ "- Rephrase your message (even slightly), or\n"
39
+ "- Start a new session.\n\n"
40
+ "This is a safety cut-off, not a model answer."
41
+ )
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class GuardDecision:
46
+ action: str # "none" | "nudge" | "abort"
47
+ count: int
48
+
49
+
50
+ class LoopGuard:
51
+ """Track consecutive identical requests per conversation fingerprint."""
52
+
53
+ def __init__(self, nudge_after: int = 3, abort_after: int = 6) -> None:
54
+ self.nudge_after = nudge_after
55
+ self.abort_after = abort_after
56
+ self._state: dict[str, int] = {}
57
+ self._last_key: str | None = None
58
+
59
+ # -- fingerprinting ----------------------------------------------------
60
+ @staticmethod
61
+ def request_key(payload: dict[str, Any]) -> str:
62
+ """Stable fingerprint of the *conversation* the model will see."""
63
+ return hashlib.sha256(
64
+ json.dumps(
65
+ {
66
+ "messages": payload.get("messages"),
67
+ "tools": payload.get("tools"),
68
+ },
69
+ sort_keys=True,
70
+ separators=(",", ":"),
71
+ ).encode()
72
+ ).hexdigest()
73
+
74
+ # -- decision flow ------------------------------------------------------
75
+ def check(self, key: str) -> GuardDecision:
76
+ """Record the incoming request and return the action to take."""
77
+ if key != self._last_key:
78
+ # A genuinely new request arrived: reset.
79
+ self._last_key = key
80
+ self._state = {key: 1}
81
+ return GuardDecision("none", 1)
82
+ count = self._state.get(key, 0) + 1
83
+ self._state[key] = count
84
+ if self.abort_after and count >= self.abort_after:
85
+ return GuardDecision("abort", count)
86
+ if self.nudge_after and count >= self.nudge_after:
87
+ return GuardDecision("nudge", count)
88
+ return GuardDecision("none", count)
89
+
90
+ def reset(self) -> None:
91
+ self._last_key = None
92
+ self._state.clear()