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.
Files changed (42) hide show
  1. {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/PKG-INFO +31 -5
  2. {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/README.md +30 -4
  3. {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/pyproject.toml +1 -1
  4. {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/src/agentcap/__init__.py +1 -1
  5. {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/src/agentcap/cli.py +143 -7
  6. {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/src/agentcap/license.py +31 -0
  7. {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/src/agentcap/proxy.py +8 -0
  8. {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/src/agentcap/runner.py +81 -2
  9. {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/tests/test_cli.py +268 -3
  10. {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/tests/test_license.py +66 -2
  11. {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/tests/test_runner.py +123 -2
  12. {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/.gitignore +0 -0
  13. {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/LICENSE +0 -0
  14. {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/landing/README.md +0 -0
  15. {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/src/agentcap/adapters.py +0 -0
  16. {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/src/agentcap/alerts.py +0 -0
  17. {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/src/agentcap/clamp.py +0 -0
  18. {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/src/agentcap/data/pricing.json +0 -0
  19. {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/src/agentcap/demo.py +0 -0
  20. {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/src/agentcap/devtools/__init__.py +0 -0
  21. {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/src/agentcap/devtools/fake_upstream.py +0 -0
  22. {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/src/agentcap/estimate.py +0 -0
  23. {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/src/agentcap/ledger.py +0 -0
  24. {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/src/agentcap/loop.py +0 -0
  25. {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/src/agentcap/pricing.py +0 -0
  26. {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/src/agentcap/upstreams.py +0 -0
  27. {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/src/agentcap/usage.py +0 -0
  28. {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/tests/license_support.py +0 -0
  29. {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/tests/proxy_support.py +0 -0
  30. {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/tests/test_adapters.py +0 -0
  31. {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/tests/test_alerts.py +0 -0
  32. {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/tests/test_clamp.py +0 -0
  33. {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/tests/test_estimate.py +0 -0
  34. {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/tests/test_ledger.py +0 -0
  35. {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/tests/test_loop.py +0 -0
  36. {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/tests/test_pricing.py +0 -0
  37. {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/tests/test_proxy_cancel.py +0 -0
  38. {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/tests/test_proxy_core.py +0 -0
  39. {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/tests/test_proxy_extras.py +0 -0
  40. {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/tests/test_proxy_streaming.py +0 -0
  41. {agentcap_cli-0.1.3 → agentcap_cli-0.1.4}/tests/test_upstreams.py +0 -0
  42. {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
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); Gemini CLI 0.58 and Codex CLI 0.163 are
190
- tested end-to-end against a local fake API only, not yet with real paid keys.
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>`; `agentcap license deactivate` removes it from
337
- this machine.
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); Gemini CLI 0.58 and Codex CLI 0.163 are
160
- tested end-to-end against a local fake API only, not yet with real paid keys.
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>`; `agentcap license deactivate` removes it from
307
- this machine.
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.3"
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"
@@ -2,6 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.1.3"
5
+ __version__ = "0.1.4"
6
6
 
7
7
  __all__ = ["__version__"]
@@ -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
- try:
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 AdapterConflict, ensure_codex_routing_is_safe, prepare_child
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
- assert capsys.readouterr().out.strip() == "license cache cleared"
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 "license validated, but could not save activation" in captured.err
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 = self._validate if name == "validate" else self._activate
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
- assert "\033[33m" in stderr.getvalue()
283
- assert stderr.getvalue().rstrip().endswith("\033[0m")
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