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.
Files changed (42) hide show
  1. {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/PKG-INFO +12 -10
  2. {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/README.md +11 -9
  3. {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/pyproject.toml +1 -1
  4. {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/src/agentcap/__init__.py +1 -1
  5. {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/src/agentcap/adapters.py +175 -27
  6. {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/src/agentcap/clamp.py +8 -0
  7. {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/src/agentcap/devtools/fake_upstream.py +42 -2
  8. agentcap_cli-0.1.2/tests/test_adapters.py +508 -0
  9. {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/tests/test_clamp.py +127 -0
  10. {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/tests/test_estimate.py +37 -0
  11. {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/tests/test_upstreams.py +78 -0
  12. agentcap_cli-0.1.1/tests/test_adapters.py +0 -251
  13. {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/.gitignore +0 -0
  14. {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/LICENSE +0 -0
  15. {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/landing/README.md +0 -0
  16. {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/src/agentcap/alerts.py +0 -0
  17. {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/src/agentcap/cli.py +0 -0
  18. {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/src/agentcap/data/pricing.json +0 -0
  19. {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/src/agentcap/demo.py +0 -0
  20. {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/src/agentcap/devtools/__init__.py +0 -0
  21. {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/src/agentcap/estimate.py +0 -0
  22. {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/src/agentcap/ledger.py +0 -0
  23. {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/src/agentcap/license.py +0 -0
  24. {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/src/agentcap/loop.py +0 -0
  25. {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/src/agentcap/pricing.py +0 -0
  26. {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/src/agentcap/proxy.py +0 -0
  27. {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/src/agentcap/runner.py +0 -0
  28. {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/src/agentcap/upstreams.py +0 -0
  29. {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/src/agentcap/usage.py +0 -0
  30. {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/tests/license_support.py +0 -0
  31. {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/tests/proxy_support.py +0 -0
  32. {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/tests/test_alerts.py +0 -0
  33. {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/tests/test_cli.py +0 -0
  34. {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/tests/test_ledger.py +0 -0
  35. {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/tests/test_license.py +0 -0
  36. {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/tests/test_loop.py +0 -0
  37. {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/tests/test_pricing.py +0 -0
  38. {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/tests/test_proxy_core.py +0 -0
  39. {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/tests/test_proxy_extras.py +0 -0
  40. {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/tests/test_proxy_streaming.py +0 -0
  41. {agentcap_cli-0.1.1 → agentcap_cli-0.1.2}/tests/test_runner.py +0 -0
  42. {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.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, 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.
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 `GEMINI_CLI_SYSTEM_SETTINGS_PATH` overlay (api-key auth). Add `--trust-workspace` to `agentcap run` for untrusted folders. |
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 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.
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, 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.
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 `GEMINI_CLI_SYSTEM_SETTINGS_PATH` overlay (api-key auth). Add `--trust-workspace` to `agentcap run` for untrusted folders. |
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 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.
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.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"
@@ -2,6 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.1.1"
5
+ __version__ = "0.1.2"
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
@@ -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)