agentcap-cli 0.1.3__tar.gz → 0.1.4__tar.gz
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-0.1.3 → agentcap_cli-0.1.4}/PKG-INFO +31 -5
- {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/README.md +30 -4
- {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/pyproject.toml +1 -1
- {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/src/agentcap/__init__.py +1 -1
- {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/src/agentcap/cli.py +143 -7
- {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/src/agentcap/license.py +31 -0
- {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/src/agentcap/proxy.py +8 -0
- {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/src/agentcap/runner.py +81 -2
- {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/tests/test_cli.py +268 -3
- {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/tests/test_license.py +66 -2
- {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/tests/test_runner.py +123 -2
- {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/.gitignore +0 -0
- {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/LICENSE +0 -0
- {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/landing/README.md +0 -0
- {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/src/agentcap/adapters.py +0 -0
- {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/src/agentcap/alerts.py +0 -0
- {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/src/agentcap/clamp.py +0 -0
- {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/src/agentcap/data/pricing.json +0 -0
- {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/src/agentcap/demo.py +0 -0
- {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/src/agentcap/devtools/__init__.py +0 -0
- {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/src/agentcap/devtools/fake_upstream.py +0 -0
- {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/src/agentcap/estimate.py +0 -0
- {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/src/agentcap/ledger.py +0 -0
- {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/src/agentcap/loop.py +0 -0
- {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/src/agentcap/pricing.py +0 -0
- {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/src/agentcap/upstreams.py +0 -0
- {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/src/agentcap/usage.py +0 -0
- {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/tests/license_support.py +0 -0
- {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/tests/proxy_support.py +0 -0
- {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/tests/test_adapters.py +0 -0
- {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/tests/test_alerts.py +0 -0
- {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/tests/test_clamp.py +0 -0
- {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/tests/test_estimate.py +0 -0
- {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/tests/test_ledger.py +0 -0
- {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/tests/test_loop.py +0 -0
- {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/tests/test_pricing.py +0 -0
- {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/tests/test_proxy_cancel.py +0 -0
- {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/tests/test_proxy_core.py +0 -0
- {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/tests/test_proxy_extras.py +0 -0
- {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/tests/test_proxy_streaming.py +0 -0
- {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/tests/test_upstreams.py +0 -0
- {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/tests/test_usage.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: agentcap-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Summary: API-key spend guard for unattended Codex CLI and Gemini CLI runs: hard per-run cap, kills the process at the limit.
|
|
5
5
|
Project-URL: Homepage, https://agentcap.launchbench.studio
|
|
6
6
|
Project-URL: Repository, https://github.com/Blitz00X/agentcap
|
|
@@ -186,8 +186,9 @@ test suite uses `httpx.MockTransport` and never calls the network.
|
|
|
186
186
|
| Your own script | Any | Uses the OpenAI or Gemini SDK with base-URL environment variables. | No code changes: AgentCap sets `OPENAI_BASE_URL` and `GOOGLE_GEMINI_BASE_URL`. |
|
|
187
187
|
|
|
188
188
|
Tested with a real Gemini API key on Gemini CLI 0.66 (spend metered from Google's
|
|
189
|
-
usageMetadata, output clamped to the cap)
|
|
190
|
-
|
|
189
|
+
usageMetadata, output clamped to the cap). Codex CLI 0.165 verified against a real
|
|
190
|
+
OpenAI-compatible upstream (Ollama Cloud, gpt-oss:20b, cap stop with exit 3); not against
|
|
191
|
+
OpenAI billing. Gemini CLI 0.58 and Codex CLI 0.163 are tested end-to-end against a local fake API only.
|
|
191
192
|
|
|
192
193
|
**Platforms:** Linux and macOS. Windows: not yet (use WSL2).
|
|
193
194
|
|
|
@@ -333,8 +334,13 @@ the 0.x line.
|
|
|
333
334
|
- The Free tier (per-run cap) needs no key and makes no network calls.
|
|
334
335
|
|
|
335
336
|
After purchase you receive a licence key by email (via Polar). Activate it with
|
|
336
|
-
`agentcap license activate <KEY
|
|
337
|
-
this
|
|
337
|
+
`agentcap license activate <KEY>`. `agentcap license deactivate` asks the
|
|
338
|
+
license server to release this activation (freeing the slot for another
|
|
339
|
+
machine) and then removes the local license; if the server cannot be reached the
|
|
340
|
+
local license is kept and the command exits 1 so you can retry. Use
|
|
341
|
+
`agentcap license deactivate --local-only` to remove just the local file — the
|
|
342
|
+
activation then still counts toward your 3 machines until you release it in the
|
|
343
|
+
Polar customer portal.
|
|
338
344
|
|
|
339
345
|
## How it compares
|
|
340
346
|
|
|
@@ -348,6 +354,26 @@ this machine.
|
|
|
348
354
|
|
|
349
355
|
Pick what matches your setup — these tools can also be combined.
|
|
350
356
|
|
|
357
|
+
## FAQ
|
|
358
|
+
|
|
359
|
+
**Does clamping output hurt quality?** Yes, it can: when the budget is tight
|
|
360
|
+
AgentCap lowers max output tokens so the call fits; answers can be shorter or
|
|
361
|
+
cut off. Use `--no-clamp` to refuse instead.
|
|
362
|
+
|
|
363
|
+
**Does stopping roll back what the agent did?** No. At the cap AgentCap sends
|
|
364
|
+
SIGTERM then SIGKILL; files or remote changes already made stay. Use git / small
|
|
365
|
+
steps.
|
|
366
|
+
|
|
367
|
+
**Is the ledger my provider bill?** No. It is a local estimate from published
|
|
368
|
+
prices and reported usage; when usage is missing it charges the full reservation
|
|
369
|
+
(conservative). Your provider invoice is authoritative.
|
|
370
|
+
|
|
371
|
+
**Is the day cap shared across machines?** No. Hour/day caps are per machine
|
|
372
|
+
(local SQLite ledger). A license covers 3 machines, each with its own budget.
|
|
373
|
+
|
|
374
|
+
**What does one-time purchase include?** $12 once, all 0.x updates; not a
|
|
375
|
+
promise of future major versions. 14-day refund window per purchase terms.
|
|
376
|
+
|
|
351
377
|
## License
|
|
352
378
|
|
|
353
379
|
MIT — see [LICENSE](LICENSE). Copyright (c) 2026 Kutay Karahan.
|
|
@@ -156,8 +156,9 @@ test suite uses `httpx.MockTransport` and never calls the network.
|
|
|
156
156
|
| Your own script | Any | Uses the OpenAI or Gemini SDK with base-URL environment variables. | No code changes: AgentCap sets `OPENAI_BASE_URL` and `GOOGLE_GEMINI_BASE_URL`. |
|
|
157
157
|
|
|
158
158
|
Tested with a real Gemini API key on Gemini CLI 0.66 (spend metered from Google's
|
|
159
|
-
usageMetadata, output clamped to the cap)
|
|
160
|
-
|
|
159
|
+
usageMetadata, output clamped to the cap). Codex CLI 0.165 verified against a real
|
|
160
|
+
OpenAI-compatible upstream (Ollama Cloud, gpt-oss:20b, cap stop with exit 3); not against
|
|
161
|
+
OpenAI billing. Gemini CLI 0.58 and Codex CLI 0.163 are tested end-to-end against a local fake API only.
|
|
161
162
|
|
|
162
163
|
**Platforms:** Linux and macOS. Windows: not yet (use WSL2).
|
|
163
164
|
|
|
@@ -303,8 +304,13 @@ the 0.x line.
|
|
|
303
304
|
- The Free tier (per-run cap) needs no key and makes no network calls.
|
|
304
305
|
|
|
305
306
|
After purchase you receive a licence key by email (via Polar). Activate it with
|
|
306
|
-
`agentcap license activate <KEY
|
|
307
|
-
this
|
|
307
|
+
`agentcap license activate <KEY>`. `agentcap license deactivate` asks the
|
|
308
|
+
license server to release this activation (freeing the slot for another
|
|
309
|
+
machine) and then removes the local license; if the server cannot be reached the
|
|
310
|
+
local license is kept and the command exits 1 so you can retry. Use
|
|
311
|
+
`agentcap license deactivate --local-only` to remove just the local file — the
|
|
312
|
+
activation then still counts toward your 3 machines until you release it in the
|
|
313
|
+
Polar customer portal.
|
|
308
314
|
|
|
309
315
|
## How it compares
|
|
310
316
|
|
|
@@ -318,6 +324,26 @@ this machine.
|
|
|
318
324
|
|
|
319
325
|
Pick what matches your setup — these tools can also be combined.
|
|
320
326
|
|
|
327
|
+
## FAQ
|
|
328
|
+
|
|
329
|
+
**Does clamping output hurt quality?** Yes, it can: when the budget is tight
|
|
330
|
+
AgentCap lowers max output tokens so the call fits; answers can be shorter or
|
|
331
|
+
cut off. Use `--no-clamp` to refuse instead.
|
|
332
|
+
|
|
333
|
+
**Does stopping roll back what the agent did?** No. At the cap AgentCap sends
|
|
334
|
+
SIGTERM then SIGKILL; files or remote changes already made stay. Use git / small
|
|
335
|
+
steps.
|
|
336
|
+
|
|
337
|
+
**Is the ledger my provider bill?** No. It is a local estimate from published
|
|
338
|
+
prices and reported usage; when usage is missing it charges the full reservation
|
|
339
|
+
(conservative). Your provider invoice is authoritative.
|
|
340
|
+
|
|
341
|
+
**Is the day cap shared across machines?** No. Hour/day caps are per machine
|
|
342
|
+
(local SQLite ledger). A license covers 3 machines, each with its own budget.
|
|
343
|
+
|
|
344
|
+
**What does one-time purchase include?** $12 once, all 0.x updates; not a
|
|
345
|
+
promise of future major versions. 14-day refund window per purchase terms.
|
|
346
|
+
|
|
321
347
|
## License
|
|
322
348
|
|
|
323
349
|
MIT — see [LICENSE](LICENSE). Copyright (c) 2026 Kutay Karahan.
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "agentcap-cli"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.4"
|
|
8
8
|
description = "API-key spend guard for unattended Codex CLI and Gemini CLI runs: hard per-run cap, kills the process at the limit."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -18,6 +18,7 @@ from .alerts import dispatcher_from_env
|
|
|
18
18
|
from .ledger import Caps, Ledger
|
|
19
19
|
from .license import (
|
|
20
20
|
LicenseManager,
|
|
21
|
+
LicenseNetworkError,
|
|
21
22
|
features,
|
|
22
23
|
status_from_environment,
|
|
23
24
|
)
|
|
@@ -42,6 +43,45 @@ PRO_CAP_MESSAGE = (
|
|
|
42
43
|
"command was not started. Run `agentcap license status`, then "
|
|
43
44
|
'`agentcap license activate "YOUR_LICENSE_KEY"`.'
|
|
44
45
|
)
|
|
46
|
+
PRO_HINT = (
|
|
47
|
+
"hint: Pro is $12 one-time: https://agentcap.launchbench.studio/#pricing — "
|
|
48
|
+
'then `agentcap license activate "YOUR_LICENSE_KEY"`.'
|
|
49
|
+
)
|
|
50
|
+
PRO_ALERTS_MESSAGE = (
|
|
51
|
+
"agentcap: webhook/Telegram alerts require an active Pro license; "
|
|
52
|
+
"they are disabled for this run."
|
|
53
|
+
)
|
|
54
|
+
DEACTIVATE_NO_LICENSE = (
|
|
55
|
+
"agentcap: no active license on this machine; nothing to deactivate."
|
|
56
|
+
)
|
|
57
|
+
DEACTIVATE_LEGACY = (
|
|
58
|
+
"agentcap: local license removed. This activation was created by an older "
|
|
59
|
+
"version and could not be released on the server; manage activations in "
|
|
60
|
+
"the Polar customer portal."
|
|
61
|
+
)
|
|
62
|
+
DEACTIVATE_RELEASED = (
|
|
63
|
+
"agentcap: activation released on the license server and local license "
|
|
64
|
+
"removed. You can activate on another machine now."
|
|
65
|
+
)
|
|
66
|
+
DEACTIVATE_UNKNOWN = (
|
|
67
|
+
"agentcap: the license server no longer knows this activation (already "
|
|
68
|
+
"released?). Local license removed."
|
|
69
|
+
)
|
|
70
|
+
DEACTIVATE_LOCAL_ONLY = (
|
|
71
|
+
"agentcap: local license removed. The activation remains on the license "
|
|
72
|
+
"server; manage activations in the Polar customer portal."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _deactivate_failure(reason: str) -> str:
|
|
77
|
+
return (
|
|
78
|
+
"agentcap: could not reach the license server to release this "
|
|
79
|
+
f"activation ({reason}). Local license kept; retry "
|
|
80
|
+
"`agentcap license deactivate` when online, or use `--local-only` to "
|
|
81
|
+
"just remove the local file."
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
45
85
|
LICENSE_USAGE_MESSAGE = (
|
|
46
86
|
'Usage: agentcap license activate "YOUR_LICENSE_KEY" '
|
|
47
87
|
"(copy the key from your Polar purchase / customer portal)"
|
|
@@ -76,6 +116,11 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
76
116
|
help="status (default), features, activate <KEY>, or deactivate",
|
|
77
117
|
)
|
|
78
118
|
license_parser.add_argument("key", nargs="?", help="license key for 'activate'")
|
|
119
|
+
license_parser.add_argument(
|
|
120
|
+
"--local-only",
|
|
121
|
+
action="store_true",
|
|
122
|
+
help="deactivate: remove the local license without contacting the server",
|
|
123
|
+
)
|
|
79
124
|
|
|
80
125
|
run = subparsers.add_parser(
|
|
81
126
|
"run", help="run a command under a spend cap (use -- before the command)"
|
|
@@ -188,6 +233,81 @@ def _license_manager() -> LicenseManager:
|
|
|
188
233
|
return LicenseManager()
|
|
189
234
|
|
|
190
235
|
|
|
236
|
+
def _remove_local_license(manager: LicenseManager) -> bool:
|
|
237
|
+
try:
|
|
238
|
+
manager.clear()
|
|
239
|
+
except OSError as exc:
|
|
240
|
+
detail = exc.strerror or str(exc)
|
|
241
|
+
print(
|
|
242
|
+
"agentcap: could not remove the local license at "
|
|
243
|
+
f"{manager.path}: {detail}. Fix the file permission and try again.",
|
|
244
|
+
file=sys.stderr,
|
|
245
|
+
)
|
|
246
|
+
return False
|
|
247
|
+
return True
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _license_deactivate(manager: LicenseManager, *, local_only: bool = False) -> int:
|
|
251
|
+
cached = manager.cached_license()
|
|
252
|
+
|
|
253
|
+
if local_only:
|
|
254
|
+
# Remove whatever local file exists, even a stale or unreadable one.
|
|
255
|
+
if cached is None and not manager.path.exists():
|
|
256
|
+
print(DEACTIVATE_NO_LICENSE)
|
|
257
|
+
return 0
|
|
258
|
+
if not _remove_local_license(manager):
|
|
259
|
+
return 1
|
|
260
|
+
print(DEACTIVATE_LOCAL_ONLY)
|
|
261
|
+
return 0
|
|
262
|
+
|
|
263
|
+
if cached is None:
|
|
264
|
+
print(DEACTIVATE_NO_LICENSE)
|
|
265
|
+
return 0
|
|
266
|
+
|
|
267
|
+
if not cached.activation_id:
|
|
268
|
+
if not _remove_local_license(manager):
|
|
269
|
+
return 1
|
|
270
|
+
print(DEACTIVATE_LEGACY)
|
|
271
|
+
return 0
|
|
272
|
+
|
|
273
|
+
releaser = getattr(manager.validator, "deactivate", None)
|
|
274
|
+
if not callable(releaser):
|
|
275
|
+
print(
|
|
276
|
+
_deactivate_failure("server-side release is not supported"),
|
|
277
|
+
file=sys.stderr,
|
|
278
|
+
)
|
|
279
|
+
return 1
|
|
280
|
+
|
|
281
|
+
try:
|
|
282
|
+
status_code = releaser(cached.key, cached.activation_id)
|
|
283
|
+
except Exception as exc: # transport failures keep the local licence
|
|
284
|
+
if isinstance(exc, LicenseNetworkError):
|
|
285
|
+
reason = str(exc)
|
|
286
|
+
else:
|
|
287
|
+
reason = type(exc).__name__
|
|
288
|
+
# The key is a credential: never echo it, even from an error string.
|
|
289
|
+
if cached.key:
|
|
290
|
+
reason = reason.replace(cached.key, "<redacted>")
|
|
291
|
+
print(_deactivate_failure(reason), file=sys.stderr)
|
|
292
|
+
return 1
|
|
293
|
+
|
|
294
|
+
if status_code == 204:
|
|
295
|
+
if not _remove_local_license(manager):
|
|
296
|
+
return 1
|
|
297
|
+
print(DEACTIVATE_RELEASED)
|
|
298
|
+
return 0
|
|
299
|
+
|
|
300
|
+
if status_code == 404:
|
|
301
|
+
if not _remove_local_license(manager):
|
|
302
|
+
return 1
|
|
303
|
+
print(DEACTIVATE_UNKNOWN)
|
|
304
|
+
return 0
|
|
305
|
+
|
|
306
|
+
detail = "validation error" if status_code == 422 else f"HTTP {status_code}"
|
|
307
|
+
print(_deactivate_failure(detail), file=sys.stderr)
|
|
308
|
+
return 1
|
|
309
|
+
|
|
310
|
+
|
|
191
311
|
def _license_persist_error(exc: OSError, path) -> str:
|
|
192
312
|
detail = exc.strerror or str(exc)
|
|
193
313
|
return (
|
|
@@ -218,13 +338,7 @@ def _license(args: argparse.Namespace) -> int:
|
|
|
218
338
|
return 0 if status.valid else 1
|
|
219
339
|
|
|
220
340
|
if args.action == "deactivate":
|
|
221
|
-
|
|
222
|
-
manager.clear()
|
|
223
|
-
except OSError as exc:
|
|
224
|
-
print(_license_persist_error(exc, manager.path), file=sys.stderr)
|
|
225
|
-
return 1
|
|
226
|
-
print("license cache cleared")
|
|
227
|
-
return 0
|
|
341
|
+
return _license_deactivate(manager, local_only=bool(args.local_only))
|
|
228
342
|
|
|
229
343
|
status = status_from_environment()
|
|
230
344
|
if args.action == "features":
|
|
@@ -263,6 +377,25 @@ def _upstream_overrides() -> dict[str, str] | None:
|
|
|
263
377
|
return {override.provider: override.url for override in overrides}
|
|
264
378
|
|
|
265
379
|
|
|
380
|
+
def _warn_alerts_need_pro(active_features: set[str]) -> None:
|
|
381
|
+
"""Explain why configured alert channels will stay silent on Free."""
|
|
382
|
+
|
|
383
|
+
webhook = bool(os.environ.get("AGENTCAP_WEBHOOK_URL", "").strip())
|
|
384
|
+
telegram = bool(
|
|
385
|
+
os.environ.get("AGENTCAP_TELEGRAM_BOT_TOKEN", "").strip()
|
|
386
|
+
and os.environ.get("AGENTCAP_TELEGRAM_CHAT_ID", "").strip()
|
|
387
|
+
)
|
|
388
|
+
if not (webhook or telegram):
|
|
389
|
+
return
|
|
390
|
+
gated = ("webhook_alerts" not in active_features) or (
|
|
391
|
+
"telegram_alerts" not in active_features
|
|
392
|
+
)
|
|
393
|
+
if not gated:
|
|
394
|
+
return
|
|
395
|
+
print(PRO_ALERTS_MESSAGE, file=sys.stderr)
|
|
396
|
+
print(PRO_HINT, file=sys.stderr)
|
|
397
|
+
|
|
398
|
+
|
|
266
399
|
def _run(args: argparse.Namespace) -> int:
|
|
267
400
|
if _windows_unsupported():
|
|
268
401
|
print(WINDOWS_UNSUPPORTED, file=sys.stderr)
|
|
@@ -297,7 +430,9 @@ def _run(args: argparse.Namespace) -> int:
|
|
|
297
430
|
or (day_cap is not None and "day_cap" not in active_features)
|
|
298
431
|
):
|
|
299
432
|
print(PRO_CAP_MESSAGE, file=sys.stderr)
|
|
433
|
+
print(PRO_HINT, file=sys.stderr)
|
|
300
434
|
return 2
|
|
435
|
+
_warn_alerts_need_pro(active_features)
|
|
301
436
|
|
|
302
437
|
try:
|
|
303
438
|
runner = Runner(
|
|
@@ -341,6 +476,7 @@ def _serve(args: argparse.Namespace) -> int:
|
|
|
341
476
|
return 2
|
|
342
477
|
|
|
343
478
|
active_features = set(features(status_from_environment().tier))
|
|
479
|
+
_warn_alerts_need_pro(active_features)
|
|
344
480
|
run_id = f"serve-{int(time.time())}"
|
|
345
481
|
alerts = dispatcher_from_env(features=active_features)
|
|
346
482
|
config = ProxyConfig(
|
|
@@ -48,6 +48,7 @@ _UNCONFIGURED_ORG_IDS = frozenset({"", "POLAR_ORG_ID_PLACEHOLDER"})
|
|
|
48
48
|
|
|
49
49
|
VALIDATE_PATH = "/v1/customer-portal/license-keys/validate"
|
|
50
50
|
ACTIVATE_PATH = "/v1/customer-portal/license-keys/activate"
|
|
51
|
+
DEACTIVATE_PATH = "/v1/customer-portal/license-keys/deactivate"
|
|
51
52
|
|
|
52
53
|
NOT_CONFIGURED_REASON = "license server not configured (AGENTCAP_POLAR_ORG_ID)"
|
|
53
54
|
NOT_FOUND_REASON = "license key not found, revoked, disabled, or expired"
|
|
@@ -387,6 +388,28 @@ class PolarLicenseValidator:
|
|
|
387
388
|
return self._reject(code, data, key), None
|
|
388
389
|
raise LicenseNetworkError(f"license server returned HTTP {code}")
|
|
389
390
|
|
|
391
|
+
def deactivate(self, key: str, activation_id: str) -> int:
|
|
392
|
+
"""Release one activation slot on the licence server.
|
|
393
|
+
|
|
394
|
+
Returns the HTTP status code: ``204`` means the activation was
|
|
395
|
+
released, ``404`` means the key/activation is unknown (already
|
|
396
|
+
released, or not belonging to this key), ``422`` is a validation
|
|
397
|
+
error. Transport failures and 5xx responses raise
|
|
398
|
+
:class:`LicenseNetworkError` so the caller can keep the local licence.
|
|
399
|
+
"""
|
|
400
|
+
|
|
401
|
+
org = self._configured_organization_id()
|
|
402
|
+
if org is None:
|
|
403
|
+
raise LicenseNetworkError(NOT_CONFIGURED_REASON)
|
|
404
|
+
|
|
405
|
+
payload: dict[str, object] = {
|
|
406
|
+
"key": key,
|
|
407
|
+
"organization_id": org,
|
|
408
|
+
"activation_id": activation_id,
|
|
409
|
+
}
|
|
410
|
+
response = self._post(DEACTIVATE_PATH, payload)
|
|
411
|
+
return response.status_code
|
|
412
|
+
|
|
390
413
|
|
|
391
414
|
def status_from_environment() -> LicenseStatus:
|
|
392
415
|
"""Resolve the licence the way the CLI and runner do.
|
|
@@ -676,6 +699,14 @@ class LicenseManager:
|
|
|
676
699
|
return None
|
|
677
700
|
return cached.status
|
|
678
701
|
|
|
702
|
+
def cached_license(self) -> CachedLicense | None:
|
|
703
|
+
"""The cached licence entry (key included) or ``None``.
|
|
704
|
+
|
|
705
|
+
Callers must never print the key: it is a credential.
|
|
706
|
+
"""
|
|
707
|
+
|
|
708
|
+
return self._read()
|
|
709
|
+
|
|
679
710
|
def clear(self) -> None:
|
|
680
711
|
try:
|
|
681
712
|
self.path.unlink()
|
|
@@ -264,6 +264,9 @@ class _Proxy:
|
|
|
264
264
|
self.observed_requests = 0
|
|
265
265
|
#: Model requests refused before they reached the upstream.
|
|
266
266
|
self.refused_requests = 0
|
|
267
|
+
#: Worst-case cost of the most recently refused request, so the runner
|
|
268
|
+
#: can suggest a cap when the very first request did not fit.
|
|
269
|
+
self.last_refused_reserved_usd: Decimal | None = None
|
|
267
270
|
self.clamped_requests = 0
|
|
268
271
|
self.killed = False
|
|
269
272
|
self._thresholds_emitted: set[int] = set()
|
|
@@ -363,6 +366,9 @@ class _Proxy:
|
|
|
363
366
|
"blocked_count": self.blocked_count,
|
|
364
367
|
"observed_requests": self.observed_requests,
|
|
365
368
|
"refused_requests": self.refused_requests,
|
|
369
|
+
"last_refused_reserved_usd": _optional_money(
|
|
370
|
+
self.last_refused_reserved_usd
|
|
371
|
+
),
|
|
366
372
|
"clamped_requests": self.clamped_requests,
|
|
367
373
|
"killed": self.killed,
|
|
368
374
|
}
|
|
@@ -540,6 +546,7 @@ class _Proxy:
|
|
|
540
546
|
"""Budget truly exhausted: reject and kill (at most once per run)."""
|
|
541
547
|
|
|
542
548
|
self._count_blocked()
|
|
549
|
+
self.last_refused_reserved_usd = needed
|
|
543
550
|
payload = {
|
|
544
551
|
"scope": budget.scope,
|
|
545
552
|
"cap_usd": str(budget.cap),
|
|
@@ -578,6 +585,7 @@ class _Proxy:
|
|
|
578
585
|
"""
|
|
579
586
|
|
|
580
587
|
self._count_blocked()
|
|
588
|
+
self.last_refused_reserved_usd = needed
|
|
581
589
|
payload = {
|
|
582
590
|
"scope": budget.scope,
|
|
583
591
|
"cap_usd": str(budget.cap),
|
|
@@ -20,13 +20,18 @@ import time
|
|
|
20
20
|
import uuid
|
|
21
21
|
from dataclasses import dataclass
|
|
22
22
|
from datetime import datetime
|
|
23
|
-
from decimal import Decimal
|
|
23
|
+
from decimal import Decimal, ROUND_CEILING
|
|
24
24
|
from typing import Any, Callable, Iterable, Mapping, Sequence, TextIO
|
|
25
25
|
|
|
26
26
|
import httpx
|
|
27
27
|
import uvicorn
|
|
28
28
|
|
|
29
|
-
from .adapters import
|
|
29
|
+
from .adapters import (
|
|
30
|
+
AdapterConflict,
|
|
31
|
+
detect_agent,
|
|
32
|
+
ensure_codex_routing_is_safe,
|
|
33
|
+
prepare_child,
|
|
34
|
+
)
|
|
30
35
|
from .alerts import AlertDispatcher, dispatcher_from_env
|
|
31
36
|
from .ledger import Caps, Ledger
|
|
32
37
|
from .license import features as license_features
|
|
@@ -55,10 +60,43 @@ ZERO_REQUEST_WARNING = (
|
|
|
55
60
|
"Your agent may be calling the API directly "
|
|
56
61
|
"(see README: Supported CLIs)."
|
|
57
62
|
)
|
|
63
|
+
ZERO_REQUEST_HINT = (
|
|
64
|
+
"hint: agentcap saw no API requests. The wrapped CLI may be using a "
|
|
65
|
+
"subscription/OAuth login (not metered) or a base URL agentcap does not "
|
|
66
|
+
"set. For Codex use an API key (OPENAI_API_KEY); for Gemini use "
|
|
67
|
+
"GEMINI_API_KEY. See README 'Supported'."
|
|
68
|
+
)
|
|
69
|
+
UNSUPPORTED_CLI_HINT = (
|
|
70
|
+
"hint: supported: codex, gemini, or any SDK script that honours "
|
|
71
|
+
"OPENAI_BASE_URL / GOOGLE_GEMINI_BASE_URL. Run `agentcap run --cap 1 -- "
|
|
72
|
+
"python your_script.py`."
|
|
73
|
+
)
|
|
74
|
+
FIRST_REQUEST_HINT = (
|
|
75
|
+
"hint: the first request alone needs about {reserved} (estimated, worst "
|
|
76
|
+
"case) but the cap is {cap}. Try --cap {suggested} (about 3x one request) "
|
|
77
|
+
"or lower max output."
|
|
78
|
+
)
|
|
58
79
|
YELLOW = "\033[33m"
|
|
59
80
|
RESET = "\033[0m"
|
|
60
81
|
|
|
61
82
|
|
|
83
|
+
def _hint_usd(value: Decimal) -> str:
|
|
84
|
+
"""Short USD amount for hint lines: ``$0.01``, ``$0.0123``."""
|
|
85
|
+
|
|
86
|
+
text = f"{value:.4f}".rstrip("0")
|
|
87
|
+
whole, _, fraction = text.partition(".")
|
|
88
|
+
if len(fraction) < 2:
|
|
89
|
+
fraction = fraction.ljust(2, "0")
|
|
90
|
+
return f"${whole}.{fraction}"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _suggested_cap(reserved: Decimal) -> Decimal:
|
|
94
|
+
"""About 3x one request, rounded up to cents, never below $0.05."""
|
|
95
|
+
|
|
96
|
+
tripled = (reserved * 3).quantize(Decimal("0.01"), rounding=ROUND_CEILING)
|
|
97
|
+
return max(Decimal("0.05"), tripled)
|
|
98
|
+
|
|
99
|
+
|
|
62
100
|
@dataclass
|
|
63
101
|
class RunSummary:
|
|
64
102
|
"""Numbers printed when a run ends."""
|
|
@@ -492,6 +530,8 @@ class Runner:
|
|
|
492
530
|
self._print_summary(self.summary)
|
|
493
531
|
if self.child is not None and self.summary.requests == 0:
|
|
494
532
|
self._warn_no_requests()
|
|
533
|
+
self._hint_no_requests()
|
|
534
|
+
self._hint_first_request_refused(self.summary)
|
|
495
535
|
return exit_code
|
|
496
536
|
|
|
497
537
|
def _build_summary(self, reason: str, exit_code: int) -> RunSummary:
|
|
@@ -538,6 +578,45 @@ class Runner:
|
|
|
538
578
|
except Exception:
|
|
539
579
|
pass
|
|
540
580
|
|
|
581
|
+
def _hint_no_requests(self) -> None:
|
|
582
|
+
"""Actionable follow-up to the zero-request warning."""
|
|
583
|
+
|
|
584
|
+
self._print_hint(ZERO_REQUEST_HINT)
|
|
585
|
+
# An unknown CLI is the common cause: say what is supported as well.
|
|
586
|
+
if detect_agent(self.argv) is None:
|
|
587
|
+
self._print_hint(UNSUPPORTED_CLI_HINT)
|
|
588
|
+
|
|
589
|
+
def _hint_first_request_refused(self, summary: RunSummary) -> None:
|
|
590
|
+
"""Explain an oversize first request and suggest a feasible cap."""
|
|
591
|
+
|
|
592
|
+
if summary.refused_requests < 1 or summary.spent_usd != 0:
|
|
593
|
+
return
|
|
594
|
+
proxy = self._proxy
|
|
595
|
+
reserved = (
|
|
596
|
+
getattr(proxy, "last_refused_reserved_usd", None)
|
|
597
|
+
if proxy is not None
|
|
598
|
+
else None
|
|
599
|
+
)
|
|
600
|
+
if reserved is None:
|
|
601
|
+
return
|
|
602
|
+
reserved = Decimal(str(reserved))
|
|
603
|
+
if reserved <= 0:
|
|
604
|
+
return
|
|
605
|
+
cap = "none" if summary.cap_usd is None else _hint_usd(summary.cap_usd)
|
|
606
|
+
self._print_hint(
|
|
607
|
+
FIRST_REQUEST_HINT.format(
|
|
608
|
+
reserved=_hint_usd(reserved),
|
|
609
|
+
cap=cap,
|
|
610
|
+
suggested=f"{_suggested_cap(reserved):.2f}",
|
|
611
|
+
)
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
def _print_hint(self, hint: str) -> None:
|
|
615
|
+
try:
|
|
616
|
+
print(hint, file=self.stderr, flush=True)
|
|
617
|
+
except Exception:
|
|
618
|
+
pass
|
|
619
|
+
|
|
541
620
|
|
|
542
621
|
__all__ = [
|
|
543
622
|
"Runner",
|
|
@@ -87,6 +87,41 @@ def test_free_tier_refuses_each_pro_cap_individually(fake_runner, capsys, flag):
|
|
|
87
87
|
assert "require an active Pro license" in capsys.readouterr().err
|
|
88
88
|
|
|
89
89
|
|
|
90
|
+
def test_free_tier_pro_refusal_includes_the_upgrade_hint(fake_runner, capsys):
|
|
91
|
+
assert cli.main(["run", "--hour-cap", "1", "--", "true"]) == 2
|
|
92
|
+
err = capsys.readouterr().err
|
|
93
|
+
assert (
|
|
94
|
+
"hint: Pro is $12 one-time: "
|
|
95
|
+
"https://agentcap.launchbench.studio/#pricing — then "
|
|
96
|
+
'`agentcap license activate "YOUR_LICENSE_KEY"`.'
|
|
97
|
+
) in err
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_free_tier_alerts_are_gated_with_an_upgrade_hint(
|
|
101
|
+
fake_runner, monkeypatch, capsys
|
|
102
|
+
):
|
|
103
|
+
monkeypatch.setenv("AGENTCAP_WEBHOOK_URL", "https://example.invalid/hook")
|
|
104
|
+
|
|
105
|
+
assert cli.main(["run", "--", "true"]) == 0
|
|
106
|
+
# The run still starts; only the Pro delivery channel is disabled.
|
|
107
|
+
assert len(fake_runner.instances) == 1
|
|
108
|
+
err = capsys.readouterr().err
|
|
109
|
+
assert "webhook/Telegram alerts require an active Pro license" in err
|
|
110
|
+
assert "hint: Pro is $12 one-time" in err
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_pro_alerts_do_not_print_the_upgrade_hint(
|
|
114
|
+
fake_runner, monkeypatch, tmp_path, capsys
|
|
115
|
+
):
|
|
116
|
+
write_pro_cache(tmp_path / "license.json")
|
|
117
|
+
monkeypatch.setenv("AGENTCAP_WEBHOOK_URL", "https://example.invalid/hook")
|
|
118
|
+
|
|
119
|
+
assert cli.main(["run", "--", "true"]) == 0
|
|
120
|
+
err = capsys.readouterr().err
|
|
121
|
+
assert "require an active Pro license" not in err
|
|
122
|
+
assert "hint: Pro is $12 one-time" not in err
|
|
123
|
+
|
|
124
|
+
|
|
90
125
|
def test_pro_license_enables_hour_and_day_caps(fake_runner, tmp_path, capsys):
|
|
91
126
|
write_pro_cache(tmp_path / "license.json")
|
|
92
127
|
assert cli.main(
|
|
@@ -226,15 +261,20 @@ def test_license_status_reads_cached_polar_license(monkeypatch, tmp_path, capsys
|
|
|
226
261
|
|
|
227
262
|
def test_license_deactivate_clears_local_cache(monkeypatch, tmp_path, capsys):
|
|
228
263
|
path = tmp_path / "license.json"
|
|
229
|
-
write_pro_cache(path)
|
|
264
|
+
write_pro_cache(path) # older cache entry: no activation_id
|
|
230
265
|
monkeypatch.setenv("AGENTCAP_LICENSE_FILE", str(path))
|
|
231
266
|
|
|
232
267
|
assert cli.main(["license", "deactivate"]) == 0
|
|
233
268
|
assert not path.exists()
|
|
234
|
-
|
|
269
|
+
out = capsys.readouterr().out.strip()
|
|
270
|
+
assert "local license removed" in out
|
|
271
|
+
assert "older version" in out
|
|
272
|
+
assert "Polar customer portal" in out
|
|
273
|
+
assert "polar-key" not in out
|
|
235
274
|
|
|
236
275
|
# Idempotent: no cache is not an error.
|
|
237
276
|
assert cli.main(["license", "deactivate"]) == 0
|
|
277
|
+
assert "nothing to deactivate" in capsys.readouterr().out
|
|
238
278
|
|
|
239
279
|
|
|
240
280
|
def test_license_activate_without_key_fails(monkeypatch, tmp_path, capsys):
|
|
@@ -319,6 +359,7 @@ def test_license_deactivate_clear_error_is_reported_without_traceback(
|
|
|
319
359
|
monkeypatch, tmp_path, capsys
|
|
320
360
|
):
|
|
321
361
|
path = tmp_path / "license.json"
|
|
362
|
+
write_pro_cache(path) # legacy entry: removal is local-only
|
|
322
363
|
monkeypatch.setenv("AGENTCAP_LICENSE_FILE", str(path))
|
|
323
364
|
|
|
324
365
|
def fail_clear(self):
|
|
@@ -328,12 +369,236 @@ def test_license_deactivate_clear_error_is_reported_without_traceback(
|
|
|
328
369
|
|
|
329
370
|
assert cli.main(["license", "deactivate"]) == 1
|
|
330
371
|
captured = capsys.readouterr()
|
|
331
|
-
assert "
|
|
372
|
+
assert "could not remove the local license" in captured.err
|
|
332
373
|
assert str(path) in captured.err
|
|
333
374
|
assert "Permission denied" in captured.err
|
|
334
375
|
assert "Traceback" not in captured.err
|
|
335
376
|
|
|
336
377
|
|
|
378
|
+
@pytest.fixture()
|
|
379
|
+
def deactivation(monkeypatch, tmp_path):
|
|
380
|
+
"""A fake Polar validator for `license deactivate` + the cache path."""
|
|
381
|
+
|
|
382
|
+
state: dict = {"status": 204, "error": None, "calls": []}
|
|
383
|
+
|
|
384
|
+
class StubPolar:
|
|
385
|
+
persist = True
|
|
386
|
+
|
|
387
|
+
def __init__(self, *args, **kwargs) -> None:
|
|
388
|
+
pass
|
|
389
|
+
|
|
390
|
+
def validate(self, key, activation_id=None):
|
|
391
|
+
raise AssertionError("deactivate must not re-validate the key")
|
|
392
|
+
|
|
393
|
+
def activate(self, key):
|
|
394
|
+
raise AssertionError("deactivate must not activate")
|
|
395
|
+
|
|
396
|
+
def deactivate(self, key, activation_id):
|
|
397
|
+
state["calls"].append((key, activation_id))
|
|
398
|
+
if state["error"] is not None:
|
|
399
|
+
raise state["error"]
|
|
400
|
+
return state["status"]
|
|
401
|
+
|
|
402
|
+
monkeypatch.setattr(license_module, "PolarLicenseValidator", StubPolar)
|
|
403
|
+
path = tmp_path / "license.json"
|
|
404
|
+
monkeypatch.setenv("AGENTCAP_LICENSE_FILE", str(path))
|
|
405
|
+
return state, path
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _write_cache_with_activation(path, activation_id="act_cached", key="polar-key"):
|
|
409
|
+
path.write_text(
|
|
410
|
+
json.dumps(
|
|
411
|
+
{
|
|
412
|
+
"key": key,
|
|
413
|
+
"tier": "pro",
|
|
414
|
+
"valid": True,
|
|
415
|
+
"reason": "license valid",
|
|
416
|
+
"checked_at": time.time(),
|
|
417
|
+
"source": "polar",
|
|
418
|
+
"activation_id": activation_id,
|
|
419
|
+
}
|
|
420
|
+
),
|
|
421
|
+
encoding="utf-8",
|
|
422
|
+
)
|
|
423
|
+
return path
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def test_license_deactivate_without_local_license_is_a_noop(deactivation, capsys):
|
|
427
|
+
state, path = deactivation
|
|
428
|
+
assert cli.main(["license", "deactivate"]) == 0
|
|
429
|
+
captured = capsys.readouterr()
|
|
430
|
+
assert captured.out.strip() == (
|
|
431
|
+
"agentcap: no active license on this machine; nothing to deactivate."
|
|
432
|
+
)
|
|
433
|
+
assert state["calls"] == []
|
|
434
|
+
assert not path.exists()
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def test_license_deactivate_legacy_cache_removes_without_server_call(
|
|
438
|
+
deactivation, capsys
|
|
439
|
+
):
|
|
440
|
+
state, path = deactivation
|
|
441
|
+
_write_cache_with_activation(path, activation_id=None)
|
|
442
|
+
|
|
443
|
+
assert cli.main(["license", "deactivate"]) == 0
|
|
444
|
+
captured = capsys.readouterr()
|
|
445
|
+
assert not path.exists()
|
|
446
|
+
assert state["calls"] == []
|
|
447
|
+
assert captured.out.strip() == (
|
|
448
|
+
"agentcap: local license removed. This activation was created by an "
|
|
449
|
+
"older version and could not be released on the server; manage "
|
|
450
|
+
"activations in the Polar customer portal."
|
|
451
|
+
)
|
|
452
|
+
assert "polar-key" not in captured.out + captured.err
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def test_license_deactivate_released_on_server_removes_local_file(
|
|
456
|
+
deactivation, capsys
|
|
457
|
+
):
|
|
458
|
+
state, path = deactivation
|
|
459
|
+
_write_cache_with_activation(path, activation_id="act_cached")
|
|
460
|
+
|
|
461
|
+
assert cli.main(["license", "deactivate"]) == 0
|
|
462
|
+
captured = capsys.readouterr()
|
|
463
|
+
assert not path.exists()
|
|
464
|
+
assert state["calls"] == [("polar-key", "act_cached")]
|
|
465
|
+
assert captured.out.strip() == (
|
|
466
|
+
"agentcap: activation released on the license server and local license "
|
|
467
|
+
"removed. You can activate on another machine now."
|
|
468
|
+
)
|
|
469
|
+
assert "polar-key" not in captured.out + captured.err
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def test_license_deactivate_unknown_activation_still_removes_local_file(
|
|
473
|
+
deactivation, capsys
|
|
474
|
+
):
|
|
475
|
+
state, path = deactivation
|
|
476
|
+
state["status"] = 404
|
|
477
|
+
_write_cache_with_activation(path, activation_id="act_gone")
|
|
478
|
+
|
|
479
|
+
assert cli.main(["license", "deactivate"]) == 0
|
|
480
|
+
captured = capsys.readouterr()
|
|
481
|
+
assert not path.exists()
|
|
482
|
+
assert state["calls"] == [("polar-key", "act_gone")]
|
|
483
|
+
assert captured.out.strip() == (
|
|
484
|
+
"agentcap: the license server no longer knows this activation "
|
|
485
|
+
"(already released?). Local license removed."
|
|
486
|
+
)
|
|
487
|
+
assert "polar-key" not in captured.out + captured.err
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
@pytest.mark.parametrize(
|
|
491
|
+
("status", "reason"),
|
|
492
|
+
[(422, "validation error"), (429, "HTTP 429"), (400, "HTTP 400")],
|
|
493
|
+
)
|
|
494
|
+
def test_license_deactivate_rejected_keeps_local_license(
|
|
495
|
+
deactivation, capsys, status, reason
|
|
496
|
+
):
|
|
497
|
+
state, path = deactivation
|
|
498
|
+
state["status"] = status
|
|
499
|
+
_write_cache_with_activation(path, activation_id="act_cached")
|
|
500
|
+
|
|
501
|
+
assert cli.main(["license", "deactivate"]) == 1
|
|
502
|
+
captured = capsys.readouterr()
|
|
503
|
+
assert path.exists()
|
|
504
|
+
assert state["calls"] == [("polar-key", "act_cached")]
|
|
505
|
+
assert captured.err.startswith("agentcap: could not reach the license server")
|
|
506
|
+
assert f"({reason})" in captured.err
|
|
507
|
+
assert "Local license kept" in captured.err
|
|
508
|
+
assert "`agentcap license deactivate`" in captured.err
|
|
509
|
+
assert "`--local-only`" in captured.err
|
|
510
|
+
assert "polar-key" not in captured.out + captured.err
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
@pytest.mark.parametrize(
|
|
514
|
+
"error",
|
|
515
|
+
[
|
|
516
|
+
LicenseNetworkError("license server unreachable (ConnectError)"),
|
|
517
|
+
LicenseNetworkError("license server error (HTTP 503)"),
|
|
518
|
+
LicenseNetworkError("license server request timed out"),
|
|
519
|
+
],
|
|
520
|
+
)
|
|
521
|
+
def test_license_deactivate_network_failure_keeps_local_license(
|
|
522
|
+
deactivation, capsys, error
|
|
523
|
+
):
|
|
524
|
+
state, path = deactivation
|
|
525
|
+
state["error"] = error
|
|
526
|
+
_write_cache_with_activation(path, activation_id="act_cached")
|
|
527
|
+
|
|
528
|
+
assert cli.main(["license", "deactivate"]) == 1
|
|
529
|
+
captured = capsys.readouterr()
|
|
530
|
+
assert path.exists()
|
|
531
|
+
assert f"({error})" in captured.err
|
|
532
|
+
assert "could not reach the license server to release this activation" in (
|
|
533
|
+
captured.err
|
|
534
|
+
)
|
|
535
|
+
assert "polar-key" not in captured.out + captured.err
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def test_license_deactivate_unexpected_validator_error_is_reported(
|
|
539
|
+
deactivation, capsys
|
|
540
|
+
):
|
|
541
|
+
state, path = deactivation
|
|
542
|
+
state["error"] = RuntimeError("boom")
|
|
543
|
+
_write_cache_with_activation(path, activation_id="act_cached")
|
|
544
|
+
|
|
545
|
+
assert cli.main(["license", "deactivate"]) == 1
|
|
546
|
+
captured = capsys.readouterr()
|
|
547
|
+
assert path.exists()
|
|
548
|
+
assert "(RuntimeError)" in captured.err
|
|
549
|
+
assert "Traceback" not in captured.err
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def test_license_deactivate_local_only_skips_server(deactivation, capsys):
|
|
553
|
+
state, path = deactivation
|
|
554
|
+
_write_cache_with_activation(path, activation_id="act_cached")
|
|
555
|
+
|
|
556
|
+
assert cli.main(["license", "deactivate", "--local-only"]) == 0
|
|
557
|
+
captured = capsys.readouterr()
|
|
558
|
+
assert not path.exists()
|
|
559
|
+
assert state["calls"] == []
|
|
560
|
+
assert "local license removed" in captured.out
|
|
561
|
+
assert "remains on the license server" in captured.out
|
|
562
|
+
assert "polar-key" not in captured.out + captured.err
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def test_license_deactivate_local_only_removes_legacy_cache_without_warning(
|
|
566
|
+
deactivation, capsys
|
|
567
|
+
):
|
|
568
|
+
state, path = deactivation
|
|
569
|
+
_write_cache_with_activation(path, activation_id=None)
|
|
570
|
+
|
|
571
|
+
assert cli.main(["license", "deactivate", "--local-only"]) == 0
|
|
572
|
+
captured = capsys.readouterr()
|
|
573
|
+
assert not path.exists()
|
|
574
|
+
assert state["calls"] == []
|
|
575
|
+
assert "remains on the license server" in captured.out
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def test_license_deactivate_local_only_without_a_file_is_a_noop(deactivation, capsys):
|
|
579
|
+
state, path = deactivation
|
|
580
|
+
|
|
581
|
+
assert cli.main(["license", "deactivate", "--local-only"]) == 0
|
|
582
|
+
captured = capsys.readouterr()
|
|
583
|
+
assert captured.out.strip() == (
|
|
584
|
+
"agentcap: no active license on this machine; nothing to deactivate."
|
|
585
|
+
)
|
|
586
|
+
assert state["calls"] == []
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def test_license_deactivate_local_only_removes_an_unreadable_cache(
|
|
590
|
+
deactivation, capsys
|
|
591
|
+
):
|
|
592
|
+
state, path = deactivation
|
|
593
|
+
path.write_text("{not json", encoding="utf-8")
|
|
594
|
+
|
|
595
|
+
assert cli.main(["license", "deactivate", "--local-only"]) == 0
|
|
596
|
+
captured = capsys.readouterr()
|
|
597
|
+
assert not path.exists()
|
|
598
|
+
assert state["calls"] == []
|
|
599
|
+
assert "remains on the license server" in captured.out
|
|
600
|
+
|
|
601
|
+
|
|
337
602
|
@pytest.mark.parametrize("platform_name", ["nt"])
|
|
338
603
|
def test_run_refuses_windows_before_starting(
|
|
339
604
|
monkeypatch, fake_runner, capsys, platform_name
|
|
@@ -16,6 +16,7 @@ import pytest
|
|
|
16
16
|
from agentcap import __version__
|
|
17
17
|
from agentcap.license import (
|
|
18
18
|
ACTIVATE_PATH,
|
|
19
|
+
DEACTIVATE_PATH,
|
|
19
20
|
NOT_CONFIGURED_REASON,
|
|
20
21
|
NOT_FOUND_REASON,
|
|
21
22
|
POLAR_API_BASE,
|
|
@@ -76,10 +77,11 @@ def _offline_guard(monkeypatch):
|
|
|
76
77
|
class PolarStub:
|
|
77
78
|
"""Routes customer-portal calls to queued mocked responses."""
|
|
78
79
|
|
|
79
|
-
def __init__(self, *, validate=None, activate=None) -> None:
|
|
80
|
+
def __init__(self, *, validate=None, activate=None, deactivate=None) -> None:
|
|
80
81
|
self.requests: list[dict[str, object]] = []
|
|
81
82
|
self._validate = validate
|
|
82
83
|
self._activate = activate
|
|
84
|
+
self._deactivate = deactivate
|
|
83
85
|
self.transport = httpx.MockTransport(self._handle)
|
|
84
86
|
|
|
85
87
|
def _handle(self, request: httpx.Request) -> httpx.Response:
|
|
@@ -92,7 +94,11 @@ class PolarStub:
|
|
|
92
94
|
}
|
|
93
95
|
)
|
|
94
96
|
name = request.url.path.rsplit("/", 1)[-1]
|
|
95
|
-
responder =
|
|
97
|
+
responder = {
|
|
98
|
+
"validate": self._validate,
|
|
99
|
+
"activate": self._activate,
|
|
100
|
+
"deactivate": self._deactivate,
|
|
101
|
+
}.get(name)
|
|
96
102
|
if responder is None:
|
|
97
103
|
raise AssertionError(f"unexpected request to {request.url.path}")
|
|
98
104
|
if callable(responder):
|
|
@@ -585,6 +591,64 @@ def test_activate_rejection_keeps_cache_for_a_different_key(tmp_path):
|
|
|
585
591
|
assert manager.check().valid is True
|
|
586
592
|
|
|
587
593
|
|
|
594
|
+
# -------------------------------------------------------------- deactivation
|
|
595
|
+
def test_deactivate_releases_activation_with_key_and_org():
|
|
596
|
+
clock = FakeClock()
|
|
597
|
+
stub = PolarStub(deactivate=httpx.Response(204))
|
|
598
|
+
validator = make_validator(stub, clock)
|
|
599
|
+
|
|
600
|
+
assert validator.deactivate(KEY, "act_123") == 204
|
|
601
|
+
assert stub.paths == [DEACTIVATE_PATH]
|
|
602
|
+
assert DEACTIVATE_PATH == "/v1/customer-portal/license-keys/deactivate"
|
|
603
|
+
assert stub.payloads == [
|
|
604
|
+
{"key": KEY, "organization_id": ORG, "activation_id": "act_123"}
|
|
605
|
+
]
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def test_deactivate_unknown_activation_returns_404():
|
|
609
|
+
clock = FakeClock()
|
|
610
|
+
stub = PolarStub(deactivate=httpx.Response(404, json={"detail": "Not found"}))
|
|
611
|
+
assert make_validator(stub, clock).deactivate(KEY, "act_gone") == 404
|
|
612
|
+
assert stub.paths == [DEACTIVATE_PATH]
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def test_deactivate_validation_error_returns_422():
|
|
616
|
+
clock = FakeClock()
|
|
617
|
+
stub = PolarStub(
|
|
618
|
+
deactivate=httpx.Response(422, json={"detail": "activation_id is required"})
|
|
619
|
+
)
|
|
620
|
+
assert make_validator(stub, clock).deactivate(KEY, "act_bad") == 422
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def test_deactivate_network_error_raises_without_echoing_key():
|
|
624
|
+
clock = FakeClock()
|
|
625
|
+
stub = PolarStub(deactivate=raise_connect)
|
|
626
|
+
with pytest.raises(LicenseNetworkError) as excinfo:
|
|
627
|
+
make_validator(stub, clock).deactivate(KEY, "act_123")
|
|
628
|
+
assert KEY not in str(excinfo.value)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def test_deactivate_5xx_raises_network_error():
|
|
632
|
+
clock = FakeClock()
|
|
633
|
+
stub = PolarStub(deactivate=httpx.Response(503, json={"detail": "unavailable"}))
|
|
634
|
+
with pytest.raises(LicenseNetworkError):
|
|
635
|
+
make_validator(stub, clock).deactivate(KEY, "act_123")
|
|
636
|
+
assert stub.paths == [DEACTIVATE_PATH]
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def test_deactivate_without_org_makes_no_request(monkeypatch):
|
|
640
|
+
monkeypatch.setenv("AGENTCAP_POLAR_ORG_ID", "POLAR_ORG_ID_PLACEHOLDER")
|
|
641
|
+
stub = PolarStub() # any request would raise
|
|
642
|
+
validator = PolarLicenseValidator(
|
|
643
|
+
base_url=BASE,
|
|
644
|
+
client=httpx.Client(transport=stub.transport),
|
|
645
|
+
clock=FakeClock(),
|
|
646
|
+
)
|
|
647
|
+
with pytest.raises(LicenseNetworkError):
|
|
648
|
+
validator.deactivate(KEY, "act_123")
|
|
649
|
+
assert stub.requests == []
|
|
650
|
+
|
|
651
|
+
|
|
588
652
|
# --------------------------------------------------------------------- cache
|
|
589
653
|
def test_cache_is_reused_within_seven_days_without_http(tmp_path):
|
|
590
654
|
clock = FakeClock()
|
|
@@ -279,8 +279,11 @@ def test_zero_request_warning_is_yellow_on_a_tty(tmp_path):
|
|
|
279
279
|
stderr = _TtyStringIO()
|
|
280
280
|
runner, _ = _runner(tmp_path, [sys.executable, "-c", "pass"], err=stderr)
|
|
281
281
|
assert runner.run() == 0
|
|
282
|
-
|
|
283
|
-
assert
|
|
282
|
+
output = stderr.getvalue()
|
|
283
|
+
assert "\033[33m" in output
|
|
284
|
+
assert "\033[0m" in output
|
|
285
|
+
# The actionable hint follows the coloured warning, uncoloured.
|
|
286
|
+
assert output.rstrip().splitlines()[-1].startswith("hint: ")
|
|
284
287
|
|
|
285
288
|
|
|
286
289
|
def test_no_zero_request_warning_when_requests_were_metered(tmp_path):
|
|
@@ -426,3 +429,121 @@ def test_terminate_kills_grandchild_after_leader_already_exited(tmp_path):
|
|
|
426
429
|
while time.monotonic() < deadline and _process_alive(grandchild):
|
|
427
430
|
time.sleep(0.02)
|
|
428
431
|
assert not _process_alive(grandchild)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
# ------------------------------------------------------------ actionable hints
|
|
435
|
+
def _fake_cli(tmp_path: Path, name: str = "codex") -> Path:
|
|
436
|
+
path = tmp_path / name
|
|
437
|
+
path.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
|
|
438
|
+
path.chmod(0o755)
|
|
439
|
+
return path
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def test_zero_request_hint_for_supported_cli(tmp_path):
|
|
443
|
+
runner, stderr = _runner(tmp_path, [str(_fake_cli(tmp_path))])
|
|
444
|
+
assert runner.run() == 0
|
|
445
|
+
output = stderr.getvalue()
|
|
446
|
+
assert "hint: agentcap saw no API requests." in output
|
|
447
|
+
assert "OPENAI_API_KEY" in output
|
|
448
|
+
assert "GEMINI_API_KEY" in output
|
|
449
|
+
assert "README 'Supported'" in output
|
|
450
|
+
assert "hint: supported: codex, gemini" not in output
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def test_zero_request_hint_for_unknown_cli_names_supported_tools(tmp_path):
|
|
454
|
+
runner, stderr = _runner(tmp_path, [sys.executable, "-c", "pass"])
|
|
455
|
+
assert runner.run() == 0
|
|
456
|
+
output = stderr.getvalue()
|
|
457
|
+
assert "hint: agentcap saw no API requests." in output
|
|
458
|
+
assert "hint: supported: codex, gemini" in output
|
|
459
|
+
assert "OPENAI_BASE_URL / GOOGLE_GEMINI_BASE_URL" in output
|
|
460
|
+
assert "agentcap run --cap 1 -- python your_script.py" in output
|
|
461
|
+
# The no-traffic hint comes first, then the supported-CLI hint.
|
|
462
|
+
assert output.index("hint: agentcap saw no API requests.") < output.index(
|
|
463
|
+
"hint: supported:"
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def test_unknown_cli_hint_is_not_printed_when_traffic_was_metered(tmp_path):
|
|
468
|
+
child = [
|
|
469
|
+
sys.executable,
|
|
470
|
+
"-c",
|
|
471
|
+
(
|
|
472
|
+
"import os, httpx\n"
|
|
473
|
+
"httpx.post(os.environ['OPENAI_BASE_URL'] + '/chat/completions',\n"
|
|
474
|
+
" json={'model': 'gpt-4o-mini',\n"
|
|
475
|
+
" 'messages': [{'role': 'user', 'content': 'hi'}]},\n"
|
|
476
|
+
" timeout=5)\n"
|
|
477
|
+
),
|
|
478
|
+
]
|
|
479
|
+
runner, stderr = _runner(tmp_path, child, cap="1")
|
|
480
|
+
assert runner.run() == 0
|
|
481
|
+
output = stderr.getvalue()
|
|
482
|
+
assert "hint: supported:" not in output
|
|
483
|
+
assert "hint: agentcap saw no API requests." not in output
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def test_first_request_refusal_hint_suggests_three_times_the_reservation(tmp_path):
|
|
487
|
+
child = [
|
|
488
|
+
sys.executable,
|
|
489
|
+
"-c",
|
|
490
|
+
(
|
|
491
|
+
"import os, sys, httpx\n"
|
|
492
|
+
"httpx.post(os.environ['OPENAI_BASE_URL'] + '/chat/completions',\n"
|
|
493
|
+
" json={'model': 'gpt-4o-mini',\n"
|
|
494
|
+
" 'messages': [{'role': 'user', 'content': 'hi'}],\n"
|
|
495
|
+
" 'max_completion_tokens': 100000},\n"
|
|
496
|
+
" timeout=5)\n"
|
|
497
|
+
"sys.exit(0)\n"
|
|
498
|
+
),
|
|
499
|
+
]
|
|
500
|
+
runner, stderr = _runner(tmp_path, child, cap="0.01", clamp_output=False)
|
|
501
|
+
assert runner.run() == 0
|
|
502
|
+
summary = runner.summary
|
|
503
|
+
assert summary is not None
|
|
504
|
+
assert summary.spent_usd == 0
|
|
505
|
+
assert summary.refused_requests == 1
|
|
506
|
+
reserved = runner._proxy.last_refused_reserved_usd
|
|
507
|
+
assert reserved is not None and reserved > 0
|
|
508
|
+
from agentcap.runner import _hint_usd, _suggested_cap
|
|
509
|
+
|
|
510
|
+
output = stderr.getvalue()
|
|
511
|
+
assert (
|
|
512
|
+
f"hint: the first request alone needs about {_hint_usd(reserved)}"
|
|
513
|
+
in output
|
|
514
|
+
)
|
|
515
|
+
assert "but the cap is $0.01." in output
|
|
516
|
+
assert (
|
|
517
|
+
f"Try --cap {_suggested_cap(reserved):.2f} (about 3x one request)"
|
|
518
|
+
in output
|
|
519
|
+
)
|
|
520
|
+
assert "or lower max output." in output
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def test_no_first_request_hint_when_requests_were_served(tmp_path):
|
|
524
|
+
child = [
|
|
525
|
+
sys.executable,
|
|
526
|
+
"-c",
|
|
527
|
+
(
|
|
528
|
+
"import os, httpx\n"
|
|
529
|
+
"httpx.post(os.environ['OPENAI_BASE_URL'] + '/chat/completions',\n"
|
|
530
|
+
" json={'model': 'gpt-4o-mini',\n"
|
|
531
|
+
" 'messages': [{'role': 'user', 'content': 'hi'}]},\n"
|
|
532
|
+
" timeout=5)\n"
|
|
533
|
+
),
|
|
534
|
+
]
|
|
535
|
+
runner, stderr = _runner(tmp_path, child, cap="1")
|
|
536
|
+
assert runner.run() == 0
|
|
537
|
+
assert "hint: the first request alone" not in stderr.getvalue()
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def test_suggested_cap_rounds_up_and_has_a_floor():
|
|
541
|
+
from agentcap.runner import _hint_usd, _suggested_cap
|
|
542
|
+
|
|
543
|
+
assert _suggested_cap(Decimal("0.001")) == Decimal("0.05")
|
|
544
|
+
assert _suggested_cap(Decimal("0.0123")) == Decimal("0.05")
|
|
545
|
+
assert _suggested_cap(Decimal("0.02")) == Decimal("0.06")
|
|
546
|
+
assert _suggested_cap(Decimal("0.06")) == Decimal("0.18")
|
|
547
|
+
assert _hint_usd(Decimal("0.01")) == "$0.01"
|
|
548
|
+
assert _hint_usd(Decimal("0.0123")) == "$0.0123"
|
|
549
|
+
assert _hint_usd(Decimal("0")) == "$0.00"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|