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/cli.py ADDED
@@ -0,0 +1,373 @@
1
+ """AgentCap command line entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import sys
9
+ import time
10
+ from datetime import datetime
11
+ from decimal import Decimal
12
+
13
+ import httpx
14
+ import uvicorn
15
+
16
+ from . import __version__
17
+ from .alerts import dispatcher_from_env
18
+ from .ledger import Caps, Ledger
19
+ from .license import (
20
+ LicenseManager,
21
+ features,
22
+ status_from_environment,
23
+ )
24
+ from .pricing import PricingError, load_pricing
25
+ from .proxy import (
26
+ DEFAULT_GEMINI_UPSTREAM,
27
+ DEFAULT_OPENAI_UPSTREAM,
28
+ ProxyConfig,
29
+ create_app,
30
+ )
31
+ from .runner import DEFAULT_CAP, Runner, cap_from_env
32
+ from .upstreams import UpstreamConfigError, resolve_upstreams
33
+
34
+ DEFAULT_SERVE_PORT = 8787
35
+
36
+
37
+ def _build_parser() -> argparse.ArgumentParser:
38
+ parser = argparse.ArgumentParser(
39
+ prog="agentcap",
40
+ description="Local, hard USD cap for AI agent runs.",
41
+ )
42
+ parser.add_argument("--version", action="version", version=f"agentcap {__version__}")
43
+ subparsers = parser.add_subparsers(dest="subcommand")
44
+
45
+ pricing = subparsers.add_parser("pricing", help="inspect bundled model prices")
46
+ pricing.add_argument("model", nargs="?", help="model id, e.g. openai/gpt-4o-mini")
47
+
48
+ license_parser = subparsers.add_parser("license", help="manage the license key")
49
+ license_parser.add_argument(
50
+ "action",
51
+ nargs="?",
52
+ default="status",
53
+ choices=("status", "features", "activate", "deactivate"),
54
+ help="status (default), features, activate <KEY>, or deactivate",
55
+ )
56
+ license_parser.add_argument("key", nargs="?", help="license key for 'activate'")
57
+
58
+ run = subparsers.add_parser(
59
+ "run", help="run a command under a spend cap (use -- before the command)"
60
+ )
61
+ run.add_argument(
62
+ "--cap",
63
+ default=None,
64
+ help=f"run cap in USD (default: ${DEFAULT_CAP} or $AGENTCAP_CAP)",
65
+ )
66
+ run.add_argument("--hour-cap", default=None, help="hourly cap in USD (Pro)")
67
+ run.add_argument("--day-cap", default=None, help="daily cap in USD (Pro)")
68
+ run.add_argument(
69
+ "--allow-unmetered",
70
+ action="store_true",
71
+ help="forward endpoints that cannot be metered (unsafe)",
72
+ )
73
+ run.add_argument(
74
+ "--no-clamp",
75
+ dest="clamp_output",
76
+ action="store_false",
77
+ default=True,
78
+ help="never lower a request's output limit to fit the remaining budget",
79
+ )
80
+ run.add_argument("--port", type=int, default=0, help="proxy port (default: random free)")
81
+ run.add_argument("--quiet", action="store_true", help="suppress non-essential output")
82
+ run.add_argument("argv", nargs=argparse.REMAINDER, help="command after --")
83
+
84
+ serve = subparsers.add_parser(
85
+ "serve", help="run the proxy in the foreground for other terminals"
86
+ )
87
+ serve.add_argument("--port", type=int, default=DEFAULT_SERVE_PORT)
88
+ serve.add_argument("--cap", default=None, help="run cap in USD")
89
+ serve.add_argument("--allow-unmetered", action="store_true")
90
+ serve.add_argument(
91
+ "--no-clamp",
92
+ dest="clamp_output",
93
+ action="store_false",
94
+ default=True,
95
+ help="never lower a request's output limit to fit the remaining budget",
96
+ )
97
+ serve.add_argument("--quiet", action="store_true")
98
+
99
+ status = subparsers.add_parser("status", help="show proxy status or ledger spend")
100
+ status.add_argument("--port", type=int, default=DEFAULT_SERVE_PORT)
101
+ status.add_argument(
102
+ "--db", action="store_true", help="read the local ledger file instead of a proxy"
103
+ )
104
+ return parser
105
+
106
+
107
+ # --------------------------------------------------------------------- pricing
108
+ def _pricing(args: argparse.Namespace) -> int:
109
+ store = load_pricing()
110
+ if args.model:
111
+ price = store.get(args.model)
112
+ verified = price.verified_at or ("yes" if price.verified else "no")
113
+ print(f"{price.key} (verified={verified})")
114
+ print(f" input: ${price.input_per_mtok}/Mtok")
115
+ print(f" cached input: ${price.cached_input_per_mtok}/Mtok")
116
+ print(f" output: ${price.output_per_mtok}/Mtok")
117
+ if price.cache_write_per_mtok is not None:
118
+ print(f" cache write: ${price.cache_write_per_mtok}/Mtok")
119
+ print(f" max output: {price.max_output_tokens} tokens")
120
+ if price.long_context is not None:
121
+ cached = price.long_context.cached_input_per_mtok
122
+ cached_text = "" if cached is None else f", ${cached}/Mtok cached"
123
+ print(
124
+ " long context: "
125
+ f">{price.long_context.threshold_tokens} tokens -> "
126
+ f"${price.long_context.input_per_mtok}/Mtok input"
127
+ f"{cached_text}, "
128
+ f"${price.long_context.output_per_mtok}/Mtok output"
129
+ )
130
+ if price.schedule:
131
+ print(" scheduled price changes:")
132
+ for entry in price.schedule:
133
+ print(
134
+ f" from {entry.effective_from.isoformat()}: "
135
+ f"${entry.input_per_mtok}/${entry.cached_input_per_mtok}/"
136
+ f"${entry.output_per_mtok} per Mtok (in/cached/out)"
137
+ )
138
+ if price.source:
139
+ print(f" source: {price.source}")
140
+ return 0
141
+
142
+ print(
143
+ f"pricing version {store.version} "
144
+ f"(updated {store.updated}, verified={store.verified})"
145
+ )
146
+ for key in sorted(store.keys()):
147
+ price = store.get(key)
148
+ print(
149
+ f"{key}: ${price.input_per_mtok}/${price.cached_input_per_mtok}/"
150
+ f"${price.output_per_mtok} per Mtok (in/cached/out)"
151
+ )
152
+ return 0
153
+
154
+
155
+ # --------------------------------------------------------------------- license
156
+ def _license_manager() -> LicenseManager:
157
+ return LicenseManager()
158
+
159
+
160
+ def _license(args: argparse.Namespace) -> int:
161
+ if args.action == "activate":
162
+ if not args.key:
163
+ print("agentcap: license activate requires a key", file=sys.stderr)
164
+ return 2
165
+ status = _license_manager().activate(args.key)
166
+ print(f"tier: {status.tier}")
167
+ print(f"valid: {status.valid}")
168
+ print(f"reason: {status.reason}")
169
+ return 0 if status.valid else 1
170
+
171
+ if args.action == "deactivate":
172
+ _license_manager().clear()
173
+ print("license cache cleared")
174
+ return 0
175
+
176
+ status = status_from_environment()
177
+ if args.action == "features":
178
+ print(json.dumps(sorted(features(status.tier)), indent=2))
179
+ else:
180
+ print(f"tier: {status.tier}")
181
+ print(f"valid: {status.valid}")
182
+ print(f"reason: {status.reason}")
183
+ return 0
184
+
185
+
186
+ # ------------------------------------------------------------------------- run
187
+ def _decimal_cap(raw: str | None, label: str) -> Decimal | None:
188
+ if raw is None:
189
+ return None
190
+ try:
191
+ value = Decimal(str(raw))
192
+ except Exception:
193
+ raise ValueError(f"{label} must be a decimal USD amount") from None
194
+ if not value.is_finite() or value < 0:
195
+ raise ValueError(f"{label} must be a non-negative decimal USD amount")
196
+ return value
197
+
198
+
199
+ def _upstream_overrides() -> dict[str, str] | None:
200
+ """Validate override env vars, print a notice, or return None on error."""
201
+
202
+ try:
203
+ overrides = resolve_upstreams()
204
+ except UpstreamConfigError as exc:
205
+ print(f"agentcap: {exc}", file=sys.stderr)
206
+ return None
207
+ for override in overrides:
208
+ print(override.notice(), file=sys.stderr)
209
+ return {override.provider: override.url for override in overrides}
210
+
211
+
212
+ def _run(args: argparse.Namespace) -> int:
213
+ argv = list(args.argv)
214
+ if argv and argv[0] == "--":
215
+ argv = argv[1:]
216
+ if not argv:
217
+ print(
218
+ "agentcap: run needs a command, e.g. agentcap run --cap 0.50 -- gemini ...",
219
+ file=sys.stderr,
220
+ )
221
+ return 2
222
+
223
+ try:
224
+ cap = _decimal_cap(args.cap, "--cap")
225
+ hour_cap = _decimal_cap(args.hour_cap, "--hour-cap")
226
+ day_cap = _decimal_cap(args.day_cap, "--day-cap")
227
+ except ValueError as exc:
228
+ print(f"agentcap: {exc}", file=sys.stderr)
229
+ return 2
230
+ if cap is None:
231
+ cap = cap_from_env()
232
+
233
+ overrides = _upstream_overrides()
234
+ if overrides is None:
235
+ return 2
236
+
237
+ active_features = set(features(status_from_environment().tier))
238
+ if hour_cap is not None and "hour_cap" not in active_features:
239
+ print(
240
+ "agentcap: --hour-cap requires AgentCap Pro; ignoring it on the free tier.",
241
+ file=sys.stderr,
242
+ )
243
+ hour_cap = None
244
+ if day_cap is not None and "day_cap" not in active_features:
245
+ print(
246
+ "agentcap: --day-cap requires AgentCap Pro; ignoring it on the free tier.",
247
+ file=sys.stderr,
248
+ )
249
+ day_cap = None
250
+
251
+ try:
252
+ runner = Runner(
253
+ argv,
254
+ cap=cap,
255
+ hour_cap=hour_cap,
256
+ day_cap=day_cap,
257
+ allow_unmetered=args.allow_unmetered,
258
+ clamp_output=args.clamp_output,
259
+ port=args.port,
260
+ features=active_features,
261
+ quiet=args.quiet,
262
+ openai_upstream=overrides.get("openai"),
263
+ gemini_upstream=overrides.get("gemini"),
264
+ )
265
+ return runner.run()
266
+ except OSError as exc:
267
+ print(
268
+ f"agentcap: could not start the command ({exc.strerror or exc})",
269
+ file=sys.stderr,
270
+ )
271
+ return 127
272
+ except (ValueError, RuntimeError, PricingError) as exc:
273
+ print(f"agentcap: {exc}", file=sys.stderr)
274
+ return 2
275
+
276
+
277
+ # ----------------------------------------------------------------------- serve
278
+ def _serve(args: argparse.Namespace) -> int:
279
+ try:
280
+ cap = _decimal_cap(args.cap, "--cap")
281
+ except ValueError as exc:
282
+ print(f"agentcap: {exc}", file=sys.stderr)
283
+ return 2
284
+ if cap is None:
285
+ cap = cap_from_env()
286
+
287
+ overrides = _upstream_overrides()
288
+ if overrides is None:
289
+ return 2
290
+
291
+ active_features = set(features(status_from_environment().tier))
292
+ run_id = f"serve-{int(time.time())}"
293
+ alerts = dispatcher_from_env(features=active_features)
294
+ config = ProxyConfig(
295
+ run_id=run_id,
296
+ caps=Caps(run_usd=cap),
297
+ features=active_features,
298
+ allow_unmetered=args.allow_unmetered,
299
+ clamp_output=args.clamp_output,
300
+ openai_upstream=overrides.get("openai") or DEFAULT_OPENAI_UPSTREAM,
301
+ gemini_upstream=overrides.get("gemini") or DEFAULT_GEMINI_UPSTREAM,
302
+ on_cap_exceeded=lambda payload: None,
303
+ on_loop_detected=lambda payload: None,
304
+ )
305
+ app = create_app(config, Ledger(), events=alerts.emit)
306
+ base = f"http://127.0.0.1:{args.port}"
307
+ if not args.quiet:
308
+ print("# Route API calls through AgentCap:")
309
+ print(f"export OPENAI_BASE_URL={base}/openai/v1")
310
+ print(f"export GOOGLE_GEMINI_BASE_URL={base}/gemini")
311
+ print(f"export GEMINI_API_BASE_URL={base}/gemini")
312
+ print(f"# run id: {run_id}, cap: ${cap:.2f}")
313
+ sys.stdout.flush()
314
+ uvicorn.run(app, host="127.0.0.1", port=args.port, log_level="info")
315
+ return 0
316
+
317
+
318
+ # ---------------------------------------------------------------------- status
319
+ def _status(args: argparse.Namespace) -> int:
320
+ if args.db:
321
+ ledger = Ledger()
322
+ now = datetime.now().astimezone()
323
+ midnight = now.replace(hour=0, minute=0, second=0, microsecond=0)
324
+ seconds_today = max((now - midnight).total_seconds(), 1.0)
325
+ try:
326
+ today = ledger.window_spent(seconds_today)
327
+ last_hour = ledger.window_spent(3600.0)
328
+ except Exception as exc:
329
+ print(f"agentcap: cannot read ledger: {exc}", file=sys.stderr)
330
+ return 1
331
+ print(f"db: {ledger.path}")
332
+ print(f"today: ${today:.4f}")
333
+ print(f"last hour: ${last_hour:.4f}")
334
+ return 0
335
+
336
+ url = f"http://127.0.0.1:{args.port}/_agentcap/status"
337
+ try:
338
+ response = httpx.get(url, timeout=5.0, trust_env=False)
339
+ response.raise_for_status()
340
+ payload = response.json()
341
+ except Exception as exc:
342
+ print(
343
+ f"agentcap: cannot read status from {url} ({type(exc).__name__})",
344
+ file=sys.stderr,
345
+ )
346
+ return 1
347
+ print(json.dumps(payload, indent=2, sort_keys=True))
348
+ return 0
349
+
350
+
351
+ def main(argv: list[str] | None = None) -> int:
352
+ parser = _build_parser()
353
+ args = parser.parse_args(argv)
354
+ if args.subcommand == "pricing":
355
+ try:
356
+ return _pricing(args)
357
+ except PricingError as exc:
358
+ print(f"agentcap: {exc}", file=sys.stderr)
359
+ return 2
360
+ if args.subcommand == "license":
361
+ return _license(args)
362
+ if args.subcommand == "run":
363
+ return _run(args)
364
+ if args.subcommand == "serve":
365
+ return _serve(args)
366
+ if args.subcommand == "status":
367
+ return _status(args)
368
+ parser.print_help()
369
+ return 0
370
+
371
+
372
+ if __name__ == "__main__":
373
+ raise SystemExit(main())