agy-usage 0.1.0__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.
- agy_usage-0.1.0/.github/workflows/publish.yml +20 -0
- agy_usage-0.1.0/.gitignore +6 -0
- agy_usage-0.1.0/PKG-INFO +81 -0
- agy_usage-0.1.0/README.md +72 -0
- agy_usage-0.1.0/pyproject.toml +25 -0
- agy_usage-0.1.0/src/agy_usage/__init__.py +513 -0
- agy_usage-0.1.0/src/agy_usage/__main__.py +3 -0
- agy_usage-0.1.0/tests/test_agy_usage.py +142 -0
- agy_usage-0.1.0/uv.lock +20 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
name: publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_dispatch:
|
|
5
|
+
push:
|
|
6
|
+
tags:
|
|
7
|
+
- "v*"
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
publish:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
environment: pypi
|
|
13
|
+
permissions:
|
|
14
|
+
id-token: write
|
|
15
|
+
contents: read
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
- uses: astral-sh/setup-uv@v5
|
|
19
|
+
- run: uv build
|
|
20
|
+
- run: uv publish
|
agy_usage-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agy-usage
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Antigravity CLI usage and quota monitor
|
|
5
|
+
Project-URL: Homepage, https://github.com/wakamex/agy-usage
|
|
6
|
+
Project-URL: Source, https://github.com/wakamex/agy-usage
|
|
7
|
+
Requires-Python: >=3.12
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# agy-usage
|
|
11
|
+
|
|
12
|
+
Antigravity CLI usage and quota monitor. It mirrors the small `ccusage`,
|
|
13
|
+
`gemini-cli-usage`, and `codex-cli-usage` tools: dependency-free Python,
|
|
14
|
+
terminal output, JSON output, statusline output, and a cache-refresh daemon.
|
|
15
|
+
|
|
16
|
+
## Example output
|
|
17
|
+
|
|
18
|
+
```text
|
|
19
|
+
Project: agy-usage
|
|
20
|
+
Model: Gemini 3.5 Flash (High)
|
|
21
|
+
gemini-3.5-flash-high 12.4% used resets 1h05m
|
|
22
|
+
History: 24 entries, latest 2026-06-29T22:53:01+00:00
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Statusline:
|
|
26
|
+
|
|
27
|
+
```text
|
|
28
|
+
q:12.4% reset:1h05m model:Gemini_3.5_Flash_(High)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
uv tool install agy-usage
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
For local development from a checkout:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
uv tool install .
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Commands
|
|
44
|
+
|
|
45
|
+
| Command | Description |
|
|
46
|
+
|---------|-------------|
|
|
47
|
+
| `agy-usage` | Show current usage |
|
|
48
|
+
| `agy-usage status` | Same as above |
|
|
49
|
+
| `agy-usage json` | Print raw JSON |
|
|
50
|
+
| `agy-usage statusline` | Compact statusline output |
|
|
51
|
+
| `agy-usage refresh` | Force a fresh fetch, rewrite cache, and print status |
|
|
52
|
+
| `agy-usage daemon [-i SECS]` | Keep the cache fresh in the foreground |
|
|
53
|
+
| `agy-usage install` | Print setup instructions |
|
|
54
|
+
|
|
55
|
+
## Data sources
|
|
56
|
+
|
|
57
|
+
- Antigravity settings: `~/.gemini/antigravity-cli/settings.json`
|
|
58
|
+
- Antigravity OAuth token: `~/.gemini/antigravity-cli/antigravity-oauth-token`
|
|
59
|
+
- Antigravity command history: `~/.gemini/antigravity-cli/history.jsonl`
|
|
60
|
+
- Cache written by this tool: `~/.gemini/antigravity-cli/usage-limits.json`
|
|
61
|
+
|
|
62
|
+
Quota lookup uses the same Code Assist quota flow Antigravity logs mention:
|
|
63
|
+
`loadCodeAssist` followed by `retrieveUserQuota`.
|
|
64
|
+
|
|
65
|
+
## Options
|
|
66
|
+
|
|
67
|
+
```text
|
|
68
|
+
usage: agy-usage [-h] [--root ROOT] [-i INTERVAL] [--max-age MAX_AGE]
|
|
69
|
+
[--refresh]
|
|
70
|
+
{status,json,daemon,statusline,refresh,install}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
- `--root ROOT`: inspect a different project root instead of the current directory
|
|
74
|
+
- `--max-age MAX_AGE`: cache TTL for `statusline`
|
|
75
|
+
- `--refresh`: ignore the cache and rebuild fresh data where applicable
|
|
76
|
+
|
|
77
|
+
Environment overrides:
|
|
78
|
+
|
|
79
|
+
- `AGY_USAGE_FILE`: alternate cache path
|
|
80
|
+
- `AGY_ACCESS_TOKEN`: provide an access token instead of reading Antigravity state
|
|
81
|
+
- `AGY_CODE_ASSIST_BASE_URL`: alternate Code Assist base URL
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# agy-usage
|
|
2
|
+
|
|
3
|
+
Antigravity CLI usage and quota monitor. It mirrors the small `ccusage`,
|
|
4
|
+
`gemini-cli-usage`, and `codex-cli-usage` tools: dependency-free Python,
|
|
5
|
+
terminal output, JSON output, statusline output, and a cache-refresh daemon.
|
|
6
|
+
|
|
7
|
+
## Example output
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
Project: agy-usage
|
|
11
|
+
Model: Gemini 3.5 Flash (High)
|
|
12
|
+
gemini-3.5-flash-high 12.4% used resets 1h05m
|
|
13
|
+
History: 24 entries, latest 2026-06-29T22:53:01+00:00
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Statusline:
|
|
17
|
+
|
|
18
|
+
```text
|
|
19
|
+
q:12.4% reset:1h05m model:Gemini_3.5_Flash_(High)
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
uv tool install agy-usage
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
For local development from a checkout:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
uv tool install .
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Commands
|
|
35
|
+
|
|
36
|
+
| Command | Description |
|
|
37
|
+
|---------|-------------|
|
|
38
|
+
| `agy-usage` | Show current usage |
|
|
39
|
+
| `agy-usage status` | Same as above |
|
|
40
|
+
| `agy-usage json` | Print raw JSON |
|
|
41
|
+
| `agy-usage statusline` | Compact statusline output |
|
|
42
|
+
| `agy-usage refresh` | Force a fresh fetch, rewrite cache, and print status |
|
|
43
|
+
| `agy-usage daemon [-i SECS]` | Keep the cache fresh in the foreground |
|
|
44
|
+
| `agy-usage install` | Print setup instructions |
|
|
45
|
+
|
|
46
|
+
## Data sources
|
|
47
|
+
|
|
48
|
+
- Antigravity settings: `~/.gemini/antigravity-cli/settings.json`
|
|
49
|
+
- Antigravity OAuth token: `~/.gemini/antigravity-cli/antigravity-oauth-token`
|
|
50
|
+
- Antigravity command history: `~/.gemini/antigravity-cli/history.jsonl`
|
|
51
|
+
- Cache written by this tool: `~/.gemini/antigravity-cli/usage-limits.json`
|
|
52
|
+
|
|
53
|
+
Quota lookup uses the same Code Assist quota flow Antigravity logs mention:
|
|
54
|
+
`loadCodeAssist` followed by `retrieveUserQuota`.
|
|
55
|
+
|
|
56
|
+
## Options
|
|
57
|
+
|
|
58
|
+
```text
|
|
59
|
+
usage: agy-usage [-h] [--root ROOT] [-i INTERVAL] [--max-age MAX_AGE]
|
|
60
|
+
[--refresh]
|
|
61
|
+
{status,json,daemon,statusline,refresh,install}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
- `--root ROOT`: inspect a different project root instead of the current directory
|
|
65
|
+
- `--max-age MAX_AGE`: cache TTL for `statusline`
|
|
66
|
+
- `--refresh`: ignore the cache and rebuild fresh data where applicable
|
|
67
|
+
|
|
68
|
+
Environment overrides:
|
|
69
|
+
|
|
70
|
+
- `AGY_USAGE_FILE`: alternate cache path
|
|
71
|
+
- `AGY_ACCESS_TOKEN`: provide an access token instead of reading Antigravity state
|
|
72
|
+
- `AGY_CODE_ASSIST_BASE_URL`: alternate Code Assist base URL
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "agy-usage"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Antigravity CLI usage and quota monitor"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
dependencies = []
|
|
8
|
+
|
|
9
|
+
[tool.uv]
|
|
10
|
+
package = true
|
|
11
|
+
|
|
12
|
+
[build-system]
|
|
13
|
+
requires = ["hatchling"]
|
|
14
|
+
build-backend = "hatchling.build"
|
|
15
|
+
|
|
16
|
+
[tool.hatch.build.targets.wheel]
|
|
17
|
+
packages = ["src/agy_usage"]
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://github.com/wakamex/agy-usage"
|
|
21
|
+
Source = "https://github.com/wakamex/agy-usage"
|
|
22
|
+
|
|
23
|
+
[project.scripts]
|
|
24
|
+
agy-usage = "agy_usage:main"
|
|
25
|
+
agyusage = "agy_usage:main"
|
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""agy-usage - Antigravity CLI usage and quota monitor."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import signal
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.parse
|
|
14
|
+
import urllib.request
|
|
15
|
+
from collections import Counter
|
|
16
|
+
from datetime import UTC, datetime
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
AGY_DIR = Path.home() / ".gemini" / "antigravity-cli"
|
|
21
|
+
TOKEN_FILE = AGY_DIR / "antigravity-oauth-token"
|
|
22
|
+
SETTINGS_FILE = AGY_DIR / "settings.json"
|
|
23
|
+
HISTORY_FILE = AGY_DIR / "history.jsonl"
|
|
24
|
+
DEFAULT_USAGE_FILE = AGY_DIR / "usage-limits.json"
|
|
25
|
+
|
|
26
|
+
DAEMON_INTERVAL = 300
|
|
27
|
+
CACHE_MAX_AGE = 300
|
|
28
|
+
CODE_ASSIST_BASE_URL = "https://daily-cloudcode-pa.googleapis.com/v1internal"
|
|
29
|
+
|
|
30
|
+
_TTY = sys.stdout.isatty()
|
|
31
|
+
_RED = "\033[0;31m" if _TTY else ""
|
|
32
|
+
_YELLOW = "\033[0;33m" if _TTY else ""
|
|
33
|
+
_GREEN = "\033[0;32m" if _TTY else ""
|
|
34
|
+
_DIM = "\033[0;90m" if _TTY else ""
|
|
35
|
+
_RESET = "\033[0m" if _TTY else ""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _read_json(path: Path) -> dict | list | None:
|
|
39
|
+
try:
|
|
40
|
+
return json.loads(path.read_text())
|
|
41
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _iso_now() -> str:
|
|
46
|
+
return datetime.now(UTC).isoformat()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _parse_iso(timestamp: str | None) -> datetime | None:
|
|
50
|
+
if not timestamp:
|
|
51
|
+
return None
|
|
52
|
+
try:
|
|
53
|
+
parsed = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
|
|
54
|
+
except ValueError:
|
|
55
|
+
return None
|
|
56
|
+
if parsed.tzinfo is None:
|
|
57
|
+
return parsed.replace(tzinfo=UTC)
|
|
58
|
+
return parsed
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _format_duration_until(iso_timestamp: str | None) -> str:
|
|
62
|
+
reset = _parse_iso(iso_timestamp)
|
|
63
|
+
if not reset:
|
|
64
|
+
return ""
|
|
65
|
+
seconds = int((reset - datetime.now(UTC)).total_seconds())
|
|
66
|
+
if seconds <= 0:
|
|
67
|
+
return ""
|
|
68
|
+
minutes = seconds // 60
|
|
69
|
+
if minutes >= 60:
|
|
70
|
+
return f"{minutes // 60}h{minutes % 60}m"
|
|
71
|
+
return f"{minutes}m"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _format_pct(pct: float | int | None) -> str:
|
|
75
|
+
if pct is None:
|
|
76
|
+
return "?"
|
|
77
|
+
value = float(pct)
|
|
78
|
+
if value >= 1:
|
|
79
|
+
return f"{value:.1f}%"
|
|
80
|
+
return f"{value:.2f}%"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _color_pct(pct: float | int | None) -> str:
|
|
84
|
+
if pct is None:
|
|
85
|
+
return "?"
|
|
86
|
+
value = float(pct)
|
|
87
|
+
color = _RED if value >= 70 else _YELLOW if value >= 40 else _GREEN
|
|
88
|
+
return f"{color}{_format_pct(value)}{_RESET}"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_usage_file() -> Path:
|
|
92
|
+
override = os.environ.get("AGY_USAGE_FILE") or os.environ.get("ANTIGRAVITY_USAGE_FILE")
|
|
93
|
+
return Path(override).expanduser() if override else DEFAULT_USAGE_FILE
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def get_settings() -> dict:
|
|
97
|
+
data = _read_json(SETTINGS_FILE)
|
|
98
|
+
return data if isinstance(data, dict) else {}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def get_auth() -> dict | None:
|
|
102
|
+
data = _read_json(TOKEN_FILE)
|
|
103
|
+
return data if isinstance(data, dict) else None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def get_access_token() -> str:
|
|
107
|
+
env_token = os.environ.get("AGY_ACCESS_TOKEN") or os.environ.get("ANTIGRAVITY_ACCESS_TOKEN")
|
|
108
|
+
if env_token:
|
|
109
|
+
return env_token
|
|
110
|
+
|
|
111
|
+
auth = get_auth()
|
|
112
|
+
if not auth:
|
|
113
|
+
raise RuntimeError("No Antigravity OAuth token at ~/.gemini/antigravity-cli/antigravity-oauth-token")
|
|
114
|
+
|
|
115
|
+
token_payload = auth.get("token")
|
|
116
|
+
token = auth.get("access_token") or auth.get("AccessToken")
|
|
117
|
+
if isinstance(token_payload, dict):
|
|
118
|
+
token = token or token_payload.get("access_token") or token_payload.get("AccessToken")
|
|
119
|
+
elif isinstance(token_payload, str):
|
|
120
|
+
token = token or token_payload
|
|
121
|
+
if not isinstance(token, str) or not token:
|
|
122
|
+
raise RuntimeError("No access token in ~/.gemini/antigravity-cli/antigravity-oauth-token")
|
|
123
|
+
return token
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _code_assist_post(method: str, payload: dict, access_token: str) -> dict:
|
|
127
|
+
base_url = os.environ.get("AGY_CODE_ASSIST_BASE_URL", CODE_ASSIST_BASE_URL).rstrip("/")
|
|
128
|
+
req = urllib.request.Request(
|
|
129
|
+
f"{base_url}:{method}",
|
|
130
|
+
data=json.dumps(payload).encode(),
|
|
131
|
+
headers={
|
|
132
|
+
"Authorization": f"Bearer {access_token}",
|
|
133
|
+
"Content-Type": "application/json",
|
|
134
|
+
"Accept": "application/json",
|
|
135
|
+
"User-Agent": "agy-usage/0.1.0",
|
|
136
|
+
},
|
|
137
|
+
)
|
|
138
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
139
|
+
return json.loads(resp.read())
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _load_code_assist(access_token: str) -> dict:
|
|
143
|
+
project_id = (
|
|
144
|
+
os.environ.get("GOOGLE_CLOUD_PROJECT")
|
|
145
|
+
or os.environ.get("GOOGLE_CLOUD_PROJECT_ID")
|
|
146
|
+
or _read_default_project_id()
|
|
147
|
+
)
|
|
148
|
+
metadata: dict[str, Any] = {
|
|
149
|
+
"ideType": "IDE_UNSPECIFIED",
|
|
150
|
+
"platform": "PLATFORM_UNSPECIFIED",
|
|
151
|
+
"pluginType": "GEMINI",
|
|
152
|
+
}
|
|
153
|
+
if project_id:
|
|
154
|
+
metadata["duetProject"] = project_id
|
|
155
|
+
return _code_assist_post(
|
|
156
|
+
"loadCodeAssist",
|
|
157
|
+
{
|
|
158
|
+
"cloudaicompanionProject": project_id,
|
|
159
|
+
"metadata": metadata,
|
|
160
|
+
},
|
|
161
|
+
access_token,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _read_default_project_id() -> str | None:
|
|
166
|
+
try:
|
|
167
|
+
project_id = (AGY_DIR / "cache" / "default_project_id.txt").read_text().strip()
|
|
168
|
+
except OSError:
|
|
169
|
+
return None
|
|
170
|
+
return project_id or None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _parse_quota_buckets(buckets: list[dict]) -> list[dict]:
|
|
174
|
+
parsed = []
|
|
175
|
+
for bucket in buckets:
|
|
176
|
+
remaining = None
|
|
177
|
+
limit = None
|
|
178
|
+
used_pct = None
|
|
179
|
+
remaining_fraction = bucket.get("remainingFraction")
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
if bucket.get("remainingAmount") is not None:
|
|
183
|
+
remaining = int(bucket["remainingAmount"])
|
|
184
|
+
except (TypeError, ValueError):
|
|
185
|
+
remaining = None
|
|
186
|
+
|
|
187
|
+
if isinstance(remaining_fraction, int | float):
|
|
188
|
+
used_pct = (1 - float(remaining_fraction)) * 100
|
|
189
|
+
if remaining is not None and isinstance(remaining_fraction, int | float):
|
|
190
|
+
if remaining_fraction > 0:
|
|
191
|
+
limit = round(remaining / float(remaining_fraction))
|
|
192
|
+
|
|
193
|
+
parsed.append(
|
|
194
|
+
{
|
|
195
|
+
"model": bucket.get("modelId") or bucket.get("model"),
|
|
196
|
+
"remaining": remaining,
|
|
197
|
+
"limit": limit,
|
|
198
|
+
"used_pct": used_pct,
|
|
199
|
+
"remaining_fraction": remaining_fraction,
|
|
200
|
+
"reset_time": bucket.get("resetTime"),
|
|
201
|
+
"token_type": bucket.get("tokenType"),
|
|
202
|
+
"disabled": bool(bucket.get("disabled", False)),
|
|
203
|
+
}
|
|
204
|
+
)
|
|
205
|
+
return parsed
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _select_summary_bucket(quota: dict) -> dict | None:
|
|
209
|
+
buckets = quota.get("buckets") or []
|
|
210
|
+
if not isinstance(buckets, list):
|
|
211
|
+
return None
|
|
212
|
+
active = [bucket for bucket in buckets if not bucket.get("disabled")]
|
|
213
|
+
scored = [bucket for bucket in active if bucket.get("used_pct") is not None]
|
|
214
|
+
if scored:
|
|
215
|
+
return max(scored, key=lambda bucket: bucket["used_pct"])
|
|
216
|
+
return active[0] if active else (buckets[0] if buckets else None)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def fetch_quota() -> dict:
|
|
220
|
+
access_token = get_access_token()
|
|
221
|
+
load_res = _load_code_assist(access_token)
|
|
222
|
+
|
|
223
|
+
project_id = (
|
|
224
|
+
load_res.get("cloudaicompanionProject")
|
|
225
|
+
or os.environ.get("GOOGLE_CLOUD_PROJECT")
|
|
226
|
+
or os.environ.get("GOOGLE_CLOUD_PROJECT_ID")
|
|
227
|
+
or _read_default_project_id()
|
|
228
|
+
)
|
|
229
|
+
if not project_id:
|
|
230
|
+
raise RuntimeError("No Code Assist project ID available. Set GOOGLE_CLOUD_PROJECT if needed.")
|
|
231
|
+
|
|
232
|
+
quota_res = _code_assist_post("retrieveUserQuota", {"project": project_id}, access_token)
|
|
233
|
+
current_tier = load_res.get("currentTier") or {}
|
|
234
|
+
paid_tier = load_res.get("paidTier") or {}
|
|
235
|
+
result = {
|
|
236
|
+
"project_id": project_id,
|
|
237
|
+
"user_tier": paid_tier.get("id") or current_tier.get("id"),
|
|
238
|
+
"user_tier_name": paid_tier.get("name") or current_tier.get("name"),
|
|
239
|
+
"buckets": _parse_quota_buckets(quota_res.get("buckets") or []),
|
|
240
|
+
}
|
|
241
|
+
result["summary_bucket"] = _select_summary_bucket(result)
|
|
242
|
+
credits = quota_res.get("credits") or quota_res.get("g1Credits")
|
|
243
|
+
if credits is not None:
|
|
244
|
+
result["credits"] = credits
|
|
245
|
+
return result
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def read_history_summary(path: Path = HISTORY_FILE) -> dict:
|
|
249
|
+
commands: Counter[str] = Counter()
|
|
250
|
+
workspaces: Counter[str] = Counter()
|
|
251
|
+
total = 0
|
|
252
|
+
latest_ms: int | None = None
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
lines = path.read_text().splitlines()
|
|
256
|
+
except OSError:
|
|
257
|
+
lines = []
|
|
258
|
+
|
|
259
|
+
for line in lines:
|
|
260
|
+
try:
|
|
261
|
+
item = json.loads(line)
|
|
262
|
+
except json.JSONDecodeError:
|
|
263
|
+
continue
|
|
264
|
+
if not isinstance(item, dict):
|
|
265
|
+
continue
|
|
266
|
+
total += 1
|
|
267
|
+
display = item.get("display")
|
|
268
|
+
workspace = item.get("workspace")
|
|
269
|
+
timestamp = item.get("timestamp")
|
|
270
|
+
if isinstance(display, str):
|
|
271
|
+
commands[display] += 1
|
|
272
|
+
if isinstance(workspace, str):
|
|
273
|
+
workspaces[workspace] += 1
|
|
274
|
+
if isinstance(timestamp, int):
|
|
275
|
+
latest_ms = timestamp if latest_ms is None else max(latest_ms, timestamp)
|
|
276
|
+
|
|
277
|
+
latest_at = (
|
|
278
|
+
datetime.fromtimestamp(latest_ms / 1000, tz=UTC).isoformat()
|
|
279
|
+
if latest_ms is not None
|
|
280
|
+
else None
|
|
281
|
+
)
|
|
282
|
+
return {
|
|
283
|
+
"entries": total,
|
|
284
|
+
"latest_at": latest_at,
|
|
285
|
+
"top_commands": dict(commands.most_common(10)),
|
|
286
|
+
"top_workspaces": dict(workspaces.most_common(10)),
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def build_usage_json(project_root: Path | None = None) -> dict:
|
|
291
|
+
root = (project_root or Path.cwd()).resolve()
|
|
292
|
+
settings = get_settings()
|
|
293
|
+
result = {
|
|
294
|
+
"project_root": str(root),
|
|
295
|
+
"model": settings.get("model"),
|
|
296
|
+
"source": [],
|
|
297
|
+
"updated_at": _iso_now(),
|
|
298
|
+
"history": read_history_summary(),
|
|
299
|
+
}
|
|
300
|
+
if result["history"]["entries"]:
|
|
301
|
+
result["source"].append("history")
|
|
302
|
+
|
|
303
|
+
try:
|
|
304
|
+
result["account_quota"] = fetch_quota()
|
|
305
|
+
result["source"].append("quota_api")
|
|
306
|
+
except Exception as exc:
|
|
307
|
+
result["quota_error"] = str(exc)
|
|
308
|
+
|
|
309
|
+
return result
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def write_usage_file(data: dict):
|
|
313
|
+
usage_file = get_usage_file()
|
|
314
|
+
usage_file.parent.mkdir(parents=True, exist_ok=True)
|
|
315
|
+
usage_file.write_text(json.dumps(data, indent=2) + "\n")
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _print_status(data: dict):
|
|
319
|
+
print(f"Project: {Path(data['project_root']).name}")
|
|
320
|
+
model = data.get("model")
|
|
321
|
+
if model:
|
|
322
|
+
print(f"Model: {model}")
|
|
323
|
+
|
|
324
|
+
quota = data.get("account_quota")
|
|
325
|
+
if quota:
|
|
326
|
+
bucket_names = [
|
|
327
|
+
(bucket.get("model") or "unknown")
|
|
328
|
+
for bucket in quota.get("buckets", [])
|
|
329
|
+
if isinstance(bucket, dict)
|
|
330
|
+
]
|
|
331
|
+
name_width = max(map(len, bucket_names), default=len("Quota"))
|
|
332
|
+
for bucket in quota.get("buckets", []):
|
|
333
|
+
model_name = bucket.get("model") or "unknown"
|
|
334
|
+
if bucket.get("disabled"):
|
|
335
|
+
print(f" {model_name:{name_width}s} {_DIM}disabled{_RESET}")
|
|
336
|
+
continue
|
|
337
|
+
reset_time = _format_duration_until(bucket.get("reset_time"))
|
|
338
|
+
reset_part = f" resets {reset_time}" if reset_time else ""
|
|
339
|
+
remaining = bucket.get("remaining")
|
|
340
|
+
limit = bucket.get("limit")
|
|
341
|
+
remain_part = (
|
|
342
|
+
f" {remaining} / {limit} remaining"
|
|
343
|
+
if remaining is not None and limit is not None
|
|
344
|
+
else ""
|
|
345
|
+
)
|
|
346
|
+
print(
|
|
347
|
+
f" {model_name:{name_width}s} {_color_pct(bucket.get('used_pct'))} used"
|
|
348
|
+
f"{remain_part}{_DIM}{reset_part}{_RESET}"
|
|
349
|
+
)
|
|
350
|
+
elif data.get("quota_error"):
|
|
351
|
+
print(f" {'Quota':20s} {_DIM}{data['quota_error']}{_RESET}")
|
|
352
|
+
|
|
353
|
+
history = data.get("history") or {}
|
|
354
|
+
if history.get("entries"):
|
|
355
|
+
latest = history.get("latest_at") or "unknown"
|
|
356
|
+
print(f"History: {history['entries']} entries, latest {latest}")
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _statusline_text(data: dict) -> str:
|
|
360
|
+
parts = []
|
|
361
|
+
quota = data.get("account_quota")
|
|
362
|
+
if quota and quota.get("summary_bucket"):
|
|
363
|
+
summary = quota["summary_bucket"]
|
|
364
|
+
if summary.get("disabled"):
|
|
365
|
+
parts.append("q:disabled")
|
|
366
|
+
elif summary.get("used_pct") is not None:
|
|
367
|
+
parts.append(f"q:{_format_pct(summary['used_pct'])}")
|
|
368
|
+
reset_time = _format_duration_until(summary.get("reset_time"))
|
|
369
|
+
if reset_time:
|
|
370
|
+
parts.append(f"reset:{reset_time}")
|
|
371
|
+
elif data.get("quota_error"):
|
|
372
|
+
parts.append("q:err")
|
|
373
|
+
|
|
374
|
+
model = data.get("model")
|
|
375
|
+
if model:
|
|
376
|
+
parts.append(f"model:{model.replace(' ', '_')}")
|
|
377
|
+
return " ".join(parts)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _get_cached_usage(
|
|
381
|
+
project_root: Path | None = None,
|
|
382
|
+
max_age: int = CACHE_MAX_AGE,
|
|
383
|
+
force_refresh: bool = False,
|
|
384
|
+
) -> dict:
|
|
385
|
+
usage_file = get_usage_file()
|
|
386
|
+
root = str((project_root or Path.cwd()).resolve())
|
|
387
|
+
if not force_refresh:
|
|
388
|
+
try:
|
|
389
|
+
cached = json.loads(usage_file.read_text())
|
|
390
|
+
updated = _parse_iso(cached.get("updated_at"))
|
|
391
|
+
if updated and cached.get("project_root") == root:
|
|
392
|
+
age = (datetime.now(UTC) - updated).total_seconds()
|
|
393
|
+
if age < max_age and "quota_api" in cached.get("source", []):
|
|
394
|
+
return cached
|
|
395
|
+
except Exception:
|
|
396
|
+
pass
|
|
397
|
+
|
|
398
|
+
try:
|
|
399
|
+
fresh = build_usage_json(project_root)
|
|
400
|
+
write_usage_file(fresh)
|
|
401
|
+
return fresh
|
|
402
|
+
except Exception:
|
|
403
|
+
try:
|
|
404
|
+
return json.loads(usage_file.read_text())
|
|
405
|
+
except Exception:
|
|
406
|
+
return build_usage_json(project_root)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def cmd_status(args):
|
|
410
|
+
data = build_usage_json(project_root=Path(args.root).resolve() if args.root else None)
|
|
411
|
+
_print_status(data)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def cmd_json(args):
|
|
415
|
+
data = build_usage_json(project_root=Path(args.root).resolve() if args.root else None)
|
|
416
|
+
print(json.dumps(data, indent=2))
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def cmd_daemon(args):
|
|
420
|
+
signal.signal(signal.SIGINT, lambda *_: sys.exit(0))
|
|
421
|
+
if hasattr(signal, "SIGTERM"):
|
|
422
|
+
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
|
|
423
|
+
|
|
424
|
+
root = Path(args.root).resolve() if args.root else None
|
|
425
|
+
usage_file = get_usage_file()
|
|
426
|
+
|
|
427
|
+
print(f"agy-usage daemon started (refreshing every {args.interval}s)")
|
|
428
|
+
print(f"Writing to {usage_file}")
|
|
429
|
+
|
|
430
|
+
while True:
|
|
431
|
+
try:
|
|
432
|
+
data = build_usage_json(project_root=root)
|
|
433
|
+
write_usage_file(data)
|
|
434
|
+
print(f"[{datetime.now().strftime('%H:%M:%S')}] {_statusline_text(data)}")
|
|
435
|
+
except Exception as exc:
|
|
436
|
+
print(f"[{datetime.now().strftime('%H:%M:%S')}] Error: {exc}", file=sys.stderr)
|
|
437
|
+
time.sleep(args.interval)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def cmd_statusline(args):
|
|
441
|
+
data = _get_cached_usage(
|
|
442
|
+
project_root=Path(args.root).resolve() if args.root else None,
|
|
443
|
+
max_age=args.max_age,
|
|
444
|
+
force_refresh=args.refresh,
|
|
445
|
+
)
|
|
446
|
+
print(_statusline_text(data))
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def cmd_refresh(args):
|
|
450
|
+
data = build_usage_json(project_root=Path(args.root).resolve() if args.root else None)
|
|
451
|
+
write_usage_file(data)
|
|
452
|
+
_print_status(data)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def cmd_install(_args):
|
|
456
|
+
print(
|
|
457
|
+
"Install with:\n"
|
|
458
|
+
" uv tool install agy-usage\n\n"
|
|
459
|
+
"For local development:\n"
|
|
460
|
+
" uv tool install .\n\n"
|
|
461
|
+
"Then run:\n"
|
|
462
|
+
" agy-usage\n"
|
|
463
|
+
" agy-usage statusline\n"
|
|
464
|
+
" agy-usage refresh\n"
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
469
|
+
parser = argparse.ArgumentParser(description="Antigravity CLI usage and quota monitor")
|
|
470
|
+
parser.add_argument(
|
|
471
|
+
"command",
|
|
472
|
+
nargs="?",
|
|
473
|
+
default="status",
|
|
474
|
+
choices=["status", "json", "daemon", "statusline", "refresh", "install"],
|
|
475
|
+
)
|
|
476
|
+
parser.add_argument("--root", help="Project root to inspect (default: current working directory)")
|
|
477
|
+
parser.add_argument(
|
|
478
|
+
"-i",
|
|
479
|
+
"--interval",
|
|
480
|
+
type=int,
|
|
481
|
+
default=DAEMON_INTERVAL,
|
|
482
|
+
help="Daemon refresh interval in seconds",
|
|
483
|
+
)
|
|
484
|
+
parser.add_argument(
|
|
485
|
+
"--max-age",
|
|
486
|
+
type=int,
|
|
487
|
+
default=CACHE_MAX_AGE,
|
|
488
|
+
help="Maximum cache age in seconds for statusline",
|
|
489
|
+
)
|
|
490
|
+
parser.add_argument(
|
|
491
|
+
"--refresh",
|
|
492
|
+
action="store_true",
|
|
493
|
+
help="Ignore cache and force a fresh fetch where applicable",
|
|
494
|
+
)
|
|
495
|
+
return parser
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def main():
|
|
499
|
+
parser = _build_parser()
|
|
500
|
+
args = parser.parse_args()
|
|
501
|
+
commands = {
|
|
502
|
+
"status": cmd_status,
|
|
503
|
+
"json": cmd_json,
|
|
504
|
+
"daemon": cmd_daemon,
|
|
505
|
+
"statusline": cmd_statusline,
|
|
506
|
+
"refresh": cmd_refresh,
|
|
507
|
+
"install": cmd_install,
|
|
508
|
+
}
|
|
509
|
+
commands[args.command](args)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
if __name__ == "__main__":
|
|
513
|
+
main()
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
import unittest
|
|
7
|
+
from datetime import UTC, datetime, timedelta
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from unittest import mock
|
|
10
|
+
|
|
11
|
+
import agy_usage
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _write_json(path: Path, payload: dict):
|
|
15
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
16
|
+
path.write_text(json.dumps(payload) + "\n")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AgyUsageTests(unittest.TestCase):
|
|
20
|
+
def test_parse_quota_buckets_computes_usage_and_limit(self):
|
|
21
|
+
buckets = agy_usage._parse_quota_buckets(
|
|
22
|
+
[
|
|
23
|
+
{
|
|
24
|
+
"modelId": "gemini-test",
|
|
25
|
+
"remainingAmount": "25",
|
|
26
|
+
"remainingFraction": 0.5,
|
|
27
|
+
"resetTime": "2026-06-30T01:00:00Z",
|
|
28
|
+
}
|
|
29
|
+
]
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
self.assertEqual(buckets[0]["model"], "gemini-test")
|
|
33
|
+
self.assertEqual(buckets[0]["remaining"], 25)
|
|
34
|
+
self.assertEqual(buckets[0]["limit"], 50)
|
|
35
|
+
self.assertEqual(buckets[0]["used_pct"], 50)
|
|
36
|
+
|
|
37
|
+
def test_summary_bucket_skips_disabled_and_picks_highest_used(self):
|
|
38
|
+
quota = {
|
|
39
|
+
"buckets": [
|
|
40
|
+
{"model": "disabled", "used_pct": 99, "disabled": True},
|
|
41
|
+
{"model": "flash", "used_pct": 10},
|
|
42
|
+
{"model": "pro", "used_pct": 75},
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
self.assertEqual(agy_usage._select_summary_bucket(quota)["model"], "pro")
|
|
47
|
+
|
|
48
|
+
def test_read_history_summary_counts_commands_and_workspaces(self):
|
|
49
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
50
|
+
history = Path(tmp) / "history.jsonl"
|
|
51
|
+
history.write_text(
|
|
52
|
+
json.dumps({"display": "/usage", "timestamp": 1_000, "workspace": "/code/a"}) + "\n"
|
|
53
|
+
+ "not json\n"
|
|
54
|
+
+ json.dumps({"display": "/model", "timestamp": 2_000, "workspace": "/code/a"}) + "\n"
|
|
55
|
+
+ json.dumps({"display": "/usage", "timestamp": 3_000, "workspace": "/code/b"}) + "\n"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
summary = agy_usage.read_history_summary(history)
|
|
59
|
+
|
|
60
|
+
self.assertEqual(summary["entries"], 3)
|
|
61
|
+
self.assertEqual(summary["top_commands"]["/usage"], 2)
|
|
62
|
+
self.assertEqual(summary["top_workspaces"]["/code/a"], 2)
|
|
63
|
+
self.assertEqual(summary["latest_at"], "1970-01-01T00:00:03+00:00")
|
|
64
|
+
|
|
65
|
+
def test_access_token_can_come_from_env(self):
|
|
66
|
+
with mock.patch.dict(os.environ, {"AGY_ACCESS_TOKEN": "token"}, clear=True):
|
|
67
|
+
self.assertEqual(agy_usage.get_access_token(), "token")
|
|
68
|
+
|
|
69
|
+
def test_access_token_reads_nested_antigravity_token_file(self):
|
|
70
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
71
|
+
token_file = Path(tmp) / "antigravity-oauth-token"
|
|
72
|
+
_write_json(
|
|
73
|
+
token_file,
|
|
74
|
+
{
|
|
75
|
+
"auth_method": "consumer",
|
|
76
|
+
"token": {
|
|
77
|
+
"access_token": "nested-token",
|
|
78
|
+
"token_type": "Bearer",
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
with (
|
|
84
|
+
mock.patch.object(agy_usage, "TOKEN_FILE", token_file),
|
|
85
|
+
mock.patch.dict(os.environ, {}, clear=True),
|
|
86
|
+
):
|
|
87
|
+
self.assertEqual(agy_usage.get_access_token(), "nested-token")
|
|
88
|
+
|
|
89
|
+
def test_build_usage_json_includes_history_when_quota_fails(self):
|
|
90
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
91
|
+
tmp_path = Path(tmp)
|
|
92
|
+
history = tmp_path / "history.jsonl"
|
|
93
|
+
settings = tmp_path / "settings.json"
|
|
94
|
+
_write_json(settings, {"model": "Gemini Test"})
|
|
95
|
+
history.write_text(json.dumps({"display": "/usage", "timestamp": 1_000}) + "\n")
|
|
96
|
+
|
|
97
|
+
with (
|
|
98
|
+
mock.patch.object(agy_usage, "HISTORY_FILE", history),
|
|
99
|
+
mock.patch.object(agy_usage, "SETTINGS_FILE", settings),
|
|
100
|
+
mock.patch.object(agy_usage, "fetch_quota", side_effect=RuntimeError("no quota")),
|
|
101
|
+
):
|
|
102
|
+
usage = agy_usage.build_usage_json(tmp_path)
|
|
103
|
+
|
|
104
|
+
self.assertEqual(usage["model"], "Gemini Test")
|
|
105
|
+
self.assertIn("history", usage["source"])
|
|
106
|
+
self.assertEqual(usage["quota_error"], "no quota")
|
|
107
|
+
|
|
108
|
+
def test_force_refresh_bypasses_cache(self):
|
|
109
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
110
|
+
tmp_path = Path(tmp)
|
|
111
|
+
project_root = tmp_path / "project"
|
|
112
|
+
project_root.mkdir()
|
|
113
|
+
usage_file = tmp_path / "usage-limits.json"
|
|
114
|
+
cached = {
|
|
115
|
+
"project_root": str(project_root.resolve()),
|
|
116
|
+
"source": ["quota_api"],
|
|
117
|
+
"updated_at": datetime.now(UTC).isoformat(),
|
|
118
|
+
}
|
|
119
|
+
fresh = {
|
|
120
|
+
"project_root": str(project_root.resolve()),
|
|
121
|
+
"source": ["quota_api"],
|
|
122
|
+
"updated_at": (datetime.now(UTC) + timedelta(seconds=1)).isoformat(),
|
|
123
|
+
}
|
|
124
|
+
usage_file.write_text(json.dumps(cached) + "\n")
|
|
125
|
+
|
|
126
|
+
with (
|
|
127
|
+
mock.patch.object(agy_usage, "DEFAULT_USAGE_FILE", usage_file),
|
|
128
|
+
mock.patch.dict(os.environ, {}, clear=True),
|
|
129
|
+
mock.patch.object(agy_usage, "build_usage_json", return_value=fresh) as build_mock,
|
|
130
|
+
):
|
|
131
|
+
result = agy_usage._get_cached_usage(project_root=project_root)
|
|
132
|
+
self.assertEqual(result, cached)
|
|
133
|
+
build_mock.assert_not_called()
|
|
134
|
+
|
|
135
|
+
result = agy_usage._get_cached_usage(project_root=project_root, force_refresh=True)
|
|
136
|
+
|
|
137
|
+
self.assertEqual(result, fresh)
|
|
138
|
+
self.assertEqual(json.loads(usage_file.read_text()), fresh)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
if __name__ == "__main__":
|
|
142
|
+
unittest.main()
|
agy_usage-0.1.0/uv.lock
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
version = 1
|
|
2
|
+
revision = 3
|
|
3
|
+
requires-python = ">=3.12"
|
|
4
|
+
|
|
5
|
+
[options]
|
|
6
|
+
exclude-newer = "2026-06-23T03:29:12.769788954Z"
|
|
7
|
+
exclude-newer-span = "P7D"
|
|
8
|
+
|
|
9
|
+
[options.exclude-newer-package]
|
|
10
|
+
clanker-analytics = false
|
|
11
|
+
ghp = false
|
|
12
|
+
search-claude-history = false
|
|
13
|
+
gemini-cli-usage = false
|
|
14
|
+
ccusage = false
|
|
15
|
+
codex-cli-usage = false
|
|
16
|
+
|
|
17
|
+
[[package]]
|
|
18
|
+
name = "agy-usage"
|
|
19
|
+
version = "0.1.0"
|
|
20
|
+
source = { editable = "." }
|