agentcap-cli 0.1.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.
agentcap/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """AgentCap - local, serverless spend-cap proxy for AI agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ __all__ = ["__version__"]
agentcap/alerts.py ADDED
@@ -0,0 +1,312 @@
1
+ """Alert fan-out for spend and safety events.
2
+
3
+ Every alert goes to the terminal (stderr, red). Pro users can additionally
4
+ receive webhook and Telegram notifications; those are delivered from a
5
+ background thread so a slow endpoint never blocks the proxy.
6
+
7
+ Payloads are built from a strict field whitelist and pass through
8
+ :func:`redact` before leaving the process, so prompts, response text and
9
+ credentials can never be included.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import re
16
+ import sys
17
+ import threading
18
+ from datetime import datetime, timezone
19
+ from typing import Any, Callable, Iterable, Mapping, TextIO
20
+
21
+ import httpx
22
+
23
+ RED = "\x1b[31m"
24
+ YELLOW = "\x1b[33m"
25
+ RESET = "\x1b[0m"
26
+
27
+ TERMINAL_EVENTS = frozenset(
28
+ {
29
+ "cap_threshold",
30
+ "cap_exceeded",
31
+ "loop_detected",
32
+ "usage_missing",
33
+ "overshoot",
34
+ "output_clamped",
35
+ "request_too_large",
36
+ }
37
+ )
38
+
39
+ # Alerts that must never be repeated for one run (a run has one kill and one
40
+ # budget-clamp notice, no matter how many later requests are rejected).
41
+ ONCE_EVENTS = frozenset({"cap_exceeded", "loop_detected", "output_clamped"})
42
+
43
+ # Only these fields may appear in an alert payload. Anything else is dropped.
44
+ ALLOWED_FIELDS = frozenset(
45
+ {
46
+ "type",
47
+ "reason",
48
+ "scope",
49
+ "cap_usd",
50
+ "spent_usd",
51
+ "needed_usd",
52
+ "cost_usd",
53
+ "reserved_usd",
54
+ "run_spent_usd",
55
+ "charged_usd",
56
+ "model",
57
+ "provider",
58
+ "threshold",
59
+ "window_s",
60
+ "request_hash",
61
+ "run_id",
62
+ "allowed_output_tokens",
63
+ "remaining_usd",
64
+ }
65
+ )
66
+
67
+ _SECRET_PATTERNS = (
68
+ re.compile(r"sk-[A-Za-z0-9_\-]{4,}"),
69
+ re.compile(r"(?i)bearer\s+[A-Za-z0-9._\-]+"),
70
+ re.compile(r"(?i)(x-goog-api-key|api[_-]?key|authorization)\s*[:=]\s*\S+"),
71
+ )
72
+
73
+
74
+ def redact(text: str) -> str:
75
+ """Scrub credential-shaped substrings from a string."""
76
+
77
+ if not isinstance(text, str):
78
+ return text
79
+ result = text
80
+ for pattern in _SECRET_PATTERNS:
81
+ result = pattern.sub("[redacted]", result)
82
+ return result
83
+
84
+
85
+ def sanitize(payload: Mapping[str, Any]) -> dict[str, Any]:
86
+ """Keep only whitelisted fields and redact every string value."""
87
+
88
+ clean: dict[str, Any] = {}
89
+ for key, value in payload.items():
90
+ if key not in ALLOWED_FIELDS:
91
+ continue
92
+ if isinstance(value, str):
93
+ clean[key] = redact(value)
94
+ elif isinstance(value, (int, float, bool)) or value is None:
95
+ clean[key] = value
96
+ else:
97
+ clean[key] = redact(str(value))
98
+ return clean
99
+
100
+
101
+ def format_alert(event: Mapping[str, Any]) -> str:
102
+ """One-line, human-readable terminal message for an event."""
103
+
104
+ kind = str(event.get("type", "event"))
105
+ run = event.get("run_id")
106
+ prefix = "agentcap" + (f" [{redact(str(run))}]" if run else "")
107
+
108
+ if kind == "cap_threshold":
109
+ threshold = event.get("threshold", "?")
110
+ return (
111
+ f"{prefix}: {threshold}% of the ${event.get('cap_usd', '?')} run cap "
112
+ f"used (${event.get('spent_usd', '?')})."
113
+ )
114
+ if kind == "cap_exceeded":
115
+ return (
116
+ f"{prefix}: RUN CAP HIT (scope={event.get('scope', 'run')}, "
117
+ f"cap=${event.get('cap_usd', '?')}, spent=${event.get('spent_usd', '?')}). "
118
+ "Stopping the agent."
119
+ )
120
+ if kind == "loop_detected":
121
+ return (
122
+ f"{prefix}: runaway loop detected (identical request "
123
+ f"{event.get('threshold', '?')} times within "
124
+ f"{event.get('window_s', '?')}s). Stopping the agent."
125
+ )
126
+ if kind == "usage_missing":
127
+ return (
128
+ f"{prefix}: upstream returned no usage; charged the full "
129
+ f"reservation (${event.get('charged_usd', '?')}) to stay conservative."
130
+ )
131
+ if kind == "overshoot":
132
+ return (
133
+ f"{prefix}: a request settled above its reservation "
134
+ f"(${event.get('cost_usd', '?')} > ${event.get('reserved_usd', '?')}); "
135
+ "the cap is still enforced on the next request."
136
+ )
137
+ if kind == "output_clamped":
138
+ return (
139
+ f"{prefix}: budget low, limiting output to "
140
+ f"{event.get('allowed_output_tokens', '?')} tokens per request"
141
+ )
142
+ if kind == "request_too_large":
143
+ return (
144
+ f"{prefix}: a request needs ${event.get('needed_usd', '?')} but only "
145
+ f"${event.get('remaining_usd', event.get('cap_usd', '?'))} of budget "
146
+ "remains; not sent. Raise the cap or enable output clamping."
147
+ )
148
+ return f"{prefix}: {kind}"
149
+
150
+
151
+ class AlertDispatcher:
152
+ """Terminal + optional webhook/Telegram alert fan-out.
153
+
154
+ ``transport`` is an ``httpx`` transport, injected in tests so no real
155
+ network call is ever made.
156
+ """
157
+
158
+ def __init__(
159
+ self,
160
+ *,
161
+ features: Iterable[str] | None = None,
162
+ webhook_url: str | None = None,
163
+ telegram_token: str | None = None,
164
+ telegram_chat_id: str | None = None,
165
+ transport: httpx.BaseTransport | None = None,
166
+ stderr: TextIO | None = None,
167
+ clock: Callable[[], datetime] | None = None,
168
+ background: bool = True,
169
+ timeout_s: float = 5.0,
170
+ ) -> None:
171
+ self.features = frozenset(features or ())
172
+ self.webhook_url = webhook_url or None
173
+ self.telegram_token = telegram_token or None
174
+ self.telegram_chat_id = telegram_chat_id or None
175
+ self.transport = transport
176
+ self.stderr = stderr if stderr is not None else sys.stderr
177
+ self.clock = clock or (lambda: datetime.now(timezone.utc))
178
+ self.background = background
179
+ self.timeout_s = float(timeout_s)
180
+ self._threads: list[threading.Thread] = []
181
+ self._lock = threading.Lock()
182
+ self._seen_once: set[tuple[str, object]] = set()
183
+
184
+ # --------------------------------------------------------------- sending
185
+ def __call__(self, event: Mapping[str, Any]) -> None:
186
+ self.emit(event)
187
+
188
+ def emit(self, event: Mapping[str, Any]) -> None:
189
+ if not isinstance(event, Mapping):
190
+ return
191
+ kind = str(event.get("type", ""))
192
+ if kind not in TERMINAL_EVENTS:
193
+ # Per-request accounting events stay local; only alerts fan out.
194
+ return
195
+ if kind in ONCE_EVENTS:
196
+ key = (kind, event.get("run_id"))
197
+ with self._lock:
198
+ if key in self._seen_once:
199
+ return
200
+ self._seen_once.add(key)
201
+ message = format_alert(event)
202
+ color = YELLOW if kind in {"output_clamped", "request_too_large"} else RED
203
+ self._terminal(message, color=color)
204
+
205
+ clean = sanitize(event)
206
+ clean.setdefault("run_id", None)
207
+ payload = {
208
+ "source": "agentcap",
209
+ "sent_at": self.clock().isoformat(),
210
+ "event": clean,
211
+ "message": redact(message),
212
+ }
213
+ if self.webhook_url and "webhook_alerts" in self.features:
214
+ self._deliver(self._post_webhook, payload)
215
+ if (
216
+ self.telegram_token
217
+ and self.telegram_chat_id
218
+ and "telegram_alerts" in self.features
219
+ ):
220
+ self._deliver(self._post_telegram, payload)
221
+
222
+ def _terminal(self, message: str, *, color: str = RED) -> None:
223
+ try:
224
+ print(f"{color}{redact(message)}{RESET}", file=self.stderr, flush=True)
225
+ except Exception:
226
+ pass
227
+
228
+ def _deliver(self, sender: Callable[[dict[str, Any]], None], payload: dict[str, Any]) -> None:
229
+ if not self.background:
230
+ sender(payload)
231
+ return
232
+ thread = threading.Thread(target=sender, args=(payload,), daemon=True)
233
+ with self._lock:
234
+ self._threads.append(thread)
235
+ thread.start()
236
+
237
+ def wait(self, timeout: float | None = 5.0) -> None:
238
+ """Join outstanding delivery threads (used by tests and shutdown)."""
239
+
240
+ with self._lock:
241
+ threads = list(self._threads)
242
+ self._threads.clear()
243
+ for thread in threads:
244
+ thread.join(timeout)
245
+
246
+ def _post_webhook(self, payload: dict[str, Any]) -> None:
247
+ try:
248
+ with httpx.Client(
249
+ transport=self.transport, timeout=self.timeout_s, trust_env=False
250
+ ) as client:
251
+ response = client.post(self.webhook_url, json=payload)
252
+ response.raise_for_status()
253
+ except Exception as exc:
254
+ # Never echo the URL or exception text: it may contain a token.
255
+ self._warn("webhook", exc)
256
+
257
+ def _post_telegram(self, payload: dict[str, Any]) -> None:
258
+ try:
259
+ text = payload.get("message") or "agentcap alert"
260
+ url = f"https://api.telegram.org/bot{self.telegram_token}/sendMessage"
261
+ with httpx.Client(
262
+ transport=self.transport, timeout=self.timeout_s, trust_env=False
263
+ ) as client:
264
+ response = client.post(
265
+ url,
266
+ json={"chat_id": self.telegram_chat_id, "text": text},
267
+ )
268
+ response.raise_for_status()
269
+ except Exception as exc:
270
+ self._warn("telegram", exc)
271
+
272
+ def _warn(self, channel: str, exc: BaseException) -> None:
273
+ try:
274
+ print(
275
+ f"agentcap: alert delivery failed ({channel}: "
276
+ f"{type(exc).__name__}); continuing.",
277
+ file=self.stderr,
278
+ flush=True,
279
+ )
280
+ except Exception:
281
+ pass
282
+
283
+
284
+ def dispatcher_from_env(
285
+ *,
286
+ features: Iterable[str] | None = None,
287
+ transport: httpx.BaseTransport | None = None,
288
+ stderr: TextIO | None = None,
289
+ background: bool = True,
290
+ ) -> AlertDispatcher:
291
+ """Build a dispatcher from ``AGENTCAP_WEBHOOK_URL`` / Telegram env vars."""
292
+
293
+ return AlertDispatcher(
294
+ features=features,
295
+ webhook_url=os.environ.get("AGENTCAP_WEBHOOK_URL"),
296
+ telegram_token=os.environ.get("AGENTCAP_TELEGRAM_BOT_TOKEN"),
297
+ telegram_chat_id=os.environ.get("AGENTCAP_TELEGRAM_CHAT_ID"),
298
+ transport=transport,
299
+ stderr=stderr,
300
+ background=background,
301
+ )
302
+
303
+
304
+ __all__ = [
305
+ "AlertDispatcher",
306
+ "ALLOWED_FIELDS",
307
+ "TERMINAL_EVENTS",
308
+ "dispatcher_from_env",
309
+ "format_alert",
310
+ "redact",
311
+ "sanitize",
312
+ ]
agentcap/clamp.py ADDED
@@ -0,0 +1,174 @@
1
+ """Budget-fit output clamping.
2
+
3
+ Most agents never set an output limit, so a full worst-case reservation
4
+ (``max_output_tokens`` of the model) is much larger than the actual request.
5
+ With a small cap that reservation alone would cross the cap and kill a run that
6
+ has spent nothing yet.
7
+
8
+ This module computes how many output tokens actually fit in the remaining
9
+ budget and rewrites *only* the request's output limit. It never raises an
10
+ existing limit, never touches prompts and never changes settlement (which still
11
+ uses real usage).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+ from decimal import Decimal, ROUND_CEILING, ROUND_FLOOR
18
+ from typing import Any, Mapping
19
+
20
+ from .estimate import candidate_count, explicit_output_tokens
21
+ from .pricing import MICRO_USD, ONE_MILLION, ModelPrice, effective_rates
22
+
23
+ #: Clamping below this many tokens is pointless: treat the budget as exhausted.
24
+ MIN_CLAMP_OUTPUT = 256
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class OutputFit:
29
+ """A clamped output allowance and the exact reservation it implies."""
30
+
31
+ allowed_output: int
32
+ input_cost: Decimal
33
+ reservation: Decimal
34
+
35
+
36
+ def _positive_int(value: Any) -> int | None:
37
+ if isinstance(value, bool) or value is None:
38
+ return None
39
+ if isinstance(value, int):
40
+ return value if value > 0 else None
41
+ if isinstance(value, float) and value.is_integer() and value > 0:
42
+ return int(value)
43
+ if isinstance(value, str) and value.strip().isdigit() and int(value.strip()) > 0:
44
+ return int(value.strip())
45
+ return None
46
+
47
+
48
+ def fit_output(
49
+ model_price: ModelPrice,
50
+ *,
51
+ provider: str,
52
+ body: Mapping[str, Any],
53
+ input_tokens: int,
54
+ remaining: Decimal,
55
+ now: Any = None,
56
+ cache_write: bool = False,
57
+ tier_multiplier: Decimal = Decimal("1"),
58
+ minimum: int = MIN_CLAMP_OUTPUT,
59
+ ) -> OutputFit:
60
+ """Largest per-candidate output limit whose reservation fits ``remaining``.
61
+
62
+ The returned ``allowed_output`` can be below ``minimum`` (or zero); the
63
+ caller decides whether that is still worth sending.
64
+ """
65
+
66
+ if remaining <= 0:
67
+ return OutputFit(0, Decimal("0"), Decimal("0"))
68
+
69
+ input_rate, _cached_rate, output_rate = effective_rates(
70
+ model_price, input_tokens, now=now
71
+ )
72
+ if cache_write and model_price.cache_write_per_mtok is not None:
73
+ input_rate = max(input_rate, model_price.cache_write_per_mtok)
74
+
75
+ input_cost = (
76
+ Decimal(input_tokens) * input_rate / ONE_MILLION
77
+ ).quantize(MICRO_USD, rounding=ROUND_CEILING)
78
+ if tier_multiplier != 1:
79
+ input_cost = (input_cost * tier_multiplier).quantize(
80
+ MICRO_USD, rounding=ROUND_CEILING
81
+ )
82
+
83
+ output_per_token = output_rate * tier_multiplier / ONE_MILLION
84
+ candidates = candidate_count(provider, body)
85
+ if output_per_token <= 0:
86
+ return OutputFit(0, input_cost, input_cost)
87
+
88
+ budget_for_output = remaining - input_cost
89
+ if budget_for_output <= 0:
90
+ allowed = 0
91
+ else:
92
+ raw = budget_for_output / (output_per_token * Decimal(candidates))
93
+ allowed = int(raw.to_integral_value(rounding=ROUND_FLOOR))
94
+ if allowed < 0:
95
+ allowed = 0
96
+
97
+ existing = explicit_output_tokens(provider, body)
98
+ if existing is not None:
99
+ # Never raise a limit the client already set (math should prevent it).
100
+ allowed = min(allowed, existing)
101
+
102
+ def _reservation(value: int) -> Decimal:
103
+ total = input_cost + (
104
+ Decimal(value * candidates) * output_rate * tier_multiplier / ONE_MILLION
105
+ )
106
+ return total.quantize(MICRO_USD, rounding=ROUND_CEILING)
107
+
108
+ reservation = _reservation(allowed)
109
+ # Ceiling rounding can push the reservation one micro-dollar over; lower the
110
+ # allowance until it genuinely fits.
111
+ while reservation > remaining and allowed > 0:
112
+ allowed -= 1
113
+ reservation = _reservation(allowed)
114
+
115
+ return OutputFit(allowed, input_cost, reservation)
116
+
117
+
118
+ def apply_output_clamp(
119
+ provider: str,
120
+ path: str,
121
+ body: dict[str, Any],
122
+ allowed_output: int,
123
+ ) -> None:
124
+ """Lower the request's output limit to ``allowed_output`` in place.
125
+
126
+ * OpenAI chat: ``max_completion_tokens`` (or the legacy ``max_tokens`` when
127
+ the body already used it; never both).
128
+ * OpenAI responses: ``max_output_tokens``.
129
+ * Gemini: ``generationConfig.maxOutputTokens`` (created when missing).
130
+ """
131
+
132
+ allowed = _positive_int(allowed_output)
133
+ if allowed is None:
134
+ return
135
+
136
+ if provider == "gemini":
137
+ config = body.get("generationConfig")
138
+ config = dict(config) if isinstance(config, Mapping) else {}
139
+ current = _positive_int(
140
+ config.get("maxOutputTokens", config.get("max_output_tokens"))
141
+ )
142
+ config["maxOutputTokens"] = min(allowed, current) if current else allowed
143
+ body["generationConfig"] = config
144
+ return
145
+
146
+ normalised = path.strip("/")
147
+ if normalised == "v1/responses":
148
+ current = _positive_int(body.get("max_output_tokens"))
149
+ body["max_output_tokens"] = min(allowed, current) if current else allowed
150
+ return
151
+
152
+ # Chat Completions (and the legacy /v1/completions shape).
153
+ if normalised == "v1/completions":
154
+ current = _positive_int(body.get("max_tokens"))
155
+ body["max_tokens"] = min(allowed, current) if current else allowed
156
+ return
157
+ legacy = _positive_int(body.get("max_tokens"))
158
+ modern = _positive_int(body.get("max_completion_tokens"))
159
+ if legacy is not None:
160
+ body["max_tokens"] = min(allowed, legacy)
161
+ if modern is not None:
162
+ # Both fields already existed; lower both so neither stays above
163
+ # the reservation. A new second field is never added.
164
+ body["max_completion_tokens"] = min(allowed, modern)
165
+ return
166
+ body["max_completion_tokens"] = min(allowed, modern) if modern else allowed
167
+
168
+
169
+ __all__ = [
170
+ "MIN_CLAMP_OUTPUT",
171
+ "OutputFit",
172
+ "apply_output_clamp",
173
+ "fit_output",
174
+ ]