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/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()
|