usage-cli 0.29.32__py3-none-any.whl
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.
- adapters/__init__.py +5 -0
- adapters/agy.py +68 -0
- adapters/claude.py +215 -0
- adapters/codex.py +209 -0
- adapters/rate_limits.py +76 -0
- adapters/registry.py +17 -0
- adapters/types.py +139 -0
- agy_disk_cache.py +135 -0
- agy_loader.py +416 -0
- agy_quota_probe.py +748 -0
- agy_window_keeper.py +185 -0
- analyzer/__init__.py +5 -0
- analyzer/aggregator.py +139 -0
- analyzer/blocks.py +80 -0
- analyzer/diagnoser.py +638 -0
- analyzer/insights.py +277 -0
- analyzer/persona_loader.py +199 -0
- analyzer/reporter.py +989 -0
- analyzer/subscription.py +108 -0
- burn_rate.py +75 -0
- cache_quarantine.py +50 -0
- codex_disk_cache.py +227 -0
- codex_events.py +136 -0
- codex_fork_replay.py +111 -0
- codex_loader.py +1426 -0
- codex_paths.py +20 -0
- critter_frames.py +26 -0
- discussion_bridge.py +1196 -0
- discussion_cli.py +844 -0
- discussion_session.py +622 -0
- discussion_usage.py +13 -0
- discussion_window.py +955 -0
- disk_cache_common.py +132 -0
- disk_cache_lifecycle.py +39 -0
- doctor.py +452 -0
- fsevents_watch.py +207 -0
- history_disk_cache.py +110 -0
- history_loader.py +416 -0
- i18n.py +88 -0
- jsonl_limits.py +17 -0
- jsonl_utils.py +40 -0
- login_item.py +154 -0
- main.py +387 -0
- menubar.py +1201 -0
- menubar_actions.py +204 -0
- menubar_agy.py +193 -0
- menubar_chrome.py +156 -0
- menubar_menu.py +169 -0
- menubar_notify.py +102 -0
- menubar_popover.py +233 -0
- menubar_prefs.py +118 -0
- menubar_refresh.py +285 -0
- menubar_state.py +1200 -0
- menubar_title.py +157 -0
- menubar_update.py +123 -0
- panel_window.py +78 -0
- panel_window_state.py +159 -0
- panels/__init__.py +186 -0
- panels/base.py +83 -0
- panels/dynamic_height.py +140 -0
- panels/payload.py +178 -0
- panels/web_panel.py +513 -0
- panels/window_drag.py +56 -0
- prefs.py +44 -0
- pricing.py +452 -0
- project_resolver.py +112 -0
- service_status.py +383 -0
- session_hooks.py +1154 -0
- setup_app.py +171 -0
- setup_hook.py +1011 -0
- statusline_settings.py +160 -0
- talent_market_bridge.py +243 -0
- time_utils.py +24 -0
- tui.py +288 -0
- tui_sprite.py +206 -0
- ui/__init__.py +5 -0
- ui/html_report.py +923 -0
- ui/report_scripts.py +251 -0
- ui/report_styles.py +370 -0
- ui/tables.py +888 -0
- update_checker.py +156 -0
- update_gate.py +66 -0
- update_release_notes.py +49 -0
- usage_cli-0.29.32.data/data/share/usage/i18n.json +2427 -0
- usage_cli-0.29.32.dist-info/METADATA +223 -0
- usage_cli-0.29.32.dist-info/RECORD +109 -0
- usage_cli-0.29.32.dist-info/WHEEL +5 -0
- usage_cli-0.29.32.dist-info/entry_points.txt +3 -0
- usage_cli-0.29.32.dist-info/licenses/LICENSE +663 -0
- usage_cli-0.29.32.dist-info/top_level.txt +80 -0
- usage_cli.py +827 -0
- usage_client.py +487 -0
- usage_diagnosis_snapshot.py +143 -0
- usage_dir_sweeper.py +100 -0
- usage_lang.py +79 -0
- usage_logging.py +75 -0
- usage_notifications.py +96 -0
- usage_rate.py +97 -0
- usage_session_resume.py +913 -0
- usage_statusline.py +810 -0
- usage_statusline_agy.py +397 -0
- usage_statusline_forwarder.py +88 -0
- usage_terse_mode.py +223 -0
- usage_terse_reminder.py +151 -0
- win_login_item.py +53 -0
- window_keeper.py +264 -0
- windows_watch.py +443 -0
- wintray.py +2014 -0
- wintray_menu.py +136 -0
adapters/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
# Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
|
|
3
|
+
#
|
|
4
|
+
# Part of "usage". Free software licensed under the GNU Affero General Public
|
|
5
|
+
# License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
|
adapters/agy.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
# Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
|
|
3
|
+
#
|
|
4
|
+
# Part of "usage". Free software licensed under the GNU Affero General Public
|
|
5
|
+
# License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import agy_loader as shared_agy_loader
|
|
10
|
+
|
|
11
|
+
from i18n import t
|
|
12
|
+
|
|
13
|
+
from .types import AgentInfo, UsageEntry
|
|
14
|
+
|
|
15
|
+
# Source: tokscale's aliases.rs. Keep unknown Antigravity model IDs unchanged
|
|
16
|
+
# so pricing's existing fallback can handle them.
|
|
17
|
+
_MODEL_ALIASES = {
|
|
18
|
+
"gemini-3-flash-a": "gemini-3-flash-preview",
|
|
19
|
+
"gemini-3-flash-c": "gemini-3-flash-preview",
|
|
20
|
+
"gemini-3-flash": "gemini-3-flash-preview",
|
|
21
|
+
"gemini-3.1-pro-high": "gemini-3.1-pro",
|
|
22
|
+
"gemini-3.1-pro-low": "gemini-3.1-pro",
|
|
23
|
+
"gemini-3-pro-high": "gemini-3-pro",
|
|
24
|
+
"gemini-3-pro-low": "gemini-3-pro",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
_PROJECT_NAME = "Antigravity"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def detect() -> AgentInfo | None:
|
|
31
|
+
sessions_dir = shared_agy_loader.AGY_SESSIONS_DIR
|
|
32
|
+
if sessions_dir.is_dir():
|
|
33
|
+
return AgentInfo(
|
|
34
|
+
id="antigravity",
|
|
35
|
+
name=t("agy_name"),
|
|
36
|
+
data_dir=str(sessions_dir),
|
|
37
|
+
installed=True,
|
|
38
|
+
)
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def load_entries(hours_back: int = 0) -> list[UsageEntry]:
|
|
43
|
+
result = shared_agy_loader.load_entries_with_stats(hours_back)
|
|
44
|
+
return [_to_usage_entry(entry) for entry in result.entries]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _to_usage_entry(entry: shared_agy_loader.AgyUsageEntry) -> UsageEntry:
|
|
48
|
+
return UsageEntry(
|
|
49
|
+
timestamp=entry.timestamp,
|
|
50
|
+
session_id=entry.session_id,
|
|
51
|
+
message_id=entry.dedup_key,
|
|
52
|
+
request_id="",
|
|
53
|
+
model=_normalize_model(entry.model),
|
|
54
|
+
input_tokens=entry.input_tokens,
|
|
55
|
+
# Match Codex's existing convention: only its reported output tokens
|
|
56
|
+
# are attributed to UsageEntry.output_tokens; separate reasoning data
|
|
57
|
+
# is not added here.
|
|
58
|
+
output_tokens=entry.output_tokens,
|
|
59
|
+
cache_creation_tokens=0,
|
|
60
|
+
cache_read_tokens=entry.cache_read_tokens,
|
|
61
|
+
cost_usd=None,
|
|
62
|
+
project=_PROJECT_NAME,
|
|
63
|
+
agent_id="antigravity",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _normalize_model(model: str) -> str:
|
|
68
|
+
return _MODEL_ALIASES.get(model.lower(), model)
|
adapters/claude.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
# Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
|
|
3
|
+
#
|
|
4
|
+
# Part of "usage". Free software licensed under the GNU Affero General Public
|
|
5
|
+
# License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
|
|
6
|
+
|
|
7
|
+
import math
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
from collections import OrderedDict
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from jsonl_utils import iter_jsonl_dicts
|
|
16
|
+
|
|
17
|
+
from .types import AgentInfo, UsageEntry
|
|
18
|
+
|
|
19
|
+
CLAUDE_DIRS = [
|
|
20
|
+
os.path.expanduser("~/.claude/projects"),
|
|
21
|
+
os.path.expanduser("~/.config/claude/projects"),
|
|
22
|
+
]
|
|
23
|
+
_FILE_CACHE_MAXSIZE = 512
|
|
24
|
+
_file_cache: OrderedDict[Path, tuple[float, int, list[UsageEntry]]] = OrderedDict()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def detect() -> AgentInfo | None:
|
|
28
|
+
for d in get_claude_dirs():
|
|
29
|
+
if Path(d).is_dir():
|
|
30
|
+
return AgentInfo(
|
|
31
|
+
id="claude-code",
|
|
32
|
+
name="Claude Code",
|
|
33
|
+
data_dir=d,
|
|
34
|
+
installed=True,
|
|
35
|
+
)
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_entries(hours_back: int = 0) -> list[UsageEntry]:
|
|
40
|
+
entries: list[UsageEntry] = []
|
|
41
|
+
seen: set[str] = set()
|
|
42
|
+
cutoff = None
|
|
43
|
+
if hours_back > 0:
|
|
44
|
+
from datetime import timedelta
|
|
45
|
+
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours_back)
|
|
46
|
+
|
|
47
|
+
for base_dir in get_claude_dirs():
|
|
48
|
+
base = Path(base_dir)
|
|
49
|
+
if not base.is_dir():
|
|
50
|
+
continue
|
|
51
|
+
for jsonl_path in base.rglob("*.jsonl"):
|
|
52
|
+
fallback_project = extract_project_from_dir(jsonl_path, base)
|
|
53
|
+
parse_jsonl(jsonl_path, fallback_project, entries, seen, cutoff)
|
|
54
|
+
|
|
55
|
+
entries.sort(key=lambda e: e.timestamp)
|
|
56
|
+
return entries
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def get_claude_dirs() -> list[str]:
|
|
60
|
+
dirs = list(CLAUDE_DIRS)
|
|
61
|
+
env = os.environ.get("CLAUDE_CONFIG_DIR")
|
|
62
|
+
if env:
|
|
63
|
+
for p in env.split(","):
|
|
64
|
+
projects_dir = os.path.join(p.strip(), "projects")
|
|
65
|
+
if projects_dir not in dirs:
|
|
66
|
+
dirs.insert(0, projects_dir)
|
|
67
|
+
return dirs
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def project_from_cwd(cwd: str) -> str:
|
|
71
|
+
home = os.path.expanduser("~")
|
|
72
|
+
rel = cwd[len(home):] if cwd.startswith(home) else cwd
|
|
73
|
+
# Windows sessions read transcripts whose cwd may use either separator
|
|
74
|
+
# (e.g. POSIX-style paths). POSIX filenames may legitimately contain
|
|
75
|
+
# backslashes, so only normalize them on Windows.
|
|
76
|
+
if sys.platform == "win32":
|
|
77
|
+
rel = rel.replace("\\", "/")
|
|
78
|
+
sep = "/"
|
|
79
|
+
else:
|
|
80
|
+
sep = os.sep
|
|
81
|
+
rel = rel.strip(sep)
|
|
82
|
+
parts = rel.split(sep)
|
|
83
|
+
return parts[-1] if parts and parts[-1] else rel or "unknown"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def extract_project_from_dir(jsonl_path: Path, base: Path) -> str:
|
|
87
|
+
rel = jsonl_path.relative_to(base)
|
|
88
|
+
project_dir = str(rel.parts[0]) if rel.parts else "unknown"
|
|
89
|
+
decoded = project_dir.replace("-", os.sep).strip(os.sep)
|
|
90
|
+
home = os.path.expanduser("~").strip(os.sep)
|
|
91
|
+
if decoded.startswith(home):
|
|
92
|
+
decoded = decoded[len(home):].strip(os.sep)
|
|
93
|
+
parts = decoded.split(os.sep)
|
|
94
|
+
return parts[-1] if parts else "unknown"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def parse_jsonl(
|
|
98
|
+
path: Path,
|
|
99
|
+
project: str,
|
|
100
|
+
entries: list[UsageEntry],
|
|
101
|
+
seen: set[str],
|
|
102
|
+
cutoff: datetime | None,
|
|
103
|
+
) -> None:
|
|
104
|
+
try:
|
|
105
|
+
st = path.stat()
|
|
106
|
+
except (OSError, PermissionError) as exc:
|
|
107
|
+
_debug_file_error("failed to stat Claude log", path, exc)
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
cached = _file_cache.get(path)
|
|
111
|
+
parsed_entries: list[UsageEntry]
|
|
112
|
+
if cached is not None and cached[0] == st.st_mtime and cached[1] == st.st_size:
|
|
113
|
+
_file_cache.move_to_end(path)
|
|
114
|
+
parsed_entries = cached[2]
|
|
115
|
+
else:
|
|
116
|
+
parsed_entries = []
|
|
117
|
+
try:
|
|
118
|
+
for data in iter_jsonl_dicts(path):
|
|
119
|
+
if data.get("type") != "assistant":
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
entry = _parse_assistant_entry(data, project)
|
|
123
|
+
if entry is None:
|
|
124
|
+
continue
|
|
125
|
+
parsed_entries.append(entry)
|
|
126
|
+
except (OSError, PermissionError, UnicodeDecodeError) as exc:
|
|
127
|
+
_debug_file_error("failed to read Claude log", path, exc)
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
if path not in _file_cache and len(_file_cache) >= _FILE_CACHE_MAXSIZE:
|
|
131
|
+
_file_cache.popitem(last=False)
|
|
132
|
+
_file_cache[path] = (st.st_mtime, st.st_size, parsed_entries)
|
|
133
|
+
|
|
134
|
+
for entry in parsed_entries:
|
|
135
|
+
if cutoff and entry.timestamp < cutoff:
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
if entry.dedup_key in seen:
|
|
139
|
+
continue
|
|
140
|
+
seen.add(entry.dedup_key)
|
|
141
|
+
|
|
142
|
+
entries.append(entry)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _parse_assistant_entry(data: dict[str, Any], project: str) -> UsageEntry | None:
|
|
146
|
+
message = data.get("message")
|
|
147
|
+
if not message or not isinstance(message, dict):
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
usage = message.get("usage")
|
|
151
|
+
if not usage or not isinstance(usage, dict):
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
input_tokens = _as_int(usage.get("input_tokens"))
|
|
155
|
+
output_tokens = _as_int(usage.get("output_tokens"))
|
|
156
|
+
cache_creation = _as_int(usage.get("cache_creation_input_tokens"))
|
|
157
|
+
cache_read = _as_int(usage.get("cache_read_input_tokens"))
|
|
158
|
+
|
|
159
|
+
if input_tokens == 0 and output_tokens == 0 and cache_creation == 0 and cache_read == 0:
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
timestamp_str = data.get("timestamp", "")
|
|
163
|
+
try:
|
|
164
|
+
ts = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
|
|
165
|
+
except (ValueError, AttributeError):
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
message_id = message.get("id", "")
|
|
169
|
+
request_id = data.get("requestId") or ""
|
|
170
|
+
model = message.get("model", "unknown")
|
|
171
|
+
session_id = data.get("sessionId", "")
|
|
172
|
+
cost_usd = _as_optional_float(data.get("costUSD"))
|
|
173
|
+
|
|
174
|
+
cwd = data.get("cwd", "")
|
|
175
|
+
if cwd:
|
|
176
|
+
project = project_from_cwd(cwd)
|
|
177
|
+
|
|
178
|
+
return UsageEntry(
|
|
179
|
+
timestamp=ts,
|
|
180
|
+
session_id=session_id,
|
|
181
|
+
message_id=message_id,
|
|
182
|
+
request_id=request_id,
|
|
183
|
+
model=model,
|
|
184
|
+
input_tokens=input_tokens,
|
|
185
|
+
output_tokens=output_tokens,
|
|
186
|
+
cache_creation_tokens=cache_creation,
|
|
187
|
+
cache_read_tokens=cache_read,
|
|
188
|
+
cost_usd=cost_usd,
|
|
189
|
+
project=project,
|
|
190
|
+
agent_id="claude-code",
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _debug_file_error(action: str, path: Path, exc: Exception) -> None:
|
|
195
|
+
if os.environ.get("USAGE_DEBUG") == "1":
|
|
196
|
+
print(f"{action} {path}: {exc}", file=sys.stderr)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _as_optional_float(value: Any) -> float | None:
|
|
200
|
+
if isinstance(value, bool):
|
|
201
|
+
return None
|
|
202
|
+
try:
|
|
203
|
+
number = float(value)
|
|
204
|
+
except (TypeError, ValueError):
|
|
205
|
+
return None
|
|
206
|
+
if not math.isfinite(number):
|
|
207
|
+
return None
|
|
208
|
+
return number
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _as_int(value: Any) -> int:
|
|
212
|
+
number = _as_optional_float(value)
|
|
213
|
+
if number is None:
|
|
214
|
+
return 0
|
|
215
|
+
return int(number)
|
adapters/codex.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
# Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
|
|
3
|
+
#
|
|
4
|
+
# Part of "usage". Free software licensed under the GNU Affero General Public
|
|
5
|
+
# License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
import os
|
|
10
|
+
import sqlite3
|
|
11
|
+
import sys
|
|
12
|
+
from collections import OrderedDict, defaultdict
|
|
13
|
+
from contextlib import closing
|
|
14
|
+
from datetime import datetime, timedelta, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import codex_loader as shared_codex_loader
|
|
19
|
+
from codex_paths import codex_home
|
|
20
|
+
|
|
21
|
+
from .types import AgentInfo, RateLimits, UsageEntry
|
|
22
|
+
|
|
23
|
+
CODEX_DIR = str(codex_home())
|
|
24
|
+
SESSIONS_DIR = os.path.join(CODEX_DIR, "sessions")
|
|
25
|
+
STATE_DB = os.path.join(CODEX_DIR, "state_5.sqlite")
|
|
26
|
+
_FILE_CACHE_MAXSIZE = 512
|
|
27
|
+
_file_cache: OrderedDict[Path, tuple[float, int, list[dict[str, Any]]]] = OrderedDict()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _readonly_sqlite_uri(path: str | Path) -> str:
|
|
31
|
+
return f"{Path(path).resolve().as_uri()}?mode=ro"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def detect() -> AgentInfo | None:
|
|
35
|
+
if Path(SESSIONS_DIR).is_dir():
|
|
36
|
+
return AgentInfo(
|
|
37
|
+
id="codex",
|
|
38
|
+
name="Codex",
|
|
39
|
+
data_dir=SESSIONS_DIR,
|
|
40
|
+
installed=True,
|
|
41
|
+
)
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load_entries(hours_back: int = 0) -> list[UsageEntry]:
|
|
46
|
+
cutoff = None
|
|
47
|
+
if hours_back > 0:
|
|
48
|
+
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours_back)
|
|
49
|
+
|
|
50
|
+
models = _load_thread_models()
|
|
51
|
+
sessions_path = Path(SESSIONS_DIR)
|
|
52
|
+
delta_entries = shared_codex_loader._load_jsonl_entries(sessions_path, models, cutoff)
|
|
53
|
+
by_session: dict[str, list[Any]] = defaultdict(list)
|
|
54
|
+
for entry in delta_entries:
|
|
55
|
+
by_session[entry.session_id].append(entry)
|
|
56
|
+
|
|
57
|
+
entries = [_aggregate_session(session_entries) for session_entries in by_session.values()]
|
|
58
|
+
entries.sort(key=lambda e: e.timestamp)
|
|
59
|
+
return entries
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _aggregate_session(session_entries: list[Any]) -> UsageEntry:
|
|
63
|
+
first = session_entries[0]
|
|
64
|
+
primary = max(session_entries, key=lambda entry: entry.total_tokens)
|
|
65
|
+
return UsageEntry(
|
|
66
|
+
timestamp=first.timestamp,
|
|
67
|
+
session_id=first.session_id,
|
|
68
|
+
message_id=first.session_id,
|
|
69
|
+
request_id="",
|
|
70
|
+
model=primary.model,
|
|
71
|
+
input_tokens=sum(entry.input_tokens for entry in session_entries),
|
|
72
|
+
output_tokens=sum(entry.output_tokens for entry in session_entries),
|
|
73
|
+
cache_creation_tokens=sum(entry.cache_creation_tokens for entry in session_entries),
|
|
74
|
+
cache_read_tokens=sum(entry.cache_read_tokens for entry in session_entries),
|
|
75
|
+
cost_usd=None,
|
|
76
|
+
project=first.project,
|
|
77
|
+
agent_id="codex",
|
|
78
|
+
message_count=len(session_entries),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _load_thread_models() -> dict[str, str]:
|
|
83
|
+
if not os.path.exists(STATE_DB):
|
|
84
|
+
return {}
|
|
85
|
+
try:
|
|
86
|
+
with closing(sqlite3.connect(_readonly_sqlite_uri(STATE_DB), uri=True)) as conn:
|
|
87
|
+
rows = conn.execute("SELECT id, model FROM threads WHERE model IS NOT NULL").fetchall()
|
|
88
|
+
return {row[0]: row[1] for row in rows}
|
|
89
|
+
except (sqlite3.Error, OSError) as exc:
|
|
90
|
+
_debug_file_error("failed to load Codex thread models from", Path(STATE_DB), exc)
|
|
91
|
+
return {}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _extract_rate_limits(path: Path, models: dict[str, str]) -> RateLimits | None:
|
|
95
|
+
session_id = ""
|
|
96
|
+
session_model = "unknown"
|
|
97
|
+
last_rl = None
|
|
98
|
+
try:
|
|
99
|
+
rows = _load_jsonl_rows(path)
|
|
100
|
+
except (OSError, PermissionError, UnicodeDecodeError) as exc:
|
|
101
|
+
_debug_file_error("failed to read Codex session log", path, exc)
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
for data in rows:
|
|
105
|
+
if data.get("type") == "session_meta":
|
|
106
|
+
payload = data.get("payload", {})
|
|
107
|
+
session_id = payload.get("id", "")
|
|
108
|
+
session_model = _session_model(payload, session_model)
|
|
109
|
+
if data.get("type") == "turn_context":
|
|
110
|
+
session_model = _session_model(data.get("payload"), session_model)
|
|
111
|
+
if data.get("type") != "event_msg":
|
|
112
|
+
continue
|
|
113
|
+
payload = data.get("payload", {})
|
|
114
|
+
if payload.get("type") != "token_count":
|
|
115
|
+
continue
|
|
116
|
+
rl = payload.get("rate_limits")
|
|
117
|
+
if rl:
|
|
118
|
+
last_rl = (rl, data.get("timestamp", ""), session_id)
|
|
119
|
+
|
|
120
|
+
if not last_rl:
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
rl, ts, sid = last_rl
|
|
124
|
+
primary = rl.get("primary") or {}
|
|
125
|
+
secondary = rl.get("secondary") or {}
|
|
126
|
+
|
|
127
|
+
five_pct = _as_optional_float(primary.get("used_percent"))
|
|
128
|
+
five_reset = _as_optional_int(primary.get("resets_at"))
|
|
129
|
+
seven_pct = _as_optional_float(secondary.get("used_percent"))
|
|
130
|
+
seven_reset = _as_optional_int(secondary.get("resets_at"))
|
|
131
|
+
|
|
132
|
+
now_ts = datetime.now(timezone.utc).timestamp()
|
|
133
|
+
if five_reset is not None and five_reset < now_ts:
|
|
134
|
+
five_pct = 0.0
|
|
135
|
+
five_reset = None
|
|
136
|
+
if seven_reset is not None and seven_reset < now_ts:
|
|
137
|
+
seven_pct = 0.0
|
|
138
|
+
seven_reset = None
|
|
139
|
+
|
|
140
|
+
if five_pct is None and seven_pct is None:
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
model_name = models.get(sid, "")
|
|
144
|
+
|
|
145
|
+
return RateLimits(
|
|
146
|
+
five_hour_pct=five_pct,
|
|
147
|
+
five_hour_resets_at=five_reset,
|
|
148
|
+
seven_day_pct=seven_pct,
|
|
149
|
+
seven_day_resets_at=seven_reset,
|
|
150
|
+
model=model_name or session_model,
|
|
151
|
+
updated_at=ts,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _load_jsonl_rows(path: Path) -> list[dict[str, Any]]:
|
|
156
|
+
st = path.stat()
|
|
157
|
+
cached = _file_cache.get(path)
|
|
158
|
+
if cached is not None and cached[0] == st.st_mtime and cached[1] == st.st_size:
|
|
159
|
+
_file_cache.move_to_end(path)
|
|
160
|
+
return cached[2]
|
|
161
|
+
|
|
162
|
+
rows: list[dict[str, Any]] = []
|
|
163
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
164
|
+
for line in f:
|
|
165
|
+
line = line.strip()
|
|
166
|
+
if not line:
|
|
167
|
+
continue
|
|
168
|
+
try:
|
|
169
|
+
data = json.loads(line)
|
|
170
|
+
except json.JSONDecodeError:
|
|
171
|
+
continue
|
|
172
|
+
if isinstance(data, dict):
|
|
173
|
+
rows.append(data)
|
|
174
|
+
|
|
175
|
+
if path not in _file_cache and len(_file_cache) >= _FILE_CACHE_MAXSIZE:
|
|
176
|
+
_file_cache.popitem(last=False)
|
|
177
|
+
_file_cache[path] = (st.st_mtime, st.st_size, rows)
|
|
178
|
+
return rows
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _as_optional_float(value: Any) -> float | None:
|
|
182
|
+
if isinstance(value, bool):
|
|
183
|
+
return None
|
|
184
|
+
try:
|
|
185
|
+
number = float(value)
|
|
186
|
+
except (TypeError, ValueError):
|
|
187
|
+
return None
|
|
188
|
+
if not math.isfinite(number):
|
|
189
|
+
return None
|
|
190
|
+
return number
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _session_model(payload: Any, fallback: str) -> str:
|
|
194
|
+
model = payload.get("model") if isinstance(payload, dict) else ""
|
|
195
|
+
if not isinstance(model, str):
|
|
196
|
+
model = ""
|
|
197
|
+
return model or fallback
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _as_optional_int(value: Any) -> int | None:
|
|
201
|
+
number = _as_optional_float(value)
|
|
202
|
+
if number is None:
|
|
203
|
+
return None
|
|
204
|
+
return int(number)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _debug_file_error(action: str, path: Path, exc: Exception) -> None:
|
|
208
|
+
if os.environ.get("USAGE_DEBUG") == "1":
|
|
209
|
+
print(f"{action} {path}: {exc}", file=sys.stderr)
|
adapters/rate_limits.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
# Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
|
|
3
|
+
#
|
|
4
|
+
# Part of "usage". Free software licensed under the GNU Affero General Public
|
|
5
|
+
# License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
import os
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .types import RateLimits
|
|
14
|
+
|
|
15
|
+
STATUS_FILE = os.path.expanduser("~/.claude/usage-status.json")
|
|
16
|
+
LEGACY_STATUS_FILE = os.path.expanduser("~/.claude/usag-status.json")
|
|
17
|
+
TT_STATUS_FILE = os.path.expanduser("~/.claude/tt-status.json")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _as_finite_float(value: Any) -> float | None:
|
|
21
|
+
if isinstance(value, bool):
|
|
22
|
+
return None
|
|
23
|
+
try:
|
|
24
|
+
numeric = float(value)
|
|
25
|
+
except (TypeError, ValueError):
|
|
26
|
+
return None
|
|
27
|
+
return numeric if math.isfinite(numeric) else None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _read_status() -> dict[str, Any] | None:
|
|
31
|
+
for path in (STATUS_FILE, LEGACY_STATUS_FILE, TT_STATUS_FILE):
|
|
32
|
+
if not os.path.exists(path):
|
|
33
|
+
continue
|
|
34
|
+
try:
|
|
35
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
36
|
+
data: dict[str, Any] = json.load(f)
|
|
37
|
+
return data
|
|
38
|
+
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
|
|
39
|
+
continue
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load_rate_limits() -> RateLimits | None:
|
|
44
|
+
data = _read_status()
|
|
45
|
+
if data is None:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
rl = data.get("rate_limits") or {}
|
|
49
|
+
five = rl.get("five_hour") or {}
|
|
50
|
+
seven = rl.get("seven_day") or {}
|
|
51
|
+
|
|
52
|
+
now_ts = datetime.now(timezone.utc).timestamp()
|
|
53
|
+
five_pct = _as_finite_float(five.get("used_percentage"))
|
|
54
|
+
five_reset = _as_finite_float(five.get("resets_at"))
|
|
55
|
+
if five_reset and five_reset < now_ts:
|
|
56
|
+
five_pct = 0.0
|
|
57
|
+
|
|
58
|
+
seven_pct = _as_finite_float(seven.get("used_percentage"))
|
|
59
|
+
seven_reset = _as_finite_float(seven.get("resets_at"))
|
|
60
|
+
if seven_reset and seven_reset < now_ts:
|
|
61
|
+
seven_pct = 0.0
|
|
62
|
+
|
|
63
|
+
model_info = data.get("model") or {}
|
|
64
|
+
model_name = model_info.get("display_name") or model_info.get("id") or ""
|
|
65
|
+
|
|
66
|
+
if five_pct is None and seven_pct is None and not model_name:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
return RateLimits(
|
|
70
|
+
five_hour_pct=five_pct,
|
|
71
|
+
five_hour_resets_at=int(five_reset) if five_reset is not None else None,
|
|
72
|
+
seven_day_pct=seven_pct,
|
|
73
|
+
seven_day_resets_at=int(seven_reset) if seven_reset is not None else None,
|
|
74
|
+
model=model_name,
|
|
75
|
+
updated_at=data.get("_received_at", ""),
|
|
76
|
+
)
|
adapters/registry.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
# Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
|
|
3
|
+
#
|
|
4
|
+
# Part of "usage". Free software licensed under the GNU Affero General Public
|
|
5
|
+
# License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
|
|
6
|
+
|
|
7
|
+
from .types import AgentInfo
|
|
8
|
+
from . import agy, claude, codex
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def detect_agents() -> list[AgentInfo]:
|
|
12
|
+
agents: list[AgentInfo] = []
|
|
13
|
+
for detector in [claude.detect, codex.detect, agy.detect]:
|
|
14
|
+
info = detector()
|
|
15
|
+
if info:
|
|
16
|
+
agents.append(info)
|
|
17
|
+
return agents
|