gemini-cli-usage 0.1.6__tar.gz → 0.1.7__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.
- {gemini_cli_usage-0.1.6 → gemini_cli_usage-0.1.7}/.github/workflows/publish.yml +4 -2
- {gemini_cli_usage-0.1.6 → gemini_cli_usage-0.1.7}/PKG-INFO +1 -1
- {gemini_cli_usage-0.1.6 → gemini_cli_usage-0.1.7}/pyproject.toml +1 -1
- {gemini_cli_usage-0.1.6 → gemini_cli_usage-0.1.7}/src/gemini_cli_usage/__init__.py +86 -35
- {gemini_cli_usage-0.1.6 → gemini_cli_usage-0.1.7}/tests/test_gemini_cli_usage.py +126 -0
- {gemini_cli_usage-0.1.6 → gemini_cli_usage-0.1.7}/uv.lock +1 -1
- {gemini_cli_usage-0.1.6 → gemini_cli_usage-0.1.7}/.gitignore +0 -0
- {gemini_cli_usage-0.1.6 → gemini_cli_usage-0.1.7}/README.md +0 -0
- {gemini_cli_usage-0.1.6 → gemini_cli_usage-0.1.7}/src/gemini_cli_usage/__main__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gemini-cli-usage
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.7
|
|
4
4
|
Summary: Gemini CLI quota monitor — fetches Code Assist quota data from Google's backend
|
|
5
5
|
Project-URL: Homepage, https://github.com/wakamex/gemini-cli-usage
|
|
6
6
|
Project-URL: Source, https://github.com/wakamex/gemini-cli-usage
|
|
@@ -23,6 +23,7 @@ import re
|
|
|
23
23
|
import signal
|
|
24
24
|
import shutil
|
|
25
25
|
import sys
|
|
26
|
+
import tempfile
|
|
26
27
|
import time
|
|
27
28
|
import urllib.error
|
|
28
29
|
import urllib.parse
|
|
@@ -181,12 +182,35 @@ def get_oauth_credentials() -> dict | None:
|
|
|
181
182
|
return data if isinstance(data, dict) else None
|
|
182
183
|
|
|
183
184
|
|
|
184
|
-
def _write_oauth_credentials(
|
|
185
|
+
def _write_oauth_credentials(
|
|
186
|
+
creds: dict, expected_refresh_token: str | None = None
|
|
187
|
+
) -> dict:
|
|
188
|
+
latest = get_oauth_credentials()
|
|
189
|
+
if expected_refresh_token and latest:
|
|
190
|
+
latest_refresh_token = latest.get("refresh_token")
|
|
191
|
+
if latest_refresh_token and latest_refresh_token != expected_refresh_token:
|
|
192
|
+
return latest
|
|
193
|
+
creds = {**latest, **creds}
|
|
194
|
+
|
|
195
|
+
tmp: Path | None = None
|
|
185
196
|
try:
|
|
186
|
-
OAUTH_FILE.
|
|
197
|
+
OAUTH_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
198
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
199
|
+
prefix=f".{OAUTH_FILE.name}.", dir=OAUTH_FILE.parent
|
|
200
|
+
)
|
|
201
|
+
tmp = Path(tmp_name)
|
|
202
|
+
os.fchmod(fd, 0o600)
|
|
203
|
+
with os.fdopen(fd, "w") as file:
|
|
204
|
+
file.write(json.dumps(creds, indent=2) + "\n")
|
|
205
|
+
os.replace(tmp, OAUTH_FILE)
|
|
187
206
|
except OSError:
|
|
188
207
|
# Best effort only; the refreshed token can still be used in-memory.
|
|
189
|
-
|
|
208
|
+
if tmp:
|
|
209
|
+
try:
|
|
210
|
+
tmp.unlink()
|
|
211
|
+
except OSError:
|
|
212
|
+
pass
|
|
213
|
+
return creds
|
|
190
214
|
|
|
191
215
|
|
|
192
216
|
def _get_gemini_cli_oauth2_path() -> Path | None:
|
|
@@ -304,8 +328,16 @@ def refresh_access_token(creds: dict) -> dict:
|
|
|
304
328
|
data=payload,
|
|
305
329
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
306
330
|
)
|
|
307
|
-
|
|
308
|
-
|
|
331
|
+
try:
|
|
332
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
333
|
+
result = json.loads(resp.read())
|
|
334
|
+
except urllib.error.HTTPError as exc:
|
|
335
|
+
latest = get_oauth_credentials()
|
|
336
|
+
if exc.code in (400, 401) and latest:
|
|
337
|
+
latest_refresh_token = latest.get("refresh_token")
|
|
338
|
+
if latest_refresh_token and latest_refresh_token != refresh_token:
|
|
339
|
+
return latest
|
|
340
|
+
raise
|
|
309
341
|
|
|
310
342
|
updated = dict(creds)
|
|
311
343
|
updated["access_token"] = result["access_token"]
|
|
@@ -316,17 +348,27 @@ def refresh_access_token(creds: dict) -> dict:
|
|
|
316
348
|
updated["id_token"] = result["id_token"]
|
|
317
349
|
if result.get("refresh_token"):
|
|
318
350
|
updated["refresh_token"] = result["refresh_token"]
|
|
319
|
-
_write_oauth_credentials(updated)
|
|
320
|
-
return updated
|
|
351
|
+
return _write_oauth_credentials(updated, expected_refresh_token=refresh_token)
|
|
321
352
|
|
|
322
353
|
|
|
323
|
-
def get_access_token(
|
|
354
|
+
def get_access_token(
|
|
355
|
+
force_refresh: bool = False, rejected_token: str | None = None
|
|
356
|
+
) -> str:
|
|
324
357
|
creds = get_oauth_credentials()
|
|
325
358
|
if not creds:
|
|
326
359
|
raise RuntimeError("No OAuth credentials at ~/.gemini/oauth_creds.json")
|
|
327
360
|
|
|
361
|
+
token = creds.get("access_token")
|
|
362
|
+
token_was_replaced = (
|
|
363
|
+
rejected_token is not None
|
|
364
|
+
and isinstance(token, str)
|
|
365
|
+
and bool(token)
|
|
366
|
+
and token != rejected_token
|
|
367
|
+
)
|
|
328
368
|
expiry_date = int(creds.get("expiry_date", 0) or 0)
|
|
329
|
-
if
|
|
369
|
+
if not token_was_replaced and (
|
|
370
|
+
force_refresh or time.time() * 1000 >= expiry_date - 60_000
|
|
371
|
+
):
|
|
330
372
|
creds = refresh_access_token(creds)
|
|
331
373
|
|
|
332
374
|
token = creds.get("access_token")
|
|
@@ -426,32 +468,41 @@ def fetch_quota(project_root: Path | None = None) -> dict:
|
|
|
426
468
|
)
|
|
427
469
|
|
|
428
470
|
access_token = get_access_token()
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
471
|
+
for attempt in range(2):
|
|
472
|
+
try:
|
|
473
|
+
load_res = _load_code_assist(access_token)
|
|
474
|
+
env_project = (
|
|
475
|
+
os.environ.get("GOOGLE_CLOUD_PROJECT")
|
|
476
|
+
or os.environ.get("GOOGLE_CLOUD_PROJECT_ID")
|
|
477
|
+
or None
|
|
478
|
+
)
|
|
479
|
+
project_id = load_res.get("cloudaicompanionProject") or env_project
|
|
480
|
+
if not project_id:
|
|
481
|
+
raise RuntimeError(
|
|
482
|
+
"No Code Assist project ID available. Set GOOGLE_CLOUD_PROJECT if your account requires it."
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
quota_res = _code_assist_post(
|
|
486
|
+
"retrieveUserQuota", {"project": project_id}, access_token
|
|
487
|
+
)
|
|
488
|
+
current_tier = load_res.get("currentTier") or {}
|
|
489
|
+
paid_tier = load_res.get("paidTier") or {}
|
|
490
|
+
result = {
|
|
491
|
+
"project_id": project_id,
|
|
492
|
+
"user_tier": paid_tier.get("id") or current_tier.get("id"),
|
|
493
|
+
"user_tier_name": paid_tier.get("name") or current_tier.get("name"),
|
|
494
|
+
"buckets": _parse_quota_buckets(quota_res.get("buckets") or []),
|
|
495
|
+
}
|
|
496
|
+
result["summary_bucket"] = _select_summary_bucket(result)
|
|
497
|
+
return result
|
|
498
|
+
except urllib.error.HTTPError as exc:
|
|
499
|
+
if exc.code in (401, 403) and attempt == 0:
|
|
500
|
+
access_token = get_access_token(
|
|
501
|
+
force_refresh=True, rejected_token=access_token
|
|
502
|
+
)
|
|
503
|
+
continue
|
|
504
|
+
raise
|
|
505
|
+
raise RuntimeError("Failed to fetch quota after token refresh")
|
|
455
506
|
|
|
456
507
|
|
|
457
508
|
def build_usage_json(project_root: Path | None = None) -> dict:
|
|
@@ -4,6 +4,7 @@ import json
|
|
|
4
4
|
import os
|
|
5
5
|
import tempfile
|
|
6
6
|
import unittest
|
|
7
|
+
import urllib.error
|
|
7
8
|
from datetime import UTC, datetime, timedelta
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
from unittest import mock
|
|
@@ -266,6 +267,131 @@ class GeminiUsageTests(unittest.TestCase):
|
|
|
266
267
|
self.assertIn("GEMINI_OAUTH_CLIENT_ID", str(exc.exception))
|
|
267
268
|
self.assertIn("run `gemini`", str(exc.exception))
|
|
268
269
|
|
|
270
|
+
def test_write_oauth_credentials_is_atomic_and_private(self):
|
|
271
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
272
|
+
oauth_file = Path(tmp) / "oauth_creds.json"
|
|
273
|
+
with mock.patch.object(gemini_cli_usage, "OAUTH_FILE", oauth_file):
|
|
274
|
+
written = gemini_cli_usage._write_oauth_credentials(
|
|
275
|
+
{"access_token": "token", "refresh_token": "refresh"}
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
self.assertEqual(json.loads(oauth_file.read_text()), written)
|
|
279
|
+
self.assertEqual(oauth_file.stat().st_mode & 0o777, 0o600)
|
|
280
|
+
self.assertEqual(list(oauth_file.parent.glob(".oauth_creds.json.*")), [])
|
|
281
|
+
|
|
282
|
+
def test_refresh_does_not_overwrite_concurrently_rotated_credentials(self):
|
|
283
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
284
|
+
oauth_file = Path(tmp) / "oauth_creds.json"
|
|
285
|
+
original = {
|
|
286
|
+
"access_token": "old-access",
|
|
287
|
+
"refresh_token": "old-refresh",
|
|
288
|
+
"expiry_date": 0,
|
|
289
|
+
}
|
|
290
|
+
rotated = {
|
|
291
|
+
"access_token": "other-access",
|
|
292
|
+
"refresh_token": "other-refresh",
|
|
293
|
+
"expiry_date": 9_999_999_999_999,
|
|
294
|
+
}
|
|
295
|
+
_write_json(oauth_file, original)
|
|
296
|
+
|
|
297
|
+
class FakeResponse:
|
|
298
|
+
def __enter__(self):
|
|
299
|
+
return self
|
|
300
|
+
|
|
301
|
+
def __exit__(self, *_args):
|
|
302
|
+
return False
|
|
303
|
+
|
|
304
|
+
def read(self):
|
|
305
|
+
_write_json(oauth_file, rotated)
|
|
306
|
+
return json.dumps(
|
|
307
|
+
{"access_token": "our-access", "refresh_token": "our-refresh"}
|
|
308
|
+
).encode()
|
|
309
|
+
|
|
310
|
+
with (
|
|
311
|
+
mock.patch.object(gemini_cli_usage, "OAUTH_FILE", oauth_file),
|
|
312
|
+
mock.patch.object(
|
|
313
|
+
gemini_cli_usage,
|
|
314
|
+
"_get_oauth_client_credentials",
|
|
315
|
+
return_value=("client", "secret"),
|
|
316
|
+
),
|
|
317
|
+
mock.patch.object(
|
|
318
|
+
gemini_cli_usage.urllib.request,
|
|
319
|
+
"urlopen",
|
|
320
|
+
return_value=FakeResponse(),
|
|
321
|
+
),
|
|
322
|
+
):
|
|
323
|
+
result = gemini_cli_usage.refresh_access_token(original)
|
|
324
|
+
|
|
325
|
+
self.assertEqual(result, rotated)
|
|
326
|
+
self.assertEqual(json.loads(oauth_file.read_text()), rotated)
|
|
327
|
+
|
|
328
|
+
def test_rejected_token_uses_concurrently_replaced_access_token(self):
|
|
329
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
330
|
+
oauth_file = Path(tmp) / "oauth_creds.json"
|
|
331
|
+
_write_json(
|
|
332
|
+
oauth_file,
|
|
333
|
+
{
|
|
334
|
+
"access_token": "replacement",
|
|
335
|
+
"refresh_token": "refresh",
|
|
336
|
+
"expiry_date": 9_999_999_999_999,
|
|
337
|
+
},
|
|
338
|
+
)
|
|
339
|
+
with (
|
|
340
|
+
mock.patch.object(gemini_cli_usage, "OAUTH_FILE", oauth_file),
|
|
341
|
+
mock.patch.object(
|
|
342
|
+
gemini_cli_usage, "refresh_access_token"
|
|
343
|
+
) as refresh_mock,
|
|
344
|
+
):
|
|
345
|
+
token = gemini_cli_usage.get_access_token(
|
|
346
|
+
force_refresh=True, rejected_token="rejected"
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
self.assertEqual(token, "replacement")
|
|
350
|
+
refresh_mock.assert_not_called()
|
|
351
|
+
|
|
352
|
+
def test_fetch_quota_recovers_once_from_auth_error(self):
|
|
353
|
+
auth_error = urllib.error.HTTPError(
|
|
354
|
+
url="https://cloudcode-pa.googleapis.com",
|
|
355
|
+
code=401,
|
|
356
|
+
msg="Unauthorized",
|
|
357
|
+
hdrs={},
|
|
358
|
+
fp=None,
|
|
359
|
+
)
|
|
360
|
+
with (
|
|
361
|
+
mock.patch.object(
|
|
362
|
+
gemini_cli_usage,
|
|
363
|
+
"get_auth_type",
|
|
364
|
+
return_value="oauth-personal",
|
|
365
|
+
),
|
|
366
|
+
mock.patch.object(
|
|
367
|
+
gemini_cli_usage,
|
|
368
|
+
"get_access_token",
|
|
369
|
+
side_effect=["stale", "fresh"],
|
|
370
|
+
) as token_mock,
|
|
371
|
+
mock.patch.object(
|
|
372
|
+
gemini_cli_usage,
|
|
373
|
+
"_load_code_assist",
|
|
374
|
+
side_effect=[
|
|
375
|
+
auth_error,
|
|
376
|
+
{"cloudaicompanionProject": "project"},
|
|
377
|
+
],
|
|
378
|
+
),
|
|
379
|
+
mock.patch.object(
|
|
380
|
+
gemini_cli_usage,
|
|
381
|
+
"_code_assist_post",
|
|
382
|
+
return_value={"buckets": []},
|
|
383
|
+
),
|
|
384
|
+
):
|
|
385
|
+
quota = gemini_cli_usage.fetch_quota()
|
|
386
|
+
|
|
387
|
+
self.assertEqual(quota["project_id"], "project")
|
|
388
|
+
token_mock.assert_has_calls(
|
|
389
|
+
[
|
|
390
|
+
mock.call(),
|
|
391
|
+
mock.call(force_refresh=True, rejected_token="stale"),
|
|
392
|
+
]
|
|
393
|
+
)
|
|
394
|
+
|
|
269
395
|
|
|
270
396
|
if __name__ == "__main__":
|
|
271
397
|
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|