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/config.py ADDED
@@ -0,0 +1,488 @@
1
+ """TOML config file support for the ``proxytool`` CLI.
2
+
3
+ Precedence for every setting:
4
+
5
+ CLI flag > environment (LTP_* / LMW_*) > config file > default
6
+
7
+ Config discovery: ``--config PATH``, then ``LTP_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 ``LTP_*`` 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"
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 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
+ if sys.version_info >= (3, 11):
59
+ import tomllib
60
+ else: # Python 3.10 — tomli is the stdlib tomllib backport
61
+ import tomli as tomllib
62
+
63
+ CWD_NAMES = ("proxytool.toml", ".proxytool.toml")
64
+
65
+
66
+ def find_config(explicit: str | None = None) -> Path | None:
67
+ """Resolve the active config file path, or None when none exists."""
68
+ if explicit:
69
+ p = Path(explicit). expanduser()
70
+ if not p.is_file():
71
+ raise ValueError(f"config file not found: {explicit}")
72
+ return p
73
+ env = os.environ.get("LTP_CONFIG")
74
+ if env:
75
+ p = Path(env).expanduser()
76
+ if not p.is_file():
77
+ raise ValueError(f"LTP_CONFIG points to a missing file: {env}")
78
+ return p
79
+ xdg = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
80
+ cwd = Path.cwd()
81
+ for cand in (cwd / CWD_NAMES[0], cwd / CWD_NAMES[1],
82
+ Path(xdg) / "proxytool" / "config.toml"):
83
+ if cand.is_file():
84
+ return cand
85
+ return None
86
+
87
+
88
+ def load_config(path: Path | None) -> dict[str, Any]:
89
+ """Parse the TOML config into a dict; {} when path is None."""
90
+ if path is None:
91
+ return {}
92
+ try:
93
+ with path.open("rb") as f:
94
+ data = tomllib.load(f)
95
+ except OSError as e:
96
+ raise ValueError(f"cannot read config {path}: {e}") from e
97
+ except tomllib.TOMLDecodeError as e:
98
+ raise ValueError(f"invalid TOML in {path}: {e}") from e
99
+ if not isinstance(data, dict):
100
+ raise ValueError(f"config {path}: top level must be a table")
101
+ validate_config(data, label=str(path))
102
+ return data
103
+
104
+
105
+ # ── strict schema validation ─────────────────────────────────────────────────
106
+ # The bridge below is permissive by design (config only FILLS unset env vars),
107
+ # but the file itself is validated strictly here: unknown keys/sections and
108
+ # wrong types fail fast at startup with a pointed message instead of surfacing
109
+ # later as an indirect runtime symptom (a typo'd port, a bad URL …).
110
+
111
+
112
+ class _CfgModel(BaseModel):
113
+ """Base: forbid unknown keys so typos become config errors, not no-ops."""
114
+
115
+ model_config = ConfigDict(extra="forbid")
116
+
117
+
118
+ class _Upstream(_CfgModel):
119
+ url: AnyHttpUrl | None = None
120
+ timeout: float | None = Field(default=None, gt=0)
121
+ api_key: str | None = None
122
+ model_map_file: str | None = None
123
+
124
+
125
+ class _Routing(_CfgModel):
126
+ # url presence is enforced with a friendlier message in validate_config.
127
+ url: AnyHttpUrl | None = None
128
+ models: list[str] | str | None = None
129
+ default: bool = False
130
+
131
+
132
+ def _int_not_bool(v: Any) -> Any:
133
+ """Lax int coercion would happily turn TOML ``port = true`` into 1."""
134
+ if isinstance(v, bool):
135
+ raise ValueError("expected an integer, not a boolean")
136
+ return v
137
+
138
+
139
+ class _Loopguard(_CfgModel):
140
+ nudge: int | None = Field(default=None, ge=0)
141
+ abort: int | None = Field(default=None, ge=0)
142
+
143
+
144
+ class _Trace(_CfgModel):
145
+ enabled: bool = False
146
+ file: str | None = None
147
+
148
+
149
+ class _Config(_CfgModel):
150
+ host: str | None = None
151
+ port: int | None = Field(default=None, ge=1, le=65535)
152
+ log_level: Literal["debug", "info", "warning", "error"] | None = None
153
+ default_backend: str | None = None
154
+ upstream: _Upstream | None = None
155
+ routing: dict[str, _Routing] | None = None
156
+ model_map: dict[str, str] | None = None
157
+ loopguard: _Loopguard | None = None
158
+ trace: _Trace | None = None
159
+
160
+ @field_validator("port", mode="before")
161
+ @classmethod
162
+ def _port_not_bool(cls, v: Any) -> Any:
163
+ return _int_not_bool(v)
164
+
165
+
166
+ _TOP_KEYS = tuple(_Config.model_fields)
167
+ _SECTION_FIELDS = {
168
+ "upstream": tuple(_Upstream.model_fields),
169
+ "routing": tuple(_Routing.model_fields),
170
+ "loopguard": tuple(_Loopguard.model_fields),
171
+ "trace": tuple(_Trace.model_fields),
172
+ }
173
+
174
+
175
+ def _did_you_mean(key: str, candidates: tuple[str, ...] | None) -> str:
176
+ if not candidates:
177
+ return ""
178
+ m = get_close_matches(key, list(candidates), n=1, cutoff=0.6)
179
+ return f" — did you mean '{m[0]}'?" if m else ""
180
+
181
+
182
+ def _pydantic_error_lines(exc: ValidationError) -> list[str]:
183
+ """Turn pydantic errors into short, pointed config-error lines."""
184
+ lines: list[str] = []
185
+ for err in exc.errors(include_url=False):
186
+ loc = tuple(str(p) for p in err["loc"])
187
+ if err.get("type") == "extra_forbidden" and loc:
188
+ key = loc[-1]
189
+ if len(loc) == 1:
190
+ lines.append("unknown key '" + key + "' (top level)"
191
+ + _did_you_mean(key, _TOP_KEYS))
192
+ else:
193
+ section = loc[0]
194
+ lines.append("unknown key '" + key + "' in [" + section + "]"
195
+ + _did_you_mean(key, _SECTION_FIELDS.get(section)))
196
+ continue
197
+ msg = str(err.get("msg", "invalid value"))
198
+ prefix = "[" + ".".join(loc or ()) + "] " if loc else ""
199
+ lines.append(prefix + msg)
200
+ return lines
201
+
202
+
203
+ def validate_config(data: dict[str, Any], label: str = "config") -> None:
204
+ """Raise ValueError with every problem found, or return silently."""
205
+ errors: list[str] = []
206
+
207
+ routing = data.get("routing")
208
+ if routing is not None:
209
+ if not isinstance(routing, dict):
210
+ errors.append("[routing] must be a table of [routing.<name>] entries")
211
+ else:
212
+ for name, t in routing.items():
213
+ if not isinstance(t, dict):
214
+ errors.append(f"routing.{name} must be a table")
215
+ continue
216
+ if not t.get("url"):
217
+ errors.append(f"routing.{name} needs at least a url")
218
+ defaults = [str(n) for n, t in routing.items()
219
+ if isinstance(t, dict) and t.get("default")]
220
+ if len(defaults) > 1:
221
+ errors.append(
222
+ "default = true on more than one routing entry: "
223
+ + ", ".join(defaults))
224
+
225
+ try:
226
+ _Config.model_validate(data)
227
+ except ValidationError as exc:
228
+ errors.extend(_pydantic_error_lines(exc))
229
+
230
+ if errors:
231
+ raise ValueError(label + ":\n - " + "\n - ".join(errors))
232
+
233
+
234
+ def _env_set(*names: str) -> bool:
235
+ return any(os.environ.get(n) for n in names)
236
+
237
+
238
+ def _set_if_unset(env_key: str, value: str) -> bool:
239
+ """Set ``env_key`` unless it (or its legacy LMW_ twin) is already set."""
240
+ legacy = "LMW_" + env_key[len("LTP_"):]
241
+ if _env_set(env_key, legacy) or not value:
242
+ return False
243
+ os.environ[env_key] = value
244
+ return True
245
+
246
+
247
+ def _flag(attr: str, args: Any) -> Any:
248
+ return getattr(args, attr, None) if args is not None else None
249
+
250
+
251
+ def _flag_unset(value: Any) -> bool:
252
+ return value is None or value is False
253
+
254
+
255
+ def _env_backends_present() -> bool:
256
+ """True when the env already declares a *named* routed backend.
257
+
258
+ ``LTP_UPSTREAM_URL`` (the standalone single-backend URL) and the DEFAULT/
259
+ _MODELS helpers are not named backends.
260
+ """
261
+ for var in os.environ:
262
+ if not var.startswith("LTP_UPSTREAM_"):
263
+ continue
264
+ rest = var[len("LTP_UPSTREAM_"):]
265
+ if rest.endswith("_MODELS") or rest in ("URL", "DEFAULT"):
266
+ continue
267
+ return True
268
+ return False
269
+
270
+
271
+ def _bridge_routing(cfg: dict[str, Any], args: Any, applied: list[str]) -> None:
272
+ """[routing.<name>] + default_backend → LTP_UPSTREAM_* env."""
273
+ flag_backends = list(getattr(args, "upstream", None) or [])
274
+ if flag_backends or _env_backends_present():
275
+ return
276
+ for name, table in (cfg.get("routing") or {}).items():
277
+ if not isinstance(table, dict) or not table.get("url"):
278
+ raise ValueError(f"routing.{name} needs at least a url")
279
+ upper = str(name).upper().replace("-", "_")
280
+ if _set_if_unset(f"LTP_UPSTREAM_{upper}", str(table["url"]).rstrip("/")):
281
+ applied.append(f"LTP_UPSTREAM_{upper}")
282
+ models = table.get("models") or []
283
+ if isinstance(models, str):
284
+ models = [m.strip() for m in models.split(",") if m.strip()]
285
+ if models:
286
+ joined = ",".join(str(m) for m in models)
287
+ if _set_if_unset(f"LTP_UPSTREAM_{upper}_MODELS", joined):
288
+ applied.append(f"LTP_UPSTREAM_{upper}_MODELS")
289
+ flag_default = _flag("default_backend", args)
290
+ if flag_default is None and not _env_set("LTP_DEFAULT_BACKEND"):
291
+ cfg_default = cfg.get("default_backend")
292
+ if cfg_default:
293
+ os.environ["LTP_DEFAULT_BACKEND"] = str(cfg_default)
294
+ applied.append("LTP_DEFAULT_BACKEND")
295
+ else:
296
+ # top-level default_backend unset → accept `default = true` on
297
+ # exactly one [routing.*] entry (the llama-swap-style spelling).
298
+ defaults = [str(n) for n, t in (cfg.get("routing") or {}).items()
299
+ if isinstance(t, dict) and t.get("default")]
300
+ if len(defaults) > 1:
301
+ raise ValueError(
302
+ "default = true on more than one routing entry: "
303
+ + ", ".join(defaults))
304
+ if defaults:
305
+ os.environ["LTP_DEFAULT_BACKEND"] = defaults[0]
306
+ applied.append("LTP_DEFAULT_BACKEND")
307
+
308
+
309
+ def _bridge_settings(cfg: dict[str, Any], args: Any, applied: list[str]) -> None:
310
+ """Top-level + [upstream] + [model_map] + [loopguard] + [trace] settings."""
311
+ if _flag_unset(_flag("host", args)) and "host" in cfg and _set_if_unset(
312
+ "LTP_HOST", str(cfg["host"])):
313
+ applied.append("LTP_HOST")
314
+ if _flag("port", args) is None and "port" in cfg and _set_if_unset(
315
+ "LTP_PORT", str(int(cfg["port"]))):
316
+ applied.append("LTP_PORT")
317
+ if _flag("log_level", args) is None and not _flag("quiet", args):
318
+ raw = str(cfg.get("log_level", "") or "")
319
+ if raw and _set_if_unset("LTP_LOG_LEVEL", raw):
320
+ applied.append("LTP_LOG_LEVEL")
321
+
322
+ up = cfg.get("upstream") or {}
323
+ if _flag_unset(_flag("upstream_url", args)) and up.get("url") and _set_if_unset(
324
+ "LTP_UPSTREAM_URL", str(up["url"]).rstrip("/")):
325
+ applied.append("LTP_UPSTREAM_URL")
326
+ for attr, key, envk in (
327
+ ("upstream_api_key", "api_key", "LTP_UPSTREAM_API_KEY"),
328
+ ("upstream_timeout", "timeout", "LTP_UPSTREAM_TIMEOUT"),
329
+ ):
330
+ if _flag_unset(_flag(attr, args)) and up.get(key) and _set_if_unset(
331
+ envk, str(up[key])):
332
+ applied.append(envk)
333
+
334
+ mm = None
335
+ if _flag_unset(_flag("model_map", args)) and _flag_unset(
336
+ _flag("model_map_file", args)):
337
+ table = cfg.get("model_map")
338
+ if isinstance(table, dict) and table:
339
+ mm = json.dumps({str(k): str(v) for k, v in table.items()})
340
+ elif up.get("model_map_file"):
341
+ mm = "@file:" + str(up["model_map_file"])
342
+ if mm and _set_if_unset("LTP_MODEL_MAP", mm):
343
+ applied.append("LTP_MODEL_MAP")
344
+
345
+ lg = cfg.get("loopguard") or {}
346
+ for attr, key, envk in (
347
+ ("loop_nudge", "nudge", "LTP_LOOP_NUDGE"),
348
+ ("loop_abort", "abort", "LTP_LOOP_ABORT"),
349
+ ):
350
+ if _flag(attr, args) is None and lg.get(key) is not None and _set_if_unset(
351
+ envk, str(int(lg[key]))):
352
+ applied.append(envk)
353
+
354
+ tr = cfg.get("trace") or {}
355
+ if not _flag("trace", args) and tr.get("enabled") and _set_if_unset(
356
+ "LTP_TRACE", "1"):
357
+ applied.append("LTP_TRACE")
358
+ if tr.get("file") and not _env_set("LMW_TRACE_FILE"):
359
+ os.environ["LMW_TRACE_FILE"] = str(tr["file"])
360
+ applied.append("LMW_TRACE_FILE")
361
+
362
+
363
+ def apply_config(args: Any, *, serve: bool,
364
+ routing: bool = False) -> tuple[Path | None, dict[str, Any],
365
+ dict[str, Any]]:
366
+ """Bridge the resolved config into env.
367
+
368
+ Returns ``(path, cfg, info)`` where ``info`` carries ``applied``
369
+ (env keys set by the bridge).
370
+ """
371
+ path = find_config(getattr(args, "config", None) if args is not None else None)
372
+ cfg = load_config(path)
373
+ info: dict[str, Any] = {"applied": []}
374
+ if not cfg:
375
+ return path, cfg, info
376
+ applied: list[str] = info["applied"]
377
+ _bridge_settings(cfg, args, applied)
378
+ if routing:
379
+ _bridge_routing(cfg, args, applied)
380
+ return path, cfg, info
381
+
382
+
383
+ def config_help_line() -> str:
384
+ """Short discovery summary for --help text."""
385
+ home = Path.home() / ".config" / "proxytool" / "config.toml"
386
+ return (f"TOML config file (LTP_CONFIG; default search: ./proxytool.toml, "
387
+ f"./.proxytool.toml, {home})")
388
+ # ── diagnostics (`proxytool config print|check`) ────────────────────────────
389
+
390
+ _SEARCH_DETAIL = {
391
+ "LTP_HOST": "host",
392
+ "LTP_PORT": "port",
393
+ "LTP_LOG_LEVEL": "log_level",
394
+ "LTP_UPSTREAM_URL": "upstream.url",
395
+ "LTP_UPSTREAM_API_KEY": "upstream.api_key",
396
+ "LTP_UPSTREAM_TIMEOUT": "upstream.timeout",
397
+ "LTP_MODEL_MAP": "model_map",
398
+ "LTP_LOOP_NUDGE": "loopguard.nudge",
399
+ "LTP_LOOP_ABORT": "loopguard.abort",
400
+ "LTP_TRACE": "trace.enabled",
401
+ "LMW_TRACE_FILE": "trace.file",
402
+ }
403
+
404
+
405
+ def _search_candidates(explicit: str | None) -> list[Path]:
406
+ """The paths find_config consults, in order (for diagnostic display)."""
407
+ if explicit:
408
+ return [Path(explicit).expanduser()]
409
+ env = os.environ.get("LTP_CONFIG")
410
+ if env:
411
+ return [Path(env).expanduser()]
412
+ xdg = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
413
+ cwd = Path.cwd()
414
+ return [cwd / name for name in CWD_NAMES] + [Path(xdg) / "proxytool" / "config.toml"]
415
+
416
+
417
+ def _detail_for(key: str) -> str:
418
+ """Which config-file key would supply ``env key`` (for sourced rows)."""
419
+ if key in _SEARCH_DETAIL:
420
+ return _SEARCH_DETAIL[key]
421
+ m = re.fullmatch(r"LTP_UPSTREAM_([A-Z0-9_]+)_MODELS", key)
422
+ if m:
423
+ return f"routing.{m.group(1).lower()}.models"
424
+ m = re.fullmatch(r"LTP_UPSTREAM_([A-Z0-9_]+)", key)
425
+ if m:
426
+ return f"routing.{m.group(1).lower()}.url"
427
+ return ""
428
+
429
+
430
+ def _relevant_env_keys(keys: Any) -> list[str]:
431
+ """Env vars the bridge could read or fill — diagnostic row candidates."""
432
+ out = []
433
+ for key in keys:
434
+ if key == "LTP_CONFIG":
435
+ continue # it *selects* the file; shown in the discovery block
436
+ if key.startswith(("LTP_", "LMW_")):
437
+ out.append(key)
438
+ return sorted(out)
439
+
440
+
441
+ def describe_config(args: Any | None, *, routing: bool = True,
442
+ serve: bool = True) -> dict[str, Any]:
443
+ """Dry-run the config bridge: resolve, validate, attribute — no env edits.
444
+
445
+ Returns a JSON-friendly dict with the discovered file, the candidates
446
+ consulted, validation errors when any, and one row per relevant env var
447
+ (LTP_/LMW_) with its value and where it would come from at serve/start
448
+ time: ``config file`` (bridged now) or ``env (pre-existing)`` (already
449
+ set, so the bridge would leave it). Flags are intentionally not shown —
450
+ precedence is CLI flag > env > config file > default, and this
451
+ subcommand takes no flags.
452
+ """
453
+ explicit = getattr(args, "config", None) if args is not None else None
454
+ candidates = [str(p) for p in _search_candidates(explicit)]
455
+ snapshot = dict(os.environ)
456
+ out: dict[str, Any] = {
457
+ "file": None, "searched": candidates, "valid": True, "errors": [],
458
+ "applied": [], "settings": [],
459
+ "precedence": "CLI flag > env var > config file > built-in default",
460
+ }
461
+ in_file = next((c for c in candidates if Path(c).is_file()), None)
462
+ try:
463
+ path = find_config(explicit)
464
+ if path is not None:
465
+ load_config(path) # validates strictly (unknown keys, types, ...)
466
+ _path, _cfg, _info = apply_config(args, serve=serve, routing=routing)
467
+ changed = {k: v for k, v in os.environ.items() if snapshot.get(k) != v}
468
+ except ValueError as exc:
469
+ out["valid"] = False
470
+ out["errors"] = str(exc).splitlines()
471
+ out["file"] = in_file
472
+ return out
473
+ finally:
474
+ os.environ.clear()
475
+ os.environ.update(snapshot)
476
+
477
+ rows: list[dict[str, str]] = []
478
+ for key in sorted(set(changed) | set(_relevant_env_keys(snapshot.keys()))):
479
+ if key in changed:
480
+ src, value = "config file", changed[key]
481
+ else:
482
+ src, value = "env (pre-existing)", snapshot[key]
483
+ detail = _detail_for(key) if src == "config file" else ""
484
+ rows.append({"key": key, "value": value, "source": src, "detail": detail})
485
+ out["file"] = str(path) if path is not None else None
486
+ out["applied"] = sorted(changed)
487
+ out["settings"] = rows
488
+ 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 middleware 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
+ "middleware 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()