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 +7 -0
- agentcap/alerts.py +312 -0
- agentcap/clamp.py +174 -0
- agentcap/cli.py +373 -0
- agentcap/data/pricing.json +383 -0
- agentcap/devtools/__init__.py +1 -0
- agentcap/devtools/fake_upstream.py +412 -0
- agentcap/estimate.py +271 -0
- agentcap/ledger.py +387 -0
- agentcap/license.py +634 -0
- agentcap/loop.py +118 -0
- agentcap/pricing.py +620 -0
- agentcap/proxy.py +1181 -0
- agentcap/runner.py +456 -0
- agentcap/upstreams.py +132 -0
- agentcap/usage.py +428 -0
- agentcap_cli-0.1.0.dist-info/METADATA +304 -0
- agentcap_cli-0.1.0.dist-info/RECORD +21 -0
- agentcap_cli-0.1.0.dist-info/WHEEL +4 -0
- agentcap_cli-0.1.0.dist-info/entry_points.txt +2 -0
- agentcap_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
+
]
|