agentcap-cli 0.1.1__tar.gz → 0.1.3__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 (43) hide show
  1. {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/PKG-INFO +18 -10
  2. {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/README.md +17 -9
  3. {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/pyproject.toml +1 -1
  4. {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/src/agentcap/__init__.py +1 -1
  5. {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/src/agentcap/adapters.py +175 -27
  6. {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/src/agentcap/clamp.py +8 -0
  7. {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/src/agentcap/cli.py +61 -16
  8. {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/src/agentcap/devtools/fake_upstream.py +42 -2
  9. {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/src/agentcap/license.py +15 -8
  10. {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/src/agentcap/proxy.py +100 -4
  11. {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/src/agentcap/runner.py +32 -15
  12. agentcap_cli-0.1.3/tests/test_adapters.py +508 -0
  13. {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/tests/test_clamp.py +127 -0
  14. {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/tests/test_cli.py +134 -8
  15. {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/tests/test_estimate.py +37 -0
  16. {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/tests/test_license.py +39 -0
  17. agentcap_cli-0.1.3/tests/test_proxy_cancel.py +98 -0
  18. {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/tests/test_runner.py +54 -0
  19. {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/tests/test_upstreams.py +78 -0
  20. agentcap_cli-0.1.1/tests/test_adapters.py +0 -251
  21. {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/.gitignore +0 -0
  22. {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/LICENSE +0 -0
  23. {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/landing/README.md +0 -0
  24. {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/src/agentcap/alerts.py +0 -0
  25. {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/src/agentcap/data/pricing.json +0 -0
  26. {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/src/agentcap/demo.py +0 -0
  27. {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/src/agentcap/devtools/__init__.py +0 -0
  28. {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/src/agentcap/estimate.py +0 -0
  29. {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/src/agentcap/ledger.py +0 -0
  30. {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/src/agentcap/loop.py +0 -0
  31. {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/src/agentcap/pricing.py +0 -0
  32. {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/src/agentcap/upstreams.py +0 -0
  33. {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/src/agentcap/usage.py +0 -0
  34. {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/tests/license_support.py +0 -0
  35. {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/tests/proxy_support.py +0 -0
  36. {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/tests/test_alerts.py +0 -0
  37. {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/tests/test_ledger.py +0 -0
  38. {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/tests/test_loop.py +0 -0
  39. {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/tests/test_pricing.py +0 -0
  40. {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/tests/test_proxy_core.py +0 -0
  41. {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/tests/test_proxy_extras.py +0 -0
  42. {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/tests/test_proxy_streaming.py +0 -0
  43. {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/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.1
3
+ Version: 0.1.3
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
@@ -71,6 +71,8 @@ pipx install agentcap-cli
71
71
  pip install agentcap-cli
72
72
  ```
73
73
 
74
+ No pipx? macOS: `brew install pipx && pipx ensurepath`; Linux: `python3 -m pip install --user pipx && python3 -m pipx ensurepath`.
75
+
74
76
  From a checkout:
75
77
 
76
78
  ```bash
@@ -126,12 +128,13 @@ sign-in (OAuth/subscription) traffic is not metered.
126
128
  already sets `model_provider`, `model_providers.*` or `openai_base_url` with
127
129
  `-c`/`--config`, AgentCap refuses to start (exit code 2) rather than let
128
130
  traffic bypass the proxy.
129
- - **Gemini CLI 0.58–0.66** needs an auth type in headless mode, so AgentCap
130
- writes a temporary settings file (merged over
131
- `$GEMINI_CLI_SYSTEM_SETTINGS_PATH` when set) that selects API-key auth, points
132
- Gemini at `GEMINI_CLI_SYSTEM_SETTINGS_PATH` for the run, and removes it
133
- afterwards; `~/.gemini` is never modified. `--trust-workspace` adds
134
- `GEMINI_CLI_TRUST_WORKSPACE=true` for Gemini.
131
+ - **Gemini CLI 0.58–0.66** needs an auth type in headless mode, and from 0.60 it
132
+ also refuses system settings files that are not root-owned. AgentCap therefore
133
+ runs Gemini with a temporary `GEMINI_CLI_HOME`: its `.gemini` directory links
134
+ your real entries (history, `trustedFolders.json`, credentials) and holds a
135
+ copied `settings.json` with the auth type forced to `gemini-api-key`. The
136
+ overlay is removed afterwards; nothing in your real `~/.gemini` is modified.
137
+ `--trust-workspace` adds `GEMINI_CLI_TRUST_WORKSPACE=true` for Gemini.
135
138
  - `npx @openai/codex ...` and `npx @google/gemini-cli ...` are recognised too.
136
139
 
137
140
  Add `--trust-workspace` for Gemini CLI runs in folders you have not trusted yet;
@@ -179,11 +182,14 @@ test suite uses `httpx.MockTransport` and never calls the network.
179
182
  | Tool | Verified version | Command and auth | Routing |
180
183
  | --- | --- | --- | --- |
181
184
  | Codex CLI | 0.163 | `codex exec "…"` with `OPENAI_API_KEY`; ChatGPT sign-in is not metered. | Automatically, via an injected custom provider (`-c model_provider=agentcap …`). |
182
- | Gemini CLI | 0.58–0.66 | `gemini -p "…"` with `GEMINI_API_KEY`; Google sign-in is not metered. | Automatically, via a temporary `GEMINI_CLI_SYSTEM_SETTINGS_PATH` overlay (api-key auth). Add `--trust-workspace` to `agentcap run` for untrusted folders. |
185
+ | Gemini CLI | 0.58–0.66 | `gemini -p "…"` with `GEMINI_API_KEY`; Google sign-in is not metered. | Automatically, via a temporary `GEMINI_CLI_HOME` overlay (your `~/.gemini` is linked, its `settings.json` is copied with the auth type forced to `gemini-api-key`; nothing in `~/.gemini` is modified). Add `--trust-workspace` to `agentcap run` for untrusted folders. |
183
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`. |
184
187
 
185
- Tested end-to-end against a local fake API with the real Codex and Gemini CLIs;
186
- not yet verified against live billing with a real key.
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.
191
+
192
+ **Platforms:** Linux and macOS. Windows: not yet (use WSL2).
187
193
 
188
194
  ## How it works
189
195
 
@@ -346,6 +352,8 @@ Pick what matches your setup — these tools can also be combined.
346
352
 
347
353
  MIT — see [LICENSE](LICENSE). Copyright (c) 2026 Kutay Karahan.
348
354
 
355
+ After purchase, copy your license key from your Polar order (purchase email / Polar customer portal), then run `agentcap license activate "YOUR_LICENSE_KEY"` and check with `agentcap license status` (expect `tier: pro`).
356
+
349
357
  The Pro licence key is separate from the source licence. How it is checked:
350
358
 
351
359
  - On activation the key is validated against Polar's public licence API
@@ -41,6 +41,8 @@ pipx install agentcap-cli
41
41
  pip install agentcap-cli
42
42
  ```
43
43
 
44
+ No pipx? macOS: `brew install pipx && pipx ensurepath`; Linux: `python3 -m pip install --user pipx && python3 -m pipx ensurepath`.
45
+
44
46
  From a checkout:
45
47
 
46
48
  ```bash
@@ -96,12 +98,13 @@ sign-in (OAuth/subscription) traffic is not metered.
96
98
  already sets `model_provider`, `model_providers.*` or `openai_base_url` with
97
99
  `-c`/`--config`, AgentCap refuses to start (exit code 2) rather than let
98
100
  traffic bypass the proxy.
99
- - **Gemini CLI 0.58–0.66** needs an auth type in headless mode, so AgentCap
100
- writes a temporary settings file (merged over
101
- `$GEMINI_CLI_SYSTEM_SETTINGS_PATH` when set) that selects API-key auth, points
102
- Gemini at `GEMINI_CLI_SYSTEM_SETTINGS_PATH` for the run, and removes it
103
- afterwards; `~/.gemini` is never modified. `--trust-workspace` adds
104
- `GEMINI_CLI_TRUST_WORKSPACE=true` for Gemini.
101
+ - **Gemini CLI 0.58–0.66** needs an auth type in headless mode, and from 0.60 it
102
+ also refuses system settings files that are not root-owned. AgentCap therefore
103
+ runs Gemini with a temporary `GEMINI_CLI_HOME`: its `.gemini` directory links
104
+ your real entries (history, `trustedFolders.json`, credentials) and holds a
105
+ copied `settings.json` with the auth type forced to `gemini-api-key`. The
106
+ overlay is removed afterwards; nothing in your real `~/.gemini` is modified.
107
+ `--trust-workspace` adds `GEMINI_CLI_TRUST_WORKSPACE=true` for Gemini.
105
108
  - `npx @openai/codex ...` and `npx @google/gemini-cli ...` are recognised too.
106
109
 
107
110
  Add `--trust-workspace` for Gemini CLI runs in folders you have not trusted yet;
@@ -149,11 +152,14 @@ test suite uses `httpx.MockTransport` and never calls the network.
149
152
  | Tool | Verified version | Command and auth | Routing |
150
153
  | --- | --- | --- | --- |
151
154
  | Codex CLI | 0.163 | `codex exec "…"` with `OPENAI_API_KEY`; ChatGPT sign-in is not metered. | Automatically, via an injected custom provider (`-c model_provider=agentcap …`). |
152
- | Gemini CLI | 0.58–0.66 | `gemini -p "…"` with `GEMINI_API_KEY`; Google sign-in is not metered. | Automatically, via a temporary `GEMINI_CLI_SYSTEM_SETTINGS_PATH` overlay (api-key auth). Add `--trust-workspace` to `agentcap run` for untrusted folders. |
155
+ | Gemini CLI | 0.58–0.66 | `gemini -p "…"` with `GEMINI_API_KEY`; Google sign-in is not metered. | Automatically, via a temporary `GEMINI_CLI_HOME` overlay (your `~/.gemini` is linked, its `settings.json` is copied with the auth type forced to `gemini-api-key`; nothing in `~/.gemini` is modified). Add `--trust-workspace` to `agentcap run` for untrusted folders. |
153
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`. |
154
157
 
155
- Tested end-to-end against a local fake API with the real Codex and Gemini CLIs;
156
- not yet verified against live billing with a real key.
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.
161
+
162
+ **Platforms:** Linux and macOS. Windows: not yet (use WSL2).
157
163
 
158
164
  ## How it works
159
165
 
@@ -316,6 +322,8 @@ Pick what matches your setup — these tools can also be combined.
316
322
 
317
323
  MIT — see [LICENSE](LICENSE). Copyright (c) 2026 Kutay Karahan.
318
324
 
325
+ After purchase, copy your license key from your Polar order (purchase email / Polar customer portal), then run `agentcap license activate "YOUR_LICENSE_KEY"` and check with `agentcap license status` (expect `tier: pro`).
326
+
319
327
  The Pro licence key is separate from the source licence. How it is checked:
320
328
 
321
329
  - On activation the key is validated against Polar's public licence API
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "agentcap-cli"
7
- version = "0.1.1"
7
+ version = "0.1.3"
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.1"
5
+ __version__ = "0.1.3"
6
6
 
7
7
  __all__ = ["__version__"]
@@ -6,13 +6,19 @@ Some agent CLIs do not honour a plain base-URL environment variable:
6
6
  transport. It has to be pointed at the proxy with ``-c`` configuration
7
7
  overrides (``model_provider`` plus a custom provider that uses the Responses
8
8
  API over plain HTTP).
9
- * Gemini CLI reads a settings file for its authentication type when it runs
10
- headless; a gateway base URL alone makes it pick an auth type that headless
11
- mode rejects. A temporary settings file selects ``gemini-api-key`` and is
12
- removed after the run.
13
-
14
- Everything here is pure (apart from creating the temporary Gemini settings
15
- directory) so it can be unit-tested without spawning a process.
9
+ * Gemini CLI reads settings for its authentication type when it runs headless;
10
+ a gateway base URL alone makes it pick an auth type that headless mode
11
+ rejects. Gemini CLI >= 0.60 also refuses system settings files whose parent
12
+ directory is not owned by root, so the old
13
+ ``GEMINI_CLI_SYSTEM_SETTINGS_PATH`` overlay is silently skipped. Instead a
14
+ temporary ``GEMINI_CLI_HOME`` is used: its ``.gemini`` directory links every
15
+ real user entry (history, ``trustedFolders.json``, OAuth credentials, ...)
16
+ and carries a real ``settings.json`` that selects ``gemini-api-key``. The
17
+ overlay is removed after the run and nothing in the real ``~/.gemini`` is
18
+ modified.
19
+
20
+ Everything here is pure (apart from creating the temporary Gemini home) so it
21
+ can be unit-tested without spawning a process.
16
22
  """
17
23
 
18
24
  from __future__ import annotations
@@ -20,6 +26,7 @@ from __future__ import annotations
20
26
  import json
21
27
  import os
22
28
  import shutil
29
+ import sys
23
30
  import tempfile
24
31
  from dataclasses import dataclass, field
25
32
  from pathlib import Path
@@ -180,17 +187,128 @@ def ensure_codex_routing_is_safe(argv: Sequence[str]) -> None:
180
187
 
181
188
 
182
189
  # -------------------------------------------------------------------- Gemini
183
- def _read_settings(path: str | None) -> dict[str, object]:
184
- """Read the existing settings JSON; unreadable/invalid files are ignored."""
190
+ GEMINI_SETTINGS_FILENAME = "settings.json"
191
+
192
+ _GEMINI_SETTINGS_WARNING = (
193
+ "agentcap: could not parse ~/.gemini/settings.json; "
194
+ "using defaults for this run"
195
+ )
196
+
197
+
198
+ def gemini_home(env: Mapping[str, str]) -> Path:
199
+ """The home directory Gemini would use without AgentCap's overlay."""
200
+
201
+ for name in ("GEMINI_CLI_HOME", "HOME"):
202
+ value = env.get(name)
203
+ if value and value.strip():
204
+ return Path(value)
205
+ return Path(os.path.expanduser("~"))
206
+
207
+
208
+ def _strip_jsonc(text: str) -> str:
209
+ """Drop ``//`` and ``/* */`` comments plus trailing commas outside strings."""
210
+
211
+ without_comments: list[str] = []
212
+ in_string = False
213
+ escaped = False
214
+ index = 0
215
+ length = len(text)
216
+ while index < length:
217
+ char = text[index]
218
+ if in_string:
219
+ without_comments.append(char)
220
+ if escaped:
221
+ escaped = False
222
+ elif char == "\\":
223
+ escaped = True
224
+ elif char == '"':
225
+ in_string = False
226
+ index += 1
227
+ continue
228
+ if char == '"':
229
+ in_string = True
230
+ without_comments.append(char)
231
+ index += 1
232
+ continue
233
+ if char == "/" and index + 1 < length:
234
+ following = text[index + 1]
235
+ if following == "/":
236
+ index += 2
237
+ while index < length and text[index] not in "\r\n":
238
+ index += 1
239
+ continue
240
+ if following == "*":
241
+ index += 2
242
+ while index + 1 < length and not (
243
+ text[index] == "*" and text[index + 1] == "/"
244
+ ):
245
+ index += 1
246
+ index += 2
247
+ continue
248
+ without_comments.append(char)
249
+ index += 1
250
+
251
+ text = "".join(without_comments)
252
+
253
+ result: list[str] = []
254
+ in_string = False
255
+ escaped = False
256
+ index = 0
257
+ length = len(text)
258
+ while index < length:
259
+ char = text[index]
260
+ if in_string:
261
+ result.append(char)
262
+ if escaped:
263
+ escaped = False
264
+ elif char == "\\":
265
+ escaped = True
266
+ elif char == '"':
267
+ in_string = False
268
+ index += 1
269
+ continue
270
+ if char == '"':
271
+ in_string = True
272
+ result.append(char)
273
+ index += 1
274
+ continue
275
+ if char == ",":
276
+ lookahead = index + 1
277
+ while lookahead < length and text[lookahead] in " \t\r\n":
278
+ lookahead += 1
279
+ if lookahead < length and text[lookahead] in "}]":
280
+ index += 1
281
+ continue
282
+ result.append(char)
283
+ index += 1
284
+ return "".join(result)
285
+
286
+
287
+ def _parse_settings_text(text: str) -> dict[str, object] | None:
288
+ for candidate in (text, _strip_jsonc(text)):
289
+ try:
290
+ payload = json.loads(candidate)
291
+ except ValueError:
292
+ continue
293
+ return payload if isinstance(payload, dict) else None
294
+ return None
295
+
296
+
297
+ def _read_user_settings(path: Path) -> dict[str, object]:
298
+ """Read the user's Gemini settings; missing files simply mean defaults."""
185
299
 
186
- if not path:
187
- return {}
188
300
  try:
189
- with open(path, "r", encoding="utf-8") as handle:
190
- payload = json.load(handle)
191
- except (OSError, ValueError):
301
+ text = path.read_text(encoding="utf-8")
302
+ except FileNotFoundError:
303
+ return {}
304
+ except OSError:
305
+ print(_GEMINI_SETTINGS_WARNING, file=sys.stderr)
306
+ return {}
307
+ payload = _parse_settings_text(text)
308
+ if payload is None:
309
+ print(_GEMINI_SETTINGS_WARNING, file=sys.stderr)
192
310
  return {}
193
- return payload if isinstance(payload, dict) else {}
311
+ return payload
194
312
 
195
313
 
196
314
  def _deep_merge(
@@ -213,15 +331,42 @@ def gemini_settings_payload(existing: Mapping[str, object] | None) -> dict[str,
213
331
  return _deep_merge(existing or {}, GEMINI_SETTINGS)
214
332
 
215
333
 
216
- def _write_gemini_settings(source: str | None, directory: Path) -> Path:
217
- payload = gemini_settings_payload(_read_settings(source))
218
- path = directory / "settings.json"
219
- with open(path, "w", encoding="utf-8") as handle:
334
+ def _write_gemini_settings(existing: Mapping[str, object] | None, path: Path) -> Path:
335
+ payload = gemini_settings_payload(existing)
336
+ descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
337
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
220
338
  json.dump(payload, handle, sort_keys=True)
221
339
  handle.write("\n")
340
+ os.chmod(path, 0o600)
222
341
  return path
223
342
 
224
343
 
344
+ def _build_gemini_home(real_gemini: Path, root: Path) -> Path:
345
+ """Create ``<root>/.gemini`` linking the real entries and forcing auth."""
346
+
347
+ overlay = root / ".gemini"
348
+ overlay.mkdir(mode=0o700, exist_ok=True)
349
+ os.chmod(overlay, 0o700)
350
+ if real_gemini.is_dir():
351
+ for entry in sorted(real_gemini.iterdir()):
352
+ if entry.name == GEMINI_SETTINGS_FILENAME:
353
+ continue
354
+ try:
355
+ os.symlink(
356
+ os.path.abspath(str(entry)),
357
+ overlay / entry.name,
358
+ target_is_directory=entry.is_dir(),
359
+ )
360
+ except OSError:
361
+ # Unsupported entry (or a race); the overlay still works.
362
+ continue
363
+ _write_gemini_settings(
364
+ _read_user_settings(real_gemini / GEMINI_SETTINGS_FILENAME),
365
+ overlay / GEMINI_SETTINGS_FILENAME,
366
+ )
367
+ return overlay
368
+
369
+
225
370
  # --------------------------------------------------------------------- entry
226
371
  def prepare_child(
227
372
  argv: Sequence[str],
@@ -234,7 +379,7 @@ def prepare_child(
234
379
  """Apply the routing adapter for the detected CLI, if any.
235
380
 
236
381
  The returned ``env`` is a copy: the caller's environment is never changed,
237
- and the temporary Gemini settings directory is removed by ``cleanup``.
382
+ and the temporary Gemini home is removed by ``cleanup``.
238
383
  """
239
384
 
240
385
  prepared_argv = list(argv)
@@ -249,20 +394,21 @@ def prepare_child(
249
394
  prepared_argv[index:index] = codex_config_arguments(port)
250
395
  return PreparedChild(prepared_argv, prepared_env)
251
396
 
252
- source = prepared_env.get("GEMINI_CLI_SYSTEM_SETTINGS_PATH")
253
- directory = tempdir_factory(prefix="agentcap-gemini-")
254
- path = Path(directory)
397
+ real_gemini = gemini_home(prepared_env) / ".gemini"
398
+ root = Path(tempdir_factory(prefix="agentcap-gemini-"))
255
399
  try:
256
- settings_path = _write_gemini_settings(source, path)
400
+ os.chmod(root, 0o700)
401
+ _build_gemini_home(real_gemini, root)
257
402
  except BaseException:
258
- shutil.rmtree(path, ignore_errors=True)
403
+ shutil.rmtree(root, ignore_errors=True)
259
404
  raise
260
- prepared_env["GEMINI_CLI_SYSTEM_SETTINGS_PATH"] = str(settings_path)
405
+ prepared_env["GEMINI_CLI_HOME"] = str(root)
261
406
  if trust_workspace:
262
407
  prepared_env["GEMINI_CLI_TRUST_WORKSPACE"] = "true"
263
408
 
264
409
  def cleanup() -> None:
265
- shutil.rmtree(path, ignore_errors=True)
410
+ # rmtree unlinks the entry symlinks instead of descending into them.
411
+ shutil.rmtree(root, ignore_errors=True)
266
412
 
267
413
  return PreparedChild(prepared_argv, prepared_env, cleanup)
268
414
 
@@ -271,11 +417,13 @@ __all__ = [
271
417
  "AdapterConflict",
272
418
  "CODEX_CONFLICT_MESSAGE",
273
419
  "GEMINI_SETTINGS",
420
+ "GEMINI_SETTINGS_FILENAME",
274
421
  "PreparedChild",
275
422
  "codex_config_arguments",
276
423
  "codex_conflicts",
277
424
  "detect_agent",
278
425
  "ensure_codex_routing_is_safe",
426
+ "gemini_home",
279
427
  "gemini_settings_payload",
280
428
  "prepare_child",
281
429
  ]
@@ -137,6 +137,8 @@ def apply_output_clamp(
137
137
  * Gemini: ``generationConfig.maxOutputTokens`` and, for thinking models,
138
138
  ``generationConfig.thinkingConfig.thinkingBudget``. Both are billed as
139
139
  output, so the two limits together are kept within ``allowed_output``.
140
+ A client-supplied ``thinkingLevel`` is replaced by the budget: Gemini 3
141
+ rejects a request that sets both.
140
142
  """
141
143
 
142
144
  allowed = _positive_int(allowed_output)
@@ -232,6 +234,12 @@ def _apply_gemini_clamp(
232
234
  config["maxOutputTokens"] = text_limit
233
235
  thinking_key = _thinking_config_key(config)
234
236
  thinking_config = dict(config.get(thinking_key) or {})
237
+ # Gemini 3 rejects a thinking config that carries both limits ("You can
238
+ # only set only one of thinking budget and thinking level"). A requested
239
+ # level is therefore replaced by the explicit budget; every other key
240
+ # (``includeThoughts`` in particular) is preserved.
241
+ thinking_config.pop("thinkingLevel", None)
242
+ thinking_config.pop("thinking_level", None)
235
243
  thinking_config["thinkingBudget"] = thinking
236
244
  config[thinking_key] = thinking_config
237
245
  body[config_key] = config
@@ -33,11 +33,33 @@ from .upstreams import UpstreamConfigError, resolve_upstreams
33
33
 
34
34
  DEFAULT_SERVE_PORT = 8787
35
35
 
36
+ WINDOWS_UNSUPPORTED = (
37
+ "agentcap: Windows is not supported yet (process-group stop is POSIX-only). "
38
+ "Use WSL2, macOS or Linux."
39
+ )
40
+ PRO_CAP_MESSAGE = (
41
+ "agentcap: --hour-cap/--day-cap require an active Pro license; "
42
+ "command was not started. Run `agentcap license status`, then "
43
+ '`agentcap license activate "YOUR_LICENSE_KEY"`.'
44
+ )
45
+ LICENSE_USAGE_MESSAGE = (
46
+ 'Usage: agentcap license activate "YOUR_LICENSE_KEY" '
47
+ "(copy the key from your Polar purchase / customer portal)"
48
+ )
49
+ LICENSE_INVALID_MESSAGE = (
50
+ "License key not found or no longer valid. Check the copied key; "
51
+ "if it still fails, email kutaykarahan6@gmail.com."
52
+ )
53
+
54
+
55
+ def _windows_unsupported() -> bool:
56
+ return os.name == "nt" or sys.platform.startswith("win")
57
+
36
58
 
37
59
  def _build_parser() -> argparse.ArgumentParser:
38
60
  parser = argparse.ArgumentParser(
39
61
  prog="agentcap",
40
- description="Local, hard USD cap for AI agent runs.",
62
+ description="Local USD spend cap for AI agent runs.",
41
63
  )
42
64
  parser.add_argument("--version", action="version", version=f"agentcap {__version__}")
43
65
  subparsers = parser.add_subparsers(dest="subcommand")
@@ -166,19 +188,41 @@ def _license_manager() -> LicenseManager:
166
188
  return LicenseManager()
167
189
 
168
190
 
191
+ def _license_persist_error(exc: OSError, path) -> str:
192
+ detail = exc.strerror or str(exc)
193
+ return (
194
+ "agentcap: license validated, but could not save activation to "
195
+ f"{path}: {detail}. Check write permission and free disk space."
196
+ )
197
+
198
+
169
199
  def _license(args: argparse.Namespace) -> int:
200
+ manager = _license_manager()
170
201
  if args.action == "activate":
171
202
  if not args.key:
172
- print("agentcap: license activate requires a key", file=sys.stderr)
203
+ print(LICENSE_USAGE_MESSAGE, file=sys.stderr)
173
204
  return 2
174
- status = _license_manager().activate(args.key)
205
+ try:
206
+ status = manager.activate(args.key)
207
+ except OSError as exc:
208
+ print(_license_persist_error(exc, manager.path), file=sys.stderr)
209
+ return 1
175
210
  print(f"tier: {status.tier}")
176
211
  print(f"valid: {status.valid}")
177
212
  print(f"reason: {status.reason}")
213
+ if (
214
+ not status.valid
215
+ and "could not reach the license server" not in status.reason
216
+ ):
217
+ print(LICENSE_INVALID_MESSAGE, file=sys.stderr)
178
218
  return 0 if status.valid else 1
179
219
 
180
220
  if args.action == "deactivate":
181
- _license_manager().clear()
221
+ try:
222
+ manager.clear()
223
+ except OSError as exc:
224
+ print(_license_persist_error(exc, manager.path), file=sys.stderr)
225
+ return 1
182
226
  print("license cache cleared")
183
227
  return 0
184
228
 
@@ -189,6 +233,7 @@ def _license(args: argparse.Namespace) -> int:
189
233
  print(f"tier: {status.tier}")
190
234
  print(f"valid: {status.valid}")
191
235
  print(f"reason: {status.reason}")
236
+ print(f"cache path: {manager.path}")
192
237
  return 0
193
238
 
194
239
 
@@ -219,6 +264,9 @@ def _upstream_overrides() -> dict[str, str] | None:
219
264
 
220
265
 
221
266
  def _run(args: argparse.Namespace) -> int:
267
+ if _windows_unsupported():
268
+ print(WINDOWS_UNSUPPORTED, file=sys.stderr)
269
+ return 2
222
270
  argv = list(args.argv)
223
271
  if argv and argv[0] == "--":
224
272
  argv = argv[1:]
@@ -244,18 +292,12 @@ def _run(args: argparse.Namespace) -> int:
244
292
  return 2
245
293
 
246
294
  active_features = set(features(status_from_environment().tier))
247
- if hour_cap is not None and "hour_cap" not in active_features:
248
- print(
249
- "agentcap: --hour-cap requires AgentCap Pro; ignoring it on the free tier.",
250
- file=sys.stderr,
251
- )
252
- hour_cap = None
253
- if day_cap is not None and "day_cap" not in active_features:
254
- print(
255
- "agentcap: --day-cap requires AgentCap Pro; ignoring it on the free tier.",
256
- file=sys.stderr,
257
- )
258
- day_cap = None
295
+ if (
296
+ (hour_cap is not None and "hour_cap" not in active_features)
297
+ or (day_cap is not None and "day_cap" not in active_features)
298
+ ):
299
+ print(PRO_CAP_MESSAGE, file=sys.stderr)
300
+ return 2
259
301
 
260
302
  try:
261
303
  runner = Runner(
@@ -327,6 +369,9 @@ def _serve(args: argparse.Namespace) -> int:
327
369
 
328
370
  # ------------------------------------------------------------------------ demo
329
371
  def _demo(_args: argparse.Namespace) -> int:
372
+ if _windows_unsupported():
373
+ print(WINDOWS_UNSUPPORTED, file=sys.stderr)
374
+ return 2
330
375
  from .demo import run_demo
331
376
 
332
377
  return run_demo()
@@ -25,7 +25,10 @@ Prompt usage is realistic rather than fixed: ``max(1, ceil(text characters /
25
25
  Output usage is ``min(requested output limit, 2000)`` tokens, so a clamped
26
26
  request visibly produces a different bill. For Gemini, ``thinkingBudget`` is
27
27
  honoured too: ``thoughtsTokenCount`` is ``min(thinkingBudget, 2000)`` and is
28
- billed as output, exactly like the real API. Every response carries
28
+ billed as output, exactly like the real API. A thinking config that sets both
29
+ ``thinkingBudget`` and ``thinkingLevel`` is rejected with the real Gemini 3
30
+ 400 error ("You can only set only one of thinking budget and thinking level"),
31
+ so a clamp regression fails loudly. Every response carries
29
32
  ``x-fake-output-limit`` with the limit that was actually received (``none``
30
33
  when the request had no output limit).
31
34
 
@@ -130,13 +133,33 @@ def _gemini_limit(payload: Mapping[str, Any]) -> int | None:
130
133
  )
131
134
 
132
135
 
133
- def _gemini_thinking_budget(payload: Mapping[str, Any]) -> int | None:
136
+ def _gemini_thinking_config(payload: Mapping[str, Any]) -> Mapping[str, Any]:
134
137
  config = _mapping(payload.get("generationConfig"))
135
138
  if not config:
136
139
  config = _mapping(payload.get("generation_config"))
137
140
  thinking = _mapping(config.get("thinkingConfig"))
138
141
  if not thinking:
139
142
  thinking = _mapping(config.get("thinking_config"))
143
+ return thinking
144
+
145
+
146
+ def _gemini_thinking_limits_conflict(payload: Mapping[str, Any]) -> bool:
147
+ """Whether a thinking config sets both a budget and a level.
148
+
149
+ The real Gemini 3 API rejects that combination with a 400, so the fake
150
+ upstream must too (it catches clamp regressions end to end).
151
+ """
152
+
153
+ thinking = _gemini_thinking_config(payload)
154
+ has_budget = any(
155
+ key in thinking for key in ("thinkingBudget", "thinking_budget")
156
+ )
157
+ has_level = any(key in thinking for key in ("thinkingLevel", "thinking_level"))
158
+ return has_budget and has_level
159
+
160
+
161
+ def _gemini_thinking_budget(payload: Mapping[str, Any]) -> int | None:
162
+ thinking = _gemini_thinking_config(payload)
140
163
  value = thinking.get("thinkingBudget", thinking.get("thinking_budget"))
141
164
  if isinstance(value, bool) or value is None:
142
165
  return None
@@ -208,6 +231,21 @@ def _not_found(what: str) -> JSONResponse:
208
231
  )
209
232
 
210
233
 
234
+ def _gemini_thinking_conflict() -> JSONResponse:
235
+ return JSONResponse(
236
+ {
237
+ "error": {
238
+ "code": 400,
239
+ "message": (
240
+ "You can only set only one of thinking budget and thinking level."
241
+ ),
242
+ "status": "INVALID_ARGUMENT",
243
+ }
244
+ },
245
+ status_code=400,
246
+ )
247
+
248
+
211
249
  # ------------------------------------------------------------------- OpenAI
212
250
  async def openai_chat(request: Request) -> Response:
213
251
  payload = await _json_body(request)
@@ -356,6 +394,8 @@ async def gemini_model(request: Request) -> Response:
356
394
  payload = await _json_body(request)
357
395
  if payload is None:
358
396
  return _invalid_body()
397
+ if _gemini_thinking_limits_conflict(payload):
398
+ return _gemini_thinking_conflict()
359
399
  limit = _gemini_limit(payload)
360
400
  thinking = _gemini_thinking_budget(payload)
361
401
  headers = _limit_header(limit)