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/runner.py ADDED
@@ -0,0 +1,456 @@
1
+ """Run a child command behind the local spend-cap proxy.
2
+
3
+ The proxy listens on ``127.0.0.1`` only, on a random free port (or ``--port``).
4
+ The child receives base-URL environment variables pointing at the proxy, so any
5
+ tool that honours them is metered without code changes.
6
+
7
+ Killing is centralised here so it can be tested: :class:`Runner` accepts an
8
+ injectable upstream transport, ``Popen`` factory, clock and alert dispatcher.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import signal
15
+ import socket
16
+ import subprocess
17
+ import sys
18
+ import threading
19
+ import time
20
+ import uuid
21
+ from dataclasses import dataclass
22
+ from datetime import datetime
23
+ from decimal import Decimal
24
+ from typing import Any, Callable, Iterable, Mapping, Sequence, TextIO
25
+
26
+ import httpx
27
+ import uvicorn
28
+
29
+ from .alerts import AlertDispatcher, dispatcher_from_env
30
+ from .ledger import Caps, Ledger
31
+ from .license import features as license_features
32
+ from .license import status_from_environment
33
+ from .pricing import PricingStore
34
+ from .proxy import (
35
+ DEFAULT_GEMINI_UPSTREAM,
36
+ DEFAULT_OPENAI_UPSTREAM,
37
+ ProxyConfig,
38
+ create_app,
39
+ )
40
+
41
+ EXIT_CAP_EXCEEDED = 3
42
+ EXIT_LOOP_DETECTED = 4
43
+
44
+ EXIT_REASONS = {
45
+ "cap_exceeded": EXIT_CAP_EXCEEDED,
46
+ "loop_detected": EXIT_LOOP_DETECTED,
47
+ }
48
+
49
+ CAP_ENV = "AGENTCAP_CAP"
50
+ DEFAULT_CAP = Decimal("5")
51
+
52
+
53
+ @dataclass
54
+ class RunSummary:
55
+ """Numbers printed when a run ends."""
56
+
57
+ run_id: str
58
+ requests: int
59
+ spent_usd: Decimal
60
+ cap_usd: Decimal | None
61
+ reserved_usd: Decimal
62
+ reason: str
63
+ exit_code: int
64
+ clamped_requests: int = 0
65
+
66
+ def lines(self) -> list[str]:
67
+ cap = "none" if self.cap_usd is None else f"${self.cap_usd:.2f}"
68
+ return [
69
+ "agentcap run summary",
70
+ f" run id: {self.run_id}",
71
+ f" requests: {self.requests}",
72
+ f" spent: ${self.spent_usd:.4f}",
73
+ f" cap: {cap}",
74
+ f" reserved-but-unsettled: ${self.reserved_usd:.4f}",
75
+ f" clamped requests: {self.clamped_requests}",
76
+ f" reason: {self.reason}",
77
+ f" exit code: {self.exit_code}",
78
+ ]
79
+
80
+
81
+ def cap_from_env(default: Decimal | str | int = DEFAULT_CAP) -> Decimal:
82
+ raw = os.environ.get(CAP_ENV)
83
+ if not raw:
84
+ return Decimal(str(default))
85
+ try:
86
+ value = Decimal(raw.strip())
87
+ except Exception: # noqa: BLE001 - fall back to the documented default
88
+ return Decimal(str(default))
89
+ return value if value >= 0 else Decimal(str(default))
90
+
91
+
92
+ def _default_popen(argv: Sequence[str], env: Mapping[str, str]) -> subprocess.Popen:
93
+ return subprocess.Popen(
94
+ list(argv),
95
+ env=dict(env),
96
+ start_new_session=True,
97
+ )
98
+
99
+
100
+ class Runner:
101
+ """Start the proxy, spawn the child, enforce kills, print a summary."""
102
+
103
+ def __init__(
104
+ self,
105
+ argv: Sequence[str],
106
+ *,
107
+ cap: Decimal | str | int | None = None,
108
+ hour_cap: Decimal | str | int | None = None,
109
+ day_cap: Decimal | str | int | None = None,
110
+ allow_unmetered: bool = False,
111
+ clamp_output: bool = True,
112
+ min_clamp_output: int = 256,
113
+ host: str = "127.0.0.1",
114
+ port: int = 0,
115
+ run_id: str | None = None,
116
+ ledger: Ledger | None = None,
117
+ features: Iterable[str] | None = None,
118
+ pricing: PricingStore | None = None,
119
+ config: ProxyConfig | None = None,
120
+ transport: httpx.AsyncBaseTransport | None = None,
121
+ popen_factory: Callable[[Sequence[str], Mapping[str, str]], subprocess.Popen]
122
+ | None = None,
123
+ clock: Callable[[], datetime] | None = None,
124
+ stderr: TextIO | None = None,
125
+ alerts: AlertDispatcher | None = None,
126
+ alert_transport: httpx.BaseTransport | None = None,
127
+ env: Mapping[str, str] | None = None,
128
+ ready_timeout: float = 10.0,
129
+ poll_interval: float = 0.05,
130
+ term_timeout: float = 5.0,
131
+ quiet: bool = False,
132
+ openai_upstream: str | None = None,
133
+ gemini_upstream: str | None = None,
134
+ ) -> None:
135
+ if not argv:
136
+ raise ValueError("argv must contain the command to run")
137
+ if host != "127.0.0.1":
138
+ raise ValueError("the proxy must bind to 127.0.0.1")
139
+ self.argv = list(argv)
140
+ self.cap = Decimal(str(cap)) if cap is not None else cap_from_env()
141
+ self.hour_cap = Decimal(str(hour_cap)) if hour_cap is not None else None
142
+ self.day_cap = Decimal(str(day_cap)) if day_cap is not None else None
143
+ self.allow_unmetered = bool(allow_unmetered)
144
+ self.clamp_output = bool(clamp_output)
145
+ self.min_clamp_output = int(min_clamp_output)
146
+ self.host = host
147
+ self.requested_port = int(port)
148
+ self.run_id = run_id or f"run-{uuid.uuid4().hex[:12]}"
149
+ self.ledger = ledger or Ledger()
150
+ if features is None:
151
+ if config is not None:
152
+ self.features = frozenset(config.features or ())
153
+ else:
154
+ self.features = frozenset(
155
+ license_features(status_from_environment().tier)
156
+ )
157
+ else:
158
+ self.features = frozenset(features)
159
+ self.pricing = pricing
160
+ self.transport = transport
161
+ self.popen_factory = popen_factory or _default_popen
162
+ self.clock = clock
163
+ self.stderr = stderr if stderr is not None else sys.stderr
164
+ self.env = dict(env) if env is not None else None
165
+ self.ready_timeout = float(ready_timeout)
166
+ self.poll_interval = float(poll_interval)
167
+ self.term_timeout = float(term_timeout)
168
+ self.quiet = quiet
169
+ self.openai_upstream = openai_upstream
170
+ self.gemini_upstream = gemini_upstream
171
+
172
+ self.port: int | None = None
173
+ self.server: uvicorn.Server | None = None
174
+ self._server_thread: threading.Thread | None = None
175
+ self._socket: socket.socket | None = None
176
+ self.kill_event = threading.Event()
177
+ self.kill_reason: str | None = None
178
+ self._kill_lock = threading.Lock()
179
+ self.child: subprocess.Popen | None = None
180
+ self.summary: RunSummary | None = None
181
+
182
+ self.alerts = alerts or dispatcher_from_env(
183
+ features=self.features,
184
+ transport=alert_transport,
185
+ stderr=self.stderr,
186
+ )
187
+
188
+ if config is not None:
189
+ if not isinstance(config, ProxyConfig):
190
+ raise TypeError("config must be a ProxyConfig")
191
+ config.on_cap_exceeded = self._on_cap_exceeded
192
+ config.on_loop_detected = self._on_loop_detected
193
+ if openai_upstream:
194
+ config.openai_upstream = openai_upstream.rstrip("/")
195
+ if gemini_upstream:
196
+ config.gemini_upstream = gemini_upstream.rstrip("/")
197
+ self.config = config
198
+ self.run_id = config.run_id
199
+ if config.caps.run_usd is not None:
200
+ self.cap = config.caps.run_usd
201
+ self.hour_cap = config.caps.hour_usd
202
+ self.day_cap = config.caps.day_usd
203
+ if config.pricing is not None:
204
+ self.pricing = config.pricing
205
+ if config.clock is not None and clock is None:
206
+ self.clock = config.clock
207
+ else:
208
+ caps = Caps(
209
+ run_usd=self.cap, hour_usd=self.hour_cap, day_usd=self.day_cap
210
+ )
211
+ self.config = ProxyConfig(
212
+ run_id=self.run_id,
213
+ caps=caps,
214
+ features=self.features,
215
+ allow_unmetered=self.allow_unmetered,
216
+ clamp_output=self.clamp_output,
217
+ min_clamp_output=self.min_clamp_output,
218
+ pricing=self.pricing,
219
+ clock=self.clock,
220
+ on_cap_exceeded=self._on_cap_exceeded,
221
+ on_loop_detected=self._on_loop_detected,
222
+ openai_upstream=openai_upstream or DEFAULT_OPENAI_UPSTREAM,
223
+ gemini_upstream=gemini_upstream or DEFAULT_GEMINI_UPSTREAM,
224
+ )
225
+ self.clamped_requests = 0
226
+
227
+ # --------------------------------------------------------------- callbacks
228
+ def _trip(self, reason: str, payload: Mapping[str, Any]) -> None:
229
+ with self._kill_lock:
230
+ if self.kill_reason is None:
231
+ self.kill_reason = reason
232
+ self.kill_event.set()
233
+
234
+ def _on_cap_exceeded(self, payload: Mapping[str, Any]) -> None:
235
+ self._trip("cap_exceeded", payload)
236
+
237
+ def _on_loop_detected(self, payload: Mapping[str, Any]) -> None:
238
+ self._trip("loop_detected", payload)
239
+
240
+ def _emit_event(self, event: Mapping[str, Any]) -> None:
241
+ """Attach the run id before handing an accounting event to alerts."""
242
+
243
+ if event.get("type") == "output_clamped":
244
+ self.clamped_requests += 1
245
+ try:
246
+ self.alerts.emit({**event, "run_id": self.run_id})
247
+ except Exception:
248
+ pass
249
+
250
+ # ----------------------------------------------------------------- proxy
251
+ @property
252
+ def status_url(self) -> str:
253
+ if self.port is None:
254
+ raise RuntimeError("proxy is not started")
255
+ return f"http://127.0.0.1:{self.port}/_agentcap/status"
256
+
257
+ def _start_proxy(self) -> None:
258
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
259
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
260
+ sock.bind((self.host, self.requested_port))
261
+ sock.listen(128)
262
+ sock.set_inheritable(True)
263
+ self._socket = sock
264
+ self.port = int(sock.getsockname()[1])
265
+
266
+ app = create_app(
267
+ self.config,
268
+ self.ledger,
269
+ upstream_transport=self.transport,
270
+ events=self._emit_event,
271
+ )
272
+ server = uvicorn.Server(
273
+ uvicorn.Config(
274
+ app,
275
+ host=self.host,
276
+ port=self.port,
277
+ log_level="error",
278
+ access_log=False,
279
+ lifespan="on",
280
+ )
281
+ )
282
+ self.server = server
283
+ self._server_thread = threading.Thread(
284
+ target=server.run,
285
+ kwargs={"sockets": [sock]},
286
+ name="agentcap-proxy",
287
+ daemon=True,
288
+ )
289
+ self._server_thread.start()
290
+ self._wait_ready()
291
+
292
+ def _wait_ready(self) -> None:
293
+ deadline = time.monotonic() + self.ready_timeout
294
+ client = httpx.Client(timeout=0.5, trust_env=False)
295
+ try:
296
+ while time.monotonic() < deadline:
297
+ if self.server is not None and self.server.started:
298
+ try:
299
+ response = client.get(self.status_url)
300
+ if response.status_code == 200:
301
+ return
302
+ except httpx.HTTPError:
303
+ pass
304
+ if self._server_thread is not None and not self._server_thread.is_alive():
305
+ raise RuntimeError("proxy server thread exited during startup")
306
+ time.sleep(self.poll_interval)
307
+ finally:
308
+ client.close()
309
+ raise RuntimeError(
310
+ f"proxy did not become ready within {self.ready_timeout:g}s"
311
+ )
312
+
313
+ def _stop_proxy(self) -> None:
314
+ if self.server is not None:
315
+ self.server.should_exit = True
316
+ if self._server_thread is not None:
317
+ self._server_thread.join(timeout=5.0)
318
+ if self._socket is not None:
319
+ try:
320
+ self._socket.close()
321
+ except OSError:
322
+ pass
323
+ try:
324
+ self.alerts.wait(timeout=5.0)
325
+ except Exception:
326
+ pass
327
+
328
+ # ------------------------------------------------------------------ child
329
+ def child_env(self) -> dict[str, str]:
330
+ if self.port is None:
331
+ raise RuntimeError("proxy is not started")
332
+ env = dict(self.env) if self.env is not None else dict(os.environ)
333
+ base = f"http://127.0.0.1:{self.port}"
334
+ env.update(
335
+ {
336
+ "OPENAI_BASE_URL": f"{base}/openai/v1",
337
+ "GOOGLE_GEMINI_BASE_URL": f"{base}/gemini",
338
+ "GEMINI_API_BASE_URL": f"{base}/gemini",
339
+ "AGENTCAP_RUN_ID": self.run_id,
340
+ "AGENTCAP_PROXY": base,
341
+ }
342
+ )
343
+ return env
344
+
345
+ def _spawn(self) -> subprocess.Popen:
346
+ return self.popen_factory(self.argv, self.child_env())
347
+
348
+ def _signal_group(self, sig: int) -> None:
349
+ child = self.child
350
+ if child is None or child.poll() is not None:
351
+ return
352
+ try:
353
+ pgid = os.getpgid(child.pid)
354
+ except (ProcessLookupError, OSError):
355
+ pgid = None
356
+ try:
357
+ if pgid is not None and pgid != os.getpgrp():
358
+ os.killpg(pgid, sig)
359
+ else:
360
+ # A custom popen factory may not have created a new session;
361
+ # never signal our own process group.
362
+ child.send_signal(sig)
363
+ except (ProcessLookupError, PermissionError, OSError):
364
+ pass
365
+
366
+ def _terminate(self) -> None:
367
+ self._signal_group(signal.SIGTERM)
368
+ child = self.child
369
+ if child is None:
370
+ return
371
+ try:
372
+ child.wait(timeout=self.term_timeout)
373
+ return
374
+ except subprocess.TimeoutExpired:
375
+ pass
376
+ self._signal_group(signal.SIGKILL)
377
+ try:
378
+ child.wait(timeout=5.0)
379
+ except subprocess.TimeoutExpired:
380
+ pass
381
+
382
+ def _monitor(self) -> int:
383
+ child = self.child
384
+ assert child is not None
385
+ while True:
386
+ if self.kill_event.is_set():
387
+ self._terminate()
388
+ return EXIT_REASONS.get(self.kill_reason or "", 1)
389
+ code = child.poll()
390
+ if code is not None:
391
+ if self.kill_event.is_set():
392
+ return EXIT_REASONS.get(self.kill_reason or "", 1)
393
+ return int(code)
394
+ time.sleep(self.poll_interval)
395
+
396
+ # -------------------------------------------------------------------- run
397
+ def run(self) -> int:
398
+ self._start_proxy()
399
+ reason = "child_exit"
400
+ exit_code = 1
401
+ try:
402
+ self.child = self._spawn()
403
+ try:
404
+ exit_code = self._monitor()
405
+ except KeyboardInterrupt:
406
+ reason = "interrupted"
407
+ self._signal_group(signal.SIGINT)
408
+ try:
409
+ code = self.child.wait(timeout=self.term_timeout)
410
+ exit_code = 130 if code is None or code < 0 else int(code)
411
+ except subprocess.TimeoutExpired:
412
+ self._terminate()
413
+ code = self.child.returncode
414
+ exit_code = 130 if code is None or code < 0 else int(code)
415
+ if self.kill_event.is_set():
416
+ reason = self.kill_reason or reason
417
+ exit_code = EXIT_REASONS.get(reason, exit_code)
418
+ else:
419
+ reason = "child_exit"
420
+ finally:
421
+ self._stop_proxy()
422
+ self.summary = self._build_summary(reason, exit_code)
423
+ self._print_summary(self.summary)
424
+ return exit_code
425
+
426
+ def _build_summary(self, reason: str, exit_code: int) -> RunSummary:
427
+ try:
428
+ stats = self.ledger.summary(self.run_id)
429
+ except Exception:
430
+ stats = {}
431
+ return RunSummary(
432
+ run_id=self.run_id,
433
+ requests=int(stats.get("reservations", 0) or 0),
434
+ spent_usd=Decimal(str(stats.get("actual_usd", "0"))),
435
+ cap_usd=self.cap,
436
+ reserved_usd=Decimal(str(stats.get("reserved_usd", "0"))),
437
+ reason=reason,
438
+ exit_code=exit_code,
439
+ clamped_requests=self.clamped_requests,
440
+ )
441
+
442
+ def _print_summary(self, summary: RunSummary) -> None:
443
+ try:
444
+ for line in summary.lines():
445
+ print(line, file=self.stderr, flush=True)
446
+ except Exception:
447
+ pass
448
+
449
+
450
+ __all__ = [
451
+ "Runner",
452
+ "RunSummary",
453
+ "EXIT_CAP_EXCEEDED",
454
+ "EXIT_LOOP_DETECTED",
455
+ "cap_from_env",
456
+ ]
agentcap/upstreams.py ADDED
@@ -0,0 +1,132 @@
1
+ """Environment-driven upstream overrides for local testing.
2
+
3
+ The proxy talks to the real provider APIs by default. For offline / local
4
+ experiments the upstream base URLs can be overridden with:
5
+
6
+ * ``AGENTCAP_OPENAI_UPSTREAM``
7
+ * ``AGENTCAP_GEMINI_UPSTREAM``
8
+
9
+ Safety rule: an override is only accepted when it is ``https://`` or when it is
10
+ ``http://`` and the host is a loopback address (``127.0.0.1``, ``localhost`` or
11
+ ``::1``). Anything else is refused so a stray environment variable can never
12
+ silently redirect a user's prompts and credentials to a remote plaintext host.
13
+
14
+ The URLs themselves carry no secrets, but userinfo and query strings are
15
+ stripped before anything is printed.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ from dataclasses import dataclass
22
+ from typing import Mapping
23
+ from urllib.parse import urlsplit, urlunsplit
24
+
25
+ OPENAI_UPSTREAM_ENV = "AGENTCAP_OPENAI_UPSTREAM"
26
+ GEMINI_UPSTREAM_ENV = "AGENTCAP_GEMINI_UPSTREAM"
27
+
28
+ _LOCAL_HTTP_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
29
+
30
+ YELLOW = "\033[33m"
31
+ RESET = "\033[0m"
32
+
33
+
34
+ class UpstreamConfigError(ValueError):
35
+ """An upstream override is malformed or unsafe."""
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class UpstreamOverride:
40
+ """One validated upstream override."""
41
+
42
+ env: str
43
+ provider: str
44
+ url: str
45
+
46
+ def notice(self) -> str:
47
+ """One-line yellow notice safe to print (no userinfo or query)."""
48
+
49
+ base = display_url(self.url)
50
+ return (
51
+ f"{YELLOW}agentcap: {self.env} override active: {base}{RESET}"
52
+ )
53
+
54
+
55
+ def display_url(value: str) -> str:
56
+ """Strip userinfo, query and fragment from a URL for display."""
57
+
58
+ try:
59
+ parts = urlsplit(value)
60
+ host = parts.hostname or ""
61
+ except ValueError:
62
+ return "<invalid URL>"
63
+ if ":" in host:
64
+ host = f"[{host}]"
65
+ try:
66
+ port = parts.port
67
+ except ValueError:
68
+ port = None
69
+ netloc = f"{host}:{port}" if port is not None else host
70
+ path = parts.path.rstrip("/")
71
+ return urlunsplit((parts.scheme, netloc, path, "", ""))
72
+
73
+
74
+ def _validate(raw: str, env_name: str) -> str:
75
+ value = raw.strip()
76
+ if not value:
77
+ raise UpstreamConfigError(
78
+ f"{env_name} is empty; unset it or give a URL."
79
+ )
80
+ try:
81
+ parts = urlsplit(value)
82
+ host = parts.hostname or ""
83
+ except ValueError:
84
+ raise UpstreamConfigError(
85
+ f"{env_name} is not a valid URL: {display_url(value)!r}"
86
+ ) from None
87
+ if parts.scheme not in ("http", "https") or not host:
88
+ raise UpstreamConfigError(
89
+ f"{env_name} must be an https:// URL "
90
+ f"(http:// is allowed only on loopback); got {display_url(value)!r}"
91
+ )
92
+ if parts.scheme == "http" and host.lower() not in _LOCAL_HTTP_HOSTS:
93
+ raise UpstreamConfigError(
94
+ f"{env_name} may use http:// only for 127.0.0.1, localhost or "
95
+ f"::1; got {display_url(value)!r}. Use https:// for a remote "
96
+ "upstream."
97
+ )
98
+ return value.rstrip("/")
99
+
100
+
101
+ def resolve_upstreams(
102
+ environ: Mapping[str, str] | None = None,
103
+ ) -> tuple[UpstreamOverride, ...]:
104
+ """Validate every active override, raising on the first unsafe one."""
105
+
106
+ env = environ if environ is not None else os.environ
107
+ overrides: list[UpstreamOverride] = []
108
+ for env_name, provider in (
109
+ (OPENAI_UPSTREAM_ENV, "openai"),
110
+ (GEMINI_UPSTREAM_ENV, "gemini"),
111
+ ):
112
+ raw = env.get(env_name)
113
+ if raw is None or not raw.strip():
114
+ continue
115
+ overrides.append(
116
+ UpstreamOverride(
117
+ env=env_name,
118
+ provider=provider,
119
+ url=_validate(raw, env_name),
120
+ )
121
+ )
122
+ return tuple(overrides)
123
+
124
+
125
+ __all__ = [
126
+ "GEMINI_UPSTREAM_ENV",
127
+ "OPENAI_UPSTREAM_ENV",
128
+ "UpstreamConfigError",
129
+ "UpstreamOverride",
130
+ "display_url",
131
+ "resolve_upstreams",
132
+ ]