agentcap-cli 0.1.1__tar.gz → 0.1.2__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.2}/PKG-INFO +12 -10
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/README.md +11 -9
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/pyproject.toml +1 -1
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/src/agentcap/__init__.py +1 -1
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/src/agentcap/adapters.py +175 -27
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/src/agentcap/clamp.py +8 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/src/agentcap/devtools/fake_upstream.py +42 -2
- agentcap_cli-0.1.2/tests/test_adapters.py +508 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/tests/test_clamp.py +127 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/tests/test_estimate.py +37 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/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.2}/.gitignore +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/LICENSE +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/landing/README.md +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/src/agentcap/alerts.py +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/src/agentcap/cli.py +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/src/agentcap/data/pricing.json +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/src/agentcap/demo.py +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/src/agentcap/devtools/__init__.py +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/src/agentcap/estimate.py +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/src/agentcap/ledger.py +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/src/agentcap/license.py +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/src/agentcap/loop.py +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/src/agentcap/pricing.py +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/src/agentcap/proxy.py +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/src/agentcap/runner.py +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/src/agentcap/upstreams.py +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/src/agentcap/usage.py +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/tests/license_support.py +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/tests/proxy_support.py +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/tests/test_alerts.py +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/tests/test_cli.py +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/tests/test_ledger.py +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/tests/test_license.py +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/tests/test_loop.py +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/tests/test_pricing.py +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/tests/test_proxy_core.py +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/tests/test_proxy_extras.py +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/tests/test_proxy_streaming.py +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/tests/test_runner.py +0 -0
- {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/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.2
|
|
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
|
|
@@ -126,12 +126,13 @@ sign-in (OAuth/subscription) traffic is not metered.
|
|
|
126
126
|
already sets `model_provider`, `model_providers.*` or `openai_base_url` with
|
|
127
127
|
`-c`/`--config`, AgentCap refuses to start (exit code 2) rather than let
|
|
128
128
|
traffic bypass the proxy.
|
|
129
|
-
- **Gemini CLI 0.58–0.66** needs an auth type in headless mode,
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
`
|
|
129
|
+
- **Gemini CLI 0.58–0.66** needs an auth type in headless mode, and from 0.60 it
|
|
130
|
+
also refuses system settings files that are not root-owned. AgentCap therefore
|
|
131
|
+
runs Gemini with a temporary `GEMINI_CLI_HOME`: its `.gemini` directory links
|
|
132
|
+
your real entries (history, `trustedFolders.json`, credentials) and holds a
|
|
133
|
+
copied `settings.json` with the auth type forced to `gemini-api-key`. The
|
|
134
|
+
overlay is removed afterwards; nothing in your real `~/.gemini` is modified.
|
|
135
|
+
`--trust-workspace` adds `GEMINI_CLI_TRUST_WORKSPACE=true` for Gemini.
|
|
135
136
|
- `npx @openai/codex ...` and `npx @google/gemini-cli ...` are recognised too.
|
|
136
137
|
|
|
137
138
|
Add `--trust-workspace` for Gemini CLI runs in folders you have not trusted yet;
|
|
@@ -179,11 +180,12 @@ test suite uses `httpx.MockTransport` and never calls the network.
|
|
|
179
180
|
| Tool | Verified version | Command and auth | Routing |
|
|
180
181
|
| --- | --- | --- | --- |
|
|
181
182
|
| 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 `
|
|
183
|
+
| 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
184
|
| 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
185
|
|
|
185
|
-
Tested
|
|
186
|
-
|
|
186
|
+
Tested with a real Gemini API key on Gemini CLI 0.66 (spend metered from Google's
|
|
187
|
+
usageMetadata, output clamped to the cap); Gemini CLI 0.58 and Codex CLI 0.163 are
|
|
188
|
+
tested end-to-end against a local fake API only, not yet with real paid keys.
|
|
187
189
|
|
|
188
190
|
## How it works
|
|
189
191
|
|
|
@@ -96,12 +96,13 @@ sign-in (OAuth/subscription) traffic is not metered.
|
|
|
96
96
|
already sets `model_provider`, `model_providers.*` or `openai_base_url` with
|
|
97
97
|
`-c`/`--config`, AgentCap refuses to start (exit code 2) rather than let
|
|
98
98
|
traffic bypass the proxy.
|
|
99
|
-
- **Gemini CLI 0.58–0.66** needs an auth type in headless mode,
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
`
|
|
99
|
+
- **Gemini CLI 0.58–0.66** needs an auth type in headless mode, and from 0.60 it
|
|
100
|
+
also refuses system settings files that are not root-owned. AgentCap therefore
|
|
101
|
+
runs Gemini with a temporary `GEMINI_CLI_HOME`: its `.gemini` directory links
|
|
102
|
+
your real entries (history, `trustedFolders.json`, credentials) and holds a
|
|
103
|
+
copied `settings.json` with the auth type forced to `gemini-api-key`. The
|
|
104
|
+
overlay is removed afterwards; nothing in your real `~/.gemini` is modified.
|
|
105
|
+
`--trust-workspace` adds `GEMINI_CLI_TRUST_WORKSPACE=true` for Gemini.
|
|
105
106
|
- `npx @openai/codex ...` and `npx @google/gemini-cli ...` are recognised too.
|
|
106
107
|
|
|
107
108
|
Add `--trust-workspace` for Gemini CLI runs in folders you have not trusted yet;
|
|
@@ -149,11 +150,12 @@ test suite uses `httpx.MockTransport` and never calls the network.
|
|
|
149
150
|
| Tool | Verified version | Command and auth | Routing |
|
|
150
151
|
| --- | --- | --- | --- |
|
|
151
152
|
| 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 `
|
|
153
|
+
| 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
154
|
| 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
155
|
|
|
155
|
-
Tested
|
|
156
|
-
|
|
156
|
+
Tested with a real Gemini API key on Gemini CLI 0.66 (spend metered from Google's
|
|
157
|
+
usageMetadata, output clamped to the cap); Gemini CLI 0.58 and Codex CLI 0.163 are
|
|
158
|
+
tested end-to-end against a local fake API only, not yet with real paid keys.
|
|
157
159
|
|
|
158
160
|
## How it works
|
|
159
161
|
|
|
@@ -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.2"
|
|
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
|
|
@@ -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)
|