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/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())
|