gemini-cli-usage 0.1.7__tar.gz → 0.1.9__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.9/.github/workflows/publish.yml +29 -0
- {gemini_cli_usage-0.1.7 → gemini_cli_usage-0.1.9}/PKG-INFO +6 -4
- {gemini_cli_usage-0.1.7 → gemini_cli_usage-0.1.9}/README.md +4 -2
- {gemini_cli_usage-0.1.7 → gemini_cli_usage-0.1.9}/pyproject.toml +1 -1
- {gemini_cli_usage-0.1.7 → gemini_cli_usage-0.1.9}/src/gemini_cli_usage/__init__.py +50 -17
- {gemini_cli_usage-0.1.7 → gemini_cli_usage-0.1.9}/tests/test_gemini_cli_usage.py +53 -0
- {gemini_cli_usage-0.1.7 → gemini_cli_usage-0.1.9}/uv.lock +1 -1
- gemini_cli_usage-0.1.7/.github/workflows/publish.yml +0 -20
- {gemini_cli_usage-0.1.7 → gemini_cli_usage-0.1.9}/.gitignore +0 -0
- {gemini_cli_usage-0.1.7 → gemini_cli_usage-0.1.9}/src/gemini_cli_usage/__main__.py +0 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
publish:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
environment: pypi
|
|
12
|
+
permissions:
|
|
13
|
+
contents: read
|
|
14
|
+
id-token: write
|
|
15
|
+
steps:
|
|
16
|
+
- name: Checkout
|
|
17
|
+
uses: actions/checkout@v7.0.1
|
|
18
|
+
- name: Install uv
|
|
19
|
+
uses: astral-sh/setup-uv@v9.0.0
|
|
20
|
+
with:
|
|
21
|
+
enable-cache: false
|
|
22
|
+
- name: Verify version tag
|
|
23
|
+
run: test "$GITHUB_REF_NAME" = "v$(uv --no-config version --short)"
|
|
24
|
+
- name: Test
|
|
25
|
+
run: uv --no-config run --locked python -m unittest discover -s tests
|
|
26
|
+
- name: Build
|
|
27
|
+
run: uv --no-config build --no-sources
|
|
28
|
+
- name: Publish
|
|
29
|
+
run: uv publish --trusted-publishing always
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: gemini-cli-usage
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.9
|
|
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
|
|
@@ -83,7 +83,8 @@ Google's internal Code Assist API:
|
|
|
83
83
|
- `retrieveUserQuota`
|
|
84
84
|
|
|
85
85
|
This tool mirrors that flow using the OAuth credentials in
|
|
86
|
-
`~/.gemini/oauth_creds.json`.
|
|
86
|
+
`~/.gemini/oauth_creds.json`. When `GEMINI_CLI_HOME` is set, it follows Gemini
|
|
87
|
+
CLI and uses `$GEMINI_CLI_HOME/.gemini` instead.
|
|
87
88
|
|
|
88
89
|
## Notes
|
|
89
90
|
|
|
@@ -102,7 +103,8 @@ This tool mirrors that flow using the OAuth credentials in
|
|
|
102
103
|
`remainingAmount` and a usable fraction. Otherwise the tool reports `% used`
|
|
103
104
|
plus reset time.
|
|
104
105
|
- Auth detection follows Gemini CLI precedence: environment variables first,
|
|
105
|
-
then workspace `.gemini/settings.json`, then global
|
|
106
|
+
then workspace `.gemini/settings.json`, then the global settings file under
|
|
107
|
+
the effective Gemini CLI home.
|
|
106
108
|
|
|
107
109
|
## Options
|
|
108
110
|
|
|
@@ -74,7 +74,8 @@ Google's internal Code Assist API:
|
|
|
74
74
|
- `retrieveUserQuota`
|
|
75
75
|
|
|
76
76
|
This tool mirrors that flow using the OAuth credentials in
|
|
77
|
-
`~/.gemini/oauth_creds.json`.
|
|
77
|
+
`~/.gemini/oauth_creds.json`. When `GEMINI_CLI_HOME` is set, it follows Gemini
|
|
78
|
+
CLI and uses `$GEMINI_CLI_HOME/.gemini` instead.
|
|
78
79
|
|
|
79
80
|
## Notes
|
|
80
81
|
|
|
@@ -93,7 +94,8 @@ This tool mirrors that flow using the OAuth credentials in
|
|
|
93
94
|
`remainingAmount` and a usable fraction. Otherwise the tool reports `% used`
|
|
94
95
|
plus reset time.
|
|
95
96
|
- Auth detection follows Gemini CLI precedence: environment variables first,
|
|
96
|
-
then workspace `.gemini/settings.json`, then global
|
|
97
|
+
then workspace `.gemini/settings.json`, then the global settings file under
|
|
98
|
+
the effective Gemini CLI home.
|
|
97
99
|
|
|
98
100
|
## Options
|
|
99
101
|
|
|
@@ -18,6 +18,7 @@ from __future__ import annotations
|
|
|
18
18
|
|
|
19
19
|
import argparse
|
|
20
20
|
import json
|
|
21
|
+
import math
|
|
21
22
|
import os
|
|
22
23
|
import re
|
|
23
24
|
import signal
|
|
@@ -31,7 +32,12 @@ import urllib.request
|
|
|
31
32
|
from datetime import UTC, datetime
|
|
32
33
|
from pathlib import Path
|
|
33
34
|
|
|
34
|
-
|
|
35
|
+
def _gemini_dir() -> Path:
|
|
36
|
+
home = os.environ.get("GEMINI_CLI_HOME")
|
|
37
|
+
return (Path(home) if home else Path.home()) / ".gemini"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
_NATIVE_GEMINI_DIR = _gemini_dir()
|
|
35
41
|
|
|
36
42
|
|
|
37
43
|
def _wsl_gemini_dirs() -> list[Path]:
|
|
@@ -68,7 +74,7 @@ def _wsl_gemini_dirs() -> list[Path]:
|
|
|
68
74
|
def _find_gemini_file(filename: str) -> Path:
|
|
69
75
|
"""Return the first existing .gemini/<filename> from native + WSL paths."""
|
|
70
76
|
native = _NATIVE_GEMINI_DIR / filename
|
|
71
|
-
if native.exists():
|
|
77
|
+
if native.exists() or os.environ.get("GEMINI_CLI_HOME"):
|
|
72
78
|
return native
|
|
73
79
|
for wsl_dir in _wsl_gemini_dirs():
|
|
74
80
|
candidate = wsl_dir / filename
|
|
@@ -121,12 +127,13 @@ def _iso_now() -> str:
|
|
|
121
127
|
|
|
122
128
|
|
|
123
129
|
def _parse_iso(timestamp: str | None) -> datetime | None:
|
|
124
|
-
if not timestamp:
|
|
130
|
+
if not isinstance(timestamp, str) or not timestamp:
|
|
125
131
|
return None
|
|
126
132
|
try:
|
|
127
|
-
|
|
133
|
+
parsed = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
|
|
128
134
|
except ValueError:
|
|
129
135
|
return None
|
|
136
|
+
return parsed if parsed.tzinfo is not None else None
|
|
130
137
|
|
|
131
138
|
|
|
132
139
|
def _read_auth_type_from_settings(path: Path) -> str | None:
|
|
@@ -311,7 +318,7 @@ def _get_oauth_client_credentials(creds: dict | None = None) -> tuple[str, str]:
|
|
|
311
318
|
def refresh_access_token(creds: dict) -> dict:
|
|
312
319
|
refresh_token = creds.get("refresh_token")
|
|
313
320
|
if not refresh_token:
|
|
314
|
-
raise RuntimeError("No refresh token in
|
|
321
|
+
raise RuntimeError(f"No refresh token in {OAUTH_FILE}")
|
|
315
322
|
|
|
316
323
|
client_id, client_secret = _get_oauth_client_credentials(creds)
|
|
317
324
|
payload = urllib.parse.urlencode(
|
|
@@ -356,7 +363,7 @@ def get_access_token(
|
|
|
356
363
|
) -> str:
|
|
357
364
|
creds = get_oauth_credentials()
|
|
358
365
|
if not creds:
|
|
359
|
-
raise RuntimeError("No OAuth credentials at
|
|
366
|
+
raise RuntimeError(f"No OAuth credentials at {OAUTH_FILE}")
|
|
360
367
|
|
|
361
368
|
token = creds.get("access_token")
|
|
362
369
|
token_was_replaced = (
|
|
@@ -373,7 +380,7 @@ def get_access_token(
|
|
|
373
380
|
|
|
374
381
|
token = creds.get("access_token")
|
|
375
382
|
if not token:
|
|
376
|
-
raise RuntimeError("No access token in
|
|
383
|
+
raise RuntimeError(f"No access token in {OAUTH_FILE}")
|
|
377
384
|
return token
|
|
378
385
|
|
|
379
386
|
|
|
@@ -415,13 +422,37 @@ def _load_code_assist(access_token: str) -> dict:
|
|
|
415
422
|
)
|
|
416
423
|
|
|
417
424
|
|
|
418
|
-
def _parse_quota_buckets(buckets:
|
|
425
|
+
def _parse_quota_buckets(buckets: object) -> list[dict]:
|
|
426
|
+
if not isinstance(buckets, list):
|
|
427
|
+
raise RuntimeError("Invalid quota response")
|
|
428
|
+
|
|
419
429
|
parsed = []
|
|
420
430
|
for bucket in buckets:
|
|
431
|
+
if not isinstance(bucket, dict):
|
|
432
|
+
raise RuntimeError("Invalid quota response")
|
|
433
|
+
|
|
421
434
|
remaining = None
|
|
422
435
|
limit = None
|
|
423
|
-
|
|
436
|
+
model = bucket.get("modelId")
|
|
424
437
|
remaining_fraction = bucket.get("remainingFraction")
|
|
438
|
+
reset_time = bucket.get("resetTime")
|
|
439
|
+
|
|
440
|
+
if model is None or model == "" or remaining_fraction is None:
|
|
441
|
+
continue
|
|
442
|
+
|
|
443
|
+
if (
|
|
444
|
+
not isinstance(model, str)
|
|
445
|
+
or not model.strip()
|
|
446
|
+
or not isinstance(remaining_fraction, int | float)
|
|
447
|
+
or isinstance(remaining_fraction, bool)
|
|
448
|
+
or (
|
|
449
|
+
isinstance(remaining_fraction, float)
|
|
450
|
+
and not math.isfinite(remaining_fraction)
|
|
451
|
+
)
|
|
452
|
+
or not 0 <= remaining_fraction <= 1
|
|
453
|
+
or (reset_time is not None and _parse_iso(reset_time) is None)
|
|
454
|
+
):
|
|
455
|
+
raise RuntimeError("Invalid quota response")
|
|
425
456
|
|
|
426
457
|
try:
|
|
427
458
|
if bucket.get("remainingAmount") is not None:
|
|
@@ -429,20 +460,18 @@ def _parse_quota_buckets(buckets: list[dict]) -> list[dict]:
|
|
|
429
460
|
except (TypeError, ValueError):
|
|
430
461
|
remaining = None
|
|
431
462
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
if remaining_fraction > 0:
|
|
436
|
-
limit = round(remaining / float(remaining_fraction))
|
|
463
|
+
used_pct = (1 - float(remaining_fraction)) * 100
|
|
464
|
+
if remaining is not None and remaining_fraction > 0:
|
|
465
|
+
limit = round(remaining / float(remaining_fraction))
|
|
437
466
|
|
|
438
467
|
parsed.append(
|
|
439
468
|
{
|
|
440
|
-
"model":
|
|
469
|
+
"model": model,
|
|
441
470
|
"remaining": remaining,
|
|
442
471
|
"limit": limit,
|
|
443
472
|
"used_pct": used_pct,
|
|
444
473
|
"remaining_fraction": remaining_fraction,
|
|
445
|
-
"reset_time":
|
|
474
|
+
"reset_time": reset_time,
|
|
446
475
|
"token_type": bucket.get("tokenType"),
|
|
447
476
|
}
|
|
448
477
|
)
|
|
@@ -485,13 +514,17 @@ def fetch_quota(project_root: Path | None = None) -> dict:
|
|
|
485
514
|
quota_res = _code_assist_post(
|
|
486
515
|
"retrieveUserQuota", {"project": project_id}, access_token
|
|
487
516
|
)
|
|
517
|
+
if not isinstance(quota_res, dict) or not isinstance(
|
|
518
|
+
quota_res.get("buckets"), list
|
|
519
|
+
):
|
|
520
|
+
raise RuntimeError("Invalid quota response")
|
|
488
521
|
current_tier = load_res.get("currentTier") or {}
|
|
489
522
|
paid_tier = load_res.get("paidTier") or {}
|
|
490
523
|
result = {
|
|
491
524
|
"project_id": project_id,
|
|
492
525
|
"user_tier": paid_tier.get("id") or current_tier.get("id"),
|
|
493
526
|
"user_tier_name": paid_tier.get("name") or current_tier.get("name"),
|
|
494
|
-
"buckets": _parse_quota_buckets(quota_res
|
|
527
|
+
"buckets": _parse_quota_buckets(quota_res["buckets"]),
|
|
495
528
|
}
|
|
496
529
|
result["summary_bucket"] = _select_summary_bucket(result)
|
|
497
530
|
return result
|
|
@@ -37,6 +37,12 @@ def _usage_payload(project_root: Path, updated_at: str) -> dict:
|
|
|
37
37
|
|
|
38
38
|
|
|
39
39
|
class GeminiUsageTests(unittest.TestCase):
|
|
40
|
+
def test_gemini_cli_home_sets_global_gemini_directory(self):
|
|
41
|
+
with tempfile.TemporaryDirectory() as tmp, mock.patch.dict(
|
|
42
|
+
os.environ, {"GEMINI_CLI_HOME": tmp}, clear=True
|
|
43
|
+
):
|
|
44
|
+
self.assertEqual(gemini_cli_usage._gemini_dir(), Path(tmp) / ".gemini")
|
|
45
|
+
|
|
40
46
|
def test_env_auth_overrides_workspace_and_global_settings(self):
|
|
41
47
|
with tempfile.TemporaryDirectory() as tmp:
|
|
42
48
|
tmp_path = Path(tmp)
|
|
@@ -117,6 +123,53 @@ class GeminiUsageTests(unittest.TestCase):
|
|
|
117
123
|
self.assertIn("q:3.5%", statusline)
|
|
118
124
|
self.assertNotIn("q:0.07%", statusline)
|
|
119
125
|
|
|
126
|
+
def test_fetch_quota_rejects_missing_buckets(self):
|
|
127
|
+
with (
|
|
128
|
+
mock.patch.object(
|
|
129
|
+
gemini_cli_usage, "get_auth_type", return_value="oauth-personal"
|
|
130
|
+
),
|
|
131
|
+
mock.patch.object(
|
|
132
|
+
gemini_cli_usage, "get_access_token", return_value="token"
|
|
133
|
+
),
|
|
134
|
+
mock.patch.object(
|
|
135
|
+
gemini_cli_usage,
|
|
136
|
+
"_load_code_assist",
|
|
137
|
+
return_value={"cloudaicompanionProject": "project"},
|
|
138
|
+
),
|
|
139
|
+
mock.patch.object(gemini_cli_usage, "_code_assist_post", return_value={}),
|
|
140
|
+
):
|
|
141
|
+
with self.assertRaisesRegex(RuntimeError, "Invalid quota response"):
|
|
142
|
+
gemini_cli_usage.fetch_quota()
|
|
143
|
+
|
|
144
|
+
def test_quota_buckets_require_finite_fraction_and_valid_reset(self):
|
|
145
|
+
invalid_buckets = (
|
|
146
|
+
{"modelId": "model", "remainingFraction": float("nan")},
|
|
147
|
+
{"modelId": "model", "remainingFraction": float("inf")},
|
|
148
|
+
{"modelId": "model", "remainingFraction": 1.1},
|
|
149
|
+
{
|
|
150
|
+
"modelId": "model",
|
|
151
|
+
"remainingFraction": 0.5,
|
|
152
|
+
"resetTime": "not-a-timestamp",
|
|
153
|
+
},
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
for bucket in invalid_buckets:
|
|
157
|
+
with self.subTest(bucket=bucket), self.assertRaisesRegex(
|
|
158
|
+
RuntimeError, "Invalid quota response"
|
|
159
|
+
):
|
|
160
|
+
gemini_cli_usage._parse_quota_buckets([bucket])
|
|
161
|
+
|
|
162
|
+
def test_quota_buckets_skip_incomplete_entries(self):
|
|
163
|
+
buckets = gemini_cli_usage._parse_quota_buckets(
|
|
164
|
+
[
|
|
165
|
+
{"remainingFraction": 0.5},
|
|
166
|
+
{"modelId": "missing-fraction"},
|
|
167
|
+
{"modelId": "valid-model", "remainingFraction": 0.25},
|
|
168
|
+
]
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
self.assertEqual([bucket["model"] for bucket in buckets], ["valid-model"])
|
|
172
|
+
|
|
120
173
|
def test_force_refresh_bypasses_cache(self):
|
|
121
174
|
with tempfile.TemporaryDirectory() as tmp:
|
|
122
175
|
tmp_path = Path(tmp)
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
name: Publish to PyPI
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
workflow_dispatch:
|
|
5
|
-
push:
|
|
6
|
-
tags:
|
|
7
|
-
- "v*"
|
|
8
|
-
|
|
9
|
-
permissions:
|
|
10
|
-
id-token: write
|
|
11
|
-
|
|
12
|
-
jobs:
|
|
13
|
-
publish:
|
|
14
|
-
runs-on: ubuntu-latest
|
|
15
|
-
environment: pypi
|
|
16
|
-
steps:
|
|
17
|
-
- uses: actions/checkout@v4
|
|
18
|
-
- uses: astral-sh/setup-uv@v4
|
|
19
|
-
- run: uv build
|
|
20
|
-
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
File without changes
|
|
File without changes
|