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.
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/PKG-INFO +18 -10
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/README.md +17 -9
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/pyproject.toml +1 -1
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/src/agentcap/__init__.py +1 -1
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/src/agentcap/adapters.py +175 -27
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/src/agentcap/clamp.py +8 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/src/agentcap/cli.py +61 -16
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/src/agentcap/devtools/fake_upstream.py +42 -2
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/src/agentcap/license.py +15 -8
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/src/agentcap/proxy.py +100 -4
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/src/agentcap/runner.py +32 -15
- agentcap_cli-0.1.3/tests/test_adapters.py +508 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/tests/test_clamp.py +127 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/tests/test_cli.py +134 -8
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/tests/test_estimate.py +37 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/tests/test_license.py +39 -0
- agentcap_cli-0.1.3/tests/test_proxy_cancel.py +98 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/tests/test_runner.py +54 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/tests/test_upstreams.py +78 -0
- agentcap_cli-0.1.1/tests/test_adapters.py +0 -251
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/.gitignore +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/LICENSE +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/landing/README.md +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/src/agentcap/alerts.py +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/src/agentcap/data/pricing.json +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/src/agentcap/demo.py +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/src/agentcap/devtools/__init__.py +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/src/agentcap/estimate.py +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/src/agentcap/ledger.py +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/src/agentcap/loop.py +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/src/agentcap/pricing.py +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/src/agentcap/upstreams.py +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/src/agentcap/usage.py +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/tests/license_support.py +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/tests/proxy_support.py +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/tests/test_alerts.py +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/tests/test_ledger.py +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/tests/test_loop.py +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/tests/test_pricing.py +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/tests/test_proxy_core.py +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/tests/test_proxy_extras.py +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.3}/tests/test_proxy_streaming.py +0 -0
- {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.
|
|
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,
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
`
|
|
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 `
|
|
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
|
|
186
|
-
|
|
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,
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
`
|
|
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 `
|
|
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
|
|
156
|
-
|
|
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.
|
|
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"
|
|
@@ -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
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
184
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
|
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(
|
|
217
|
-
payload = gemini_settings_payload(
|
|
218
|
-
|
|
219
|
-
with
|
|
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
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
path = Path(directory)
|
|
397
|
+
real_gemini = gemini_home(prepared_env) / ".gemini"
|
|
398
|
+
root = Path(tempdir_factory(prefix="agentcap-gemini-"))
|
|
255
399
|
try:
|
|
256
|
-
|
|
400
|
+
os.chmod(root, 0o700)
|
|
401
|
+
_build_gemini_home(real_gemini, root)
|
|
257
402
|
except BaseException:
|
|
258
|
-
shutil.rmtree(
|
|
403
|
+
shutil.rmtree(root, ignore_errors=True)
|
|
259
404
|
raise
|
|
260
|
-
prepared_env["
|
|
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
|
-
|
|
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
|
|
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(
|
|
203
|
+
print(LICENSE_USAGE_MESSAGE, file=sys.stderr)
|
|
173
204
|
return 2
|
|
174
|
-
|
|
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
|
-
|
|
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
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
)
|
|
252
|
-
|
|
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.
|
|
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
|
|
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)
|