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
analyzer/subscription.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
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
|
+
"""Read the locally-stored subscription plan + start date for each agent.
|
|
8
|
+
|
|
9
|
+
Everything here is read-only and stays on disk — we only pull the plan name and
|
|
10
|
+
the subscription start date out of the OAuth account files that Claude Code and
|
|
11
|
+
Codex already keep. Tokens, emails and account IDs are never read or returned.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import base64
|
|
16
|
+
import binascii
|
|
17
|
+
import json
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from codex_paths import codex_home
|
|
22
|
+
|
|
23
|
+
CLAUDE_CONFIG = Path.home() / ".claude.json"
|
|
24
|
+
CODEX_AUTH = codex_home() / "auth.json"
|
|
25
|
+
|
|
26
|
+
_CLAUDE_PLAN_NAMES = {
|
|
27
|
+
"claude_pro": "Claude Pro",
|
|
28
|
+
"claude_max": "Claude Max",
|
|
29
|
+
"claude_team": "Claude Team",
|
|
30
|
+
"claude_enterprise": "Claude Enterprise",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _decode_jwt_payload(token: str) -> dict[str, Any]:
|
|
35
|
+
if not isinstance(token, str):
|
|
36
|
+
return {}
|
|
37
|
+
parts = token.split(".")
|
|
38
|
+
if len(parts) < 2:
|
|
39
|
+
return {}
|
|
40
|
+
payload = parts[1]
|
|
41
|
+
payload += "=" * (-len(payload) % 4)
|
|
42
|
+
try:
|
|
43
|
+
claims = json.loads(base64.urlsafe_b64decode(payload))
|
|
44
|
+
if not isinstance(claims, dict):
|
|
45
|
+
return {}
|
|
46
|
+
return claims
|
|
47
|
+
except (binascii.Error, ValueError):
|
|
48
|
+
return {}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _load_claude_subscription() -> dict[str, str | None] | None:
|
|
52
|
+
try:
|
|
53
|
+
data = json.loads(CLAUDE_CONFIG.read_text(encoding="utf-8"))
|
|
54
|
+
except (OSError, ValueError):
|
|
55
|
+
return None
|
|
56
|
+
if not isinstance(data, dict):
|
|
57
|
+
return None
|
|
58
|
+
account = data.get("oauthAccount")
|
|
59
|
+
if not isinstance(account, dict):
|
|
60
|
+
account = {}
|
|
61
|
+
org_type = account.get("organizationType")
|
|
62
|
+
since = account.get("subscriptionCreatedAt")
|
|
63
|
+
if not isinstance(org_type, str):
|
|
64
|
+
org_type = None
|
|
65
|
+
if not isinstance(since, str):
|
|
66
|
+
since = None
|
|
67
|
+
if not org_type and not since:
|
|
68
|
+
return None
|
|
69
|
+
plan = _CLAUDE_PLAN_NAMES.get(org_type or "")
|
|
70
|
+
if not plan:
|
|
71
|
+
plan = (org_type or "Claude").replace("claude_", "Claude ").replace("_", " ").title()
|
|
72
|
+
return {"agent": "Claude Code", "plan": plan, "since": since[:10] if since else None}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _load_codex_subscription() -> dict[str, str | None] | None:
|
|
76
|
+
try:
|
|
77
|
+
data = json.loads(CODEX_AUTH.read_text(encoding="utf-8"))
|
|
78
|
+
except (OSError, ValueError):
|
|
79
|
+
return None
|
|
80
|
+
if not isinstance(data, dict):
|
|
81
|
+
return None
|
|
82
|
+
tokens = data.get("tokens")
|
|
83
|
+
if not isinstance(tokens, dict):
|
|
84
|
+
tokens = {}
|
|
85
|
+
claims = _decode_jwt_payload(tokens.get("id_token", ""))
|
|
86
|
+
auth = claims.get("https://api.openai.com/auth")
|
|
87
|
+
if not isinstance(auth, dict):
|
|
88
|
+
auth = {}
|
|
89
|
+
plan_type = auth.get("chatgpt_plan_type")
|
|
90
|
+
since = auth.get("chatgpt_subscription_active_start")
|
|
91
|
+
if not isinstance(plan_type, str):
|
|
92
|
+
plan_type = None
|
|
93
|
+
if not isinstance(since, str):
|
|
94
|
+
since = None
|
|
95
|
+
if not plan_type and not since:
|
|
96
|
+
return None
|
|
97
|
+
plan = f"ChatGPT {plan_type.title()}" if plan_type else "ChatGPT"
|
|
98
|
+
return {"agent": "Codex", "plan": plan, "since": since[:10] if since else None}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def load_subscriptions() -> list[dict[str, str | None]]:
|
|
102
|
+
"""Return ``[{agent, plan, since}]`` for whichever agents we can detect."""
|
|
103
|
+
subs: list[dict[str, str | None]] = []
|
|
104
|
+
for loader in (_load_claude_subscription, _load_codex_subscription):
|
|
105
|
+
sub = loader()
|
|
106
|
+
if sub:
|
|
107
|
+
subs.append(sub)
|
|
108
|
+
return subs
|
burn_rate.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
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
|
+
from collections import deque
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
ROLLING_WINDOW_SECONDS = 60 * 60
|
|
13
|
+
FORECAST_WINDOW_SECONDS = 10 * 60
|
|
14
|
+
RESET_DROP_PERCENT = 5.0
|
|
15
|
+
MIN_FORECAST_SAMPLES = 5
|
|
16
|
+
MIN_FORECAST_SPAN_SECONDS = 5 * 60
|
|
17
|
+
WARNING_PERCENT_FLOOR = 50.0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(slots=True)
|
|
21
|
+
class BurnSample:
|
|
22
|
+
timestamp: float
|
|
23
|
+
percent: float
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class BurnRateTracker:
|
|
27
|
+
def __init__(self) -> None:
|
|
28
|
+
self._samples: deque[BurnSample] = deque()
|
|
29
|
+
|
|
30
|
+
def record(self, now: float, percent: float) -> None:
|
|
31
|
+
sample = BurnSample(timestamp=float(now), percent=float(percent))
|
|
32
|
+
previous = self._samples[-1] if self._samples else None
|
|
33
|
+
if previous is not None and (previous.percent - sample.percent) > RESET_DROP_PERCENT:
|
|
34
|
+
self._samples.clear()
|
|
35
|
+
self._samples.append(sample)
|
|
36
|
+
self._prune(now=sample.timestamp)
|
|
37
|
+
|
|
38
|
+
def forecast_seconds(
|
|
39
|
+
self,
|
|
40
|
+
window_seconds: float | None = None,
|
|
41
|
+
min_span_seconds: float | None = None,
|
|
42
|
+
) -> float | None:
|
|
43
|
+
if len(self._samples) < 2:
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
latest = self._samples[-1]
|
|
47
|
+
window = window_seconds if window_seconds is not None else FORECAST_WINDOW_SECONDS
|
|
48
|
+
cutoff = latest.timestamp - window
|
|
49
|
+
selected = [sample for sample in self._samples if sample.timestamp >= cutoff]
|
|
50
|
+
if len(selected) < MIN_FORECAST_SAMPLES:
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
first = selected[0]
|
|
54
|
+
elapsed = latest.timestamp - first.timestamp
|
|
55
|
+
span_threshold = (
|
|
56
|
+
min_span_seconds if min_span_seconds is not None else MIN_FORECAST_SPAN_SECONDS
|
|
57
|
+
)
|
|
58
|
+
if elapsed < span_threshold:
|
|
59
|
+
return None
|
|
60
|
+
if elapsed <= 0:
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
slope_per_second = (latest.percent - first.percent) / elapsed
|
|
64
|
+
if slope_per_second <= 0:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
remaining_percent = 100.0 - latest.percent
|
|
68
|
+
if remaining_percent <= 0:
|
|
69
|
+
return 0.0
|
|
70
|
+
return remaining_percent / slope_per_second
|
|
71
|
+
|
|
72
|
+
def _prune(self, now: float) -> None:
|
|
73
|
+
cutoff = now - ROLLING_WINDOW_SECONDS
|
|
74
|
+
while self._samples and self._samples[0].timestamp < cutoff:
|
|
75
|
+
self._samples.popleft()
|
cache_quarantine.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
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
|
+
"""Best-effort quarantine for corrupt disk cache files."""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import errno
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import shutil
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
QUARANTINE_DIR = Path.home() / ".usage" / "quarantine"
|
|
21
|
+
_MAX_FILE_SIZE = 5 * 1024 * 1024
|
|
22
|
+
_MAX_BACKUPS = 10
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def quarantine(path: Path, reason: str) -> None:
|
|
26
|
+
"""Best-effort move of a corrupt cache file into the quarantine directory."""
|
|
27
|
+
try:
|
|
28
|
+
if path.stat().st_size > _MAX_FILE_SIZE:
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
quarantine_dir = QUARANTINE_DIR
|
|
32
|
+
quarantine_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
33
|
+
timestamp_ms = time.time_ns() // 1_000_000
|
|
34
|
+
backup_path = quarantine_dir / f"{path.name}.{timestamp_ms}.bak"
|
|
35
|
+
try:
|
|
36
|
+
os.replace(path, backup_path)
|
|
37
|
+
except OSError as exc:
|
|
38
|
+
if exc.errno != errno.EXDEV:
|
|
39
|
+
raise
|
|
40
|
+
shutil.copy2(path, backup_path)
|
|
41
|
+
path.unlink()
|
|
42
|
+
|
|
43
|
+
backups = sorted(
|
|
44
|
+
quarantine_dir.glob("*.bak"), key=lambda candidate: candidate.stat().st_mtime
|
|
45
|
+
)
|
|
46
|
+
for backup in backups[:-_MAX_BACKUPS]:
|
|
47
|
+
backup.unlink()
|
|
48
|
+
except Exception as exc:
|
|
49
|
+
if os.environ.get("USAGE_DEBUG") == "1":
|
|
50
|
+
logger.warning("failed to quarantine cache file %s (%s): %s", path, reason, exc)
|
codex_disk_cache.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
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
|
+
"""Sharded disk persistence for codex_loader's parse caches."""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
from collections import OrderedDict
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from codex_events import _SessionFileInfo, _TokenUsage
|
|
18
|
+
from disk_cache_common import (
|
|
19
|
+
_SHARD_COUNT,
|
|
20
|
+
_cache_dir,
|
|
21
|
+
_deserialize_usage_entry,
|
|
22
|
+
_encoded_payload,
|
|
23
|
+
_load_payload,
|
|
24
|
+
_remove_legacy_cache,
|
|
25
|
+
_serialize_usage_entry,
|
|
26
|
+
_shard_index,
|
|
27
|
+
_shard_path,
|
|
28
|
+
_write_if_changed,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
__all__ = ["_shard_index", "_shard_path", "flush_caches", "seed_caches"]
|
|
34
|
+
|
|
35
|
+
_JsonlCache = OrderedDict[Path, Any]
|
|
36
|
+
_FileInfoCache = OrderedDict[Path, tuple[float, int, _SessionFileInfo]]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _serialize_token_usage(value: _TokenUsage | None) -> dict[str, int] | None:
|
|
40
|
+
if value is None:
|
|
41
|
+
return None
|
|
42
|
+
return {
|
|
43
|
+
"input_tokens": value.input_tokens,
|
|
44
|
+
"output_tokens": value.output_tokens,
|
|
45
|
+
"cache_read_tokens": value.cache_read_tokens,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _deserialize_token_usage(value: Any) -> _TokenUsage | None:
|
|
50
|
+
if not isinstance(value, dict):
|
|
51
|
+
return None
|
|
52
|
+
try:
|
|
53
|
+
return _TokenUsage(
|
|
54
|
+
input_tokens=int(value["input_tokens"]),
|
|
55
|
+
output_tokens=int(value["output_tokens"]),
|
|
56
|
+
cache_read_tokens=int(value["cache_read_tokens"]),
|
|
57
|
+
)
|
|
58
|
+
except (KeyError, TypeError, ValueError):
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _sqlite_log_path(cache_path: Path) -> Path:
|
|
63
|
+
return _cache_dir(cache_path) / "sqlite-log.json"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def seed_caches(
|
|
67
|
+
cache_path: Path,
|
|
68
|
+
schema_version: int,
|
|
69
|
+
maxsize: int,
|
|
70
|
+
jsonl_cache: _JsonlCache,
|
|
71
|
+
file_info_cache: _FileInfoCache,
|
|
72
|
+
sqlite_log_cache: Any,
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Seed valid shards, skipping only corrupt or stale shards."""
|
|
75
|
+
from codex_loader import _JsonlCacheEntry, _JsonlParseState
|
|
76
|
+
|
|
77
|
+
_remove_legacy_cache(cache_path)
|
|
78
|
+
sqlite_payload = _load_payload(_sqlite_log_path(cache_path), schema_version)
|
|
79
|
+
if sqlite_payload is not None:
|
|
80
|
+
try:
|
|
81
|
+
watermark = sqlite_payload["watermark"]
|
|
82
|
+
entries_data = sqlite_payload["entries"]
|
|
83
|
+
if (
|
|
84
|
+
isinstance(watermark, list)
|
|
85
|
+
and len(watermark) == 3
|
|
86
|
+
and isinstance(entries_data, list)
|
|
87
|
+
):
|
|
88
|
+
sqlite_log_cache.watermark = tuple(int(item) for item in watermark)
|
|
89
|
+
sqlite_log_cache.entries = [
|
|
90
|
+
_deserialize_usage_entry(entry) for entry in entries_data
|
|
91
|
+
]
|
|
92
|
+
except (KeyError, TypeError, ValueError):
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
for index in range(_SHARD_COUNT):
|
|
96
|
+
payload = _load_payload(_shard_path(cache_path, index), schema_version)
|
|
97
|
+
if payload is None:
|
|
98
|
+
continue
|
|
99
|
+
files = payload.get("files")
|
|
100
|
+
if not isinstance(files, dict):
|
|
101
|
+
continue
|
|
102
|
+
for path_str, file_data in files.items():
|
|
103
|
+
if not isinstance(file_data, dict):
|
|
104
|
+
continue
|
|
105
|
+
try:
|
|
106
|
+
path = Path(path_str)
|
|
107
|
+
mtime = file_data["mtime"]
|
|
108
|
+
size = file_data["size"]
|
|
109
|
+
session_id = file_data["session_id"]
|
|
110
|
+
forked_from_id = file_data["forked_from_id"]
|
|
111
|
+
entries_data = file_data["entries"]
|
|
112
|
+
confirmed_offset = int(file_data.get("confirmed_offset", 0))
|
|
113
|
+
digest_hex = file_data.get("confirmed_prefix_digest", "")
|
|
114
|
+
parse_state_data = file_data.get("parse_state", {})
|
|
115
|
+
if not isinstance(parse_state_data, dict):
|
|
116
|
+
parse_state_data = {}
|
|
117
|
+
|
|
118
|
+
if len(file_info_cache) >= maxsize:
|
|
119
|
+
file_info_cache.popitem(last=False)
|
|
120
|
+
file_info_cache[path] = (
|
|
121
|
+
mtime,
|
|
122
|
+
size,
|
|
123
|
+
_SessionFileInfo(session_id=session_id, forked_from_id=forked_from_id),
|
|
124
|
+
)
|
|
125
|
+
if entries_data is None:
|
|
126
|
+
continue
|
|
127
|
+
if not isinstance(entries_data, list):
|
|
128
|
+
continue
|
|
129
|
+
if (
|
|
130
|
+
"confirmed_offset" not in file_data
|
|
131
|
+
or "confirmed_prefix_digest" not in file_data
|
|
132
|
+
or "parse_state" not in file_data
|
|
133
|
+
):
|
|
134
|
+
continue
|
|
135
|
+
state = _JsonlParseState(
|
|
136
|
+
session_timestamp=str(parse_state_data.get("session_timestamp", "")),
|
|
137
|
+
project=str(parse_state_data.get("project", "unknown")),
|
|
138
|
+
session_model=str(parse_state_data.get("session_model", "unknown")),
|
|
139
|
+
previous_usage=_deserialize_token_usage(parse_state_data.get("previous_usage")),
|
|
140
|
+
token_count_index=int(parse_state_data.get("token_count_index", 0)),
|
|
141
|
+
)
|
|
142
|
+
if len(jsonl_cache) >= maxsize:
|
|
143
|
+
jsonl_cache.popitem(last=False)
|
|
144
|
+
jsonl_cache[path] = _JsonlCacheEntry(
|
|
145
|
+
mtime=mtime,
|
|
146
|
+
size=size,
|
|
147
|
+
replay_cache_key=None,
|
|
148
|
+
entries=[_deserialize_usage_entry(entry) for entry in entries_data],
|
|
149
|
+
confirmed_offset=confirmed_offset,
|
|
150
|
+
confirmed_prefix_digest=bytes.fromhex(digest_hex)
|
|
151
|
+
if isinstance(digest_hex, str)
|
|
152
|
+
else b"",
|
|
153
|
+
state=state,
|
|
154
|
+
)
|
|
155
|
+
except (KeyError, TypeError, ValueError):
|
|
156
|
+
continue
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def flush_caches(
|
|
160
|
+
cache_path: Path,
|
|
161
|
+
schema_version: int,
|
|
162
|
+
jsonl_cache: _JsonlCache,
|
|
163
|
+
file_info_cache: _FileInfoCache,
|
|
164
|
+
sqlite_log_cache: Any,
|
|
165
|
+
) -> None:
|
|
166
|
+
"""Atomically replace only shards whose serialized contents changed."""
|
|
167
|
+
try:
|
|
168
|
+
_remove_legacy_cache(cache_path)
|
|
169
|
+
shards: list[dict[str, Any]] = [{} for _ in range(_SHARD_COUNT)]
|
|
170
|
+
for path, entry in jsonl_cache.items():
|
|
171
|
+
info = file_info_cache.get(path)
|
|
172
|
+
if entry.replay_cache_key is not None:
|
|
173
|
+
if info is None:
|
|
174
|
+
continue
|
|
175
|
+
entries_data = None
|
|
176
|
+
session_id = info[2].session_id
|
|
177
|
+
forked_from_id = info[2].forked_from_id
|
|
178
|
+
else:
|
|
179
|
+
entries_data = [_serialize_usage_entry(item) for item in entry.entries]
|
|
180
|
+
if entry.entries:
|
|
181
|
+
session_id = entry.entries[0].session_id
|
|
182
|
+
forked_from_id = ""
|
|
183
|
+
elif info is not None:
|
|
184
|
+
session_id = info[2].session_id
|
|
185
|
+
forked_from_id = info[2].forked_from_id
|
|
186
|
+
else:
|
|
187
|
+
continue
|
|
188
|
+
shards[_shard_index(path)][str(path)] = {
|
|
189
|
+
"mtime": entry.mtime,
|
|
190
|
+
"size": entry.size,
|
|
191
|
+
"session_id": session_id,
|
|
192
|
+
"forked_from_id": forked_from_id,
|
|
193
|
+
"entries": entries_data,
|
|
194
|
+
"confirmed_offset": entry.confirmed_offset,
|
|
195
|
+
"confirmed_prefix_digest": entry.confirmed_prefix_digest.hex(),
|
|
196
|
+
"parse_state": {
|
|
197
|
+
"session_timestamp": entry.state.session_timestamp,
|
|
198
|
+
"project": entry.state.project,
|
|
199
|
+
"session_model": entry.state.session_model,
|
|
200
|
+
"previous_usage": _serialize_token_usage(entry.state.previous_usage),
|
|
201
|
+
"token_count_index": entry.state.token_count_index,
|
|
202
|
+
},
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
for index, files in enumerate(shards):
|
|
206
|
+
path = _shard_path(cache_path, index)
|
|
207
|
+
if files:
|
|
208
|
+
_write_if_changed(
|
|
209
|
+
path,
|
|
210
|
+
_encoded_payload({"schema_version": schema_version, "files": files}),
|
|
211
|
+
)
|
|
212
|
+
else:
|
|
213
|
+
path.unlink(missing_ok=True)
|
|
214
|
+
|
|
215
|
+
sqlite_payload = {
|
|
216
|
+
"schema_version": schema_version,
|
|
217
|
+
"watermark": list(sqlite_log_cache.watermark)
|
|
218
|
+
if sqlite_log_cache.watermark is not None
|
|
219
|
+
else None,
|
|
220
|
+
"entries": [
|
|
221
|
+
_serialize_usage_entry(entry) for entry in sqlite_log_cache.entries
|
|
222
|
+
],
|
|
223
|
+
}
|
|
224
|
+
_write_if_changed(_sqlite_log_path(cache_path), _encoded_payload(sqlite_payload))
|
|
225
|
+
except Exception as exc:
|
|
226
|
+
if os.environ.get("USAGE_DEBUG") == "1":
|
|
227
|
+
logger.warning("failed to write codex jsonl cache %s: %s", cache_path, exc)
|
codex_events.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
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
|
+
"""Stateless value parsing for Codex session logs.
|
|
8
|
+
|
|
9
|
+
Leaf helpers shared by codex_loader's JSONL and sqlite readers: coercing raw
|
|
10
|
+
JSON/log values into scalars, token-usage payloads and per-file metadata.
|
|
11
|
+
Nothing here touches module state, the filesystem layout constants, or caches —
|
|
12
|
+
those all stay in codex_loader so test monkeypatching keeps working.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import math
|
|
19
|
+
import re
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from datetime import UTC, datetime
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class _ThreadMetadata:
|
|
27
|
+
model: str = "unknown"
|
|
28
|
+
cwd: str = ""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class _SessionFileInfo:
|
|
33
|
+
session_id: str = ""
|
|
34
|
+
forked_from_id: str = ""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class _TokenUsage:
|
|
39
|
+
input_tokens: int
|
|
40
|
+
output_tokens: int
|
|
41
|
+
cache_read_tokens: int
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def total_tokens(self) -> int:
|
|
45
|
+
return self.input_tokens + self.output_tokens + self.cache_read_tokens
|
|
46
|
+
|
|
47
|
+
def delta(self, previous: _TokenUsage | None) -> _TokenUsage:
|
|
48
|
+
if previous is None:
|
|
49
|
+
return self
|
|
50
|
+
return _TokenUsage(
|
|
51
|
+
input_tokens=max(0, self.input_tokens - previous.input_tokens),
|
|
52
|
+
output_tokens=max(0, self.output_tokens - previous.output_tokens),
|
|
53
|
+
cache_read_tokens=max(0, self.cache_read_tokens - previous.cache_read_tokens),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _token_usage_from_payload(usage: dict[str, Any]) -> _TokenUsage:
|
|
58
|
+
cached = _as_int(usage.get("cached_input_tokens"))
|
|
59
|
+
input_tokens = max(0, _as_int(usage.get("input_tokens")) - cached)
|
|
60
|
+
output_tokens = _as_int(usage.get("output_tokens"))
|
|
61
|
+
return _TokenUsage(
|
|
62
|
+
input_tokens=input_tokens,
|
|
63
|
+
output_tokens=output_tokens,
|
|
64
|
+
cache_read_tokens=cached,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _load_json_line(line: str) -> dict[str, Any] | None:
|
|
69
|
+
try:
|
|
70
|
+
data = json.loads(line)
|
|
71
|
+
except (json.JSONDecodeError, RecursionError):
|
|
72
|
+
return None
|
|
73
|
+
return data if isinstance(data, dict) else None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
_EVENT_VALUE_RE_CACHE: dict[str, re.Pattern[str]] = {}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _event_value(body: str, key: str) -> str:
|
|
80
|
+
pattern = _EVENT_VALUE_RE_CACHE.get(key)
|
|
81
|
+
if pattern is None:
|
|
82
|
+
pattern = re.compile(rf'(?:^|[\s{{]){re.escape(key)}=(?:"([^"]*)"|([^\s}}]+))')
|
|
83
|
+
_EVENT_VALUE_RE_CACHE[key] = pattern
|
|
84
|
+
match = pattern.search(body)
|
|
85
|
+
if match is None:
|
|
86
|
+
return ""
|
|
87
|
+
return match.group(1) if match.group(1) is not None else match.group(2)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _timestamp_from_log_ts(value: Any) -> datetime | None:
|
|
91
|
+
if isinstance(value, bool):
|
|
92
|
+
return None
|
|
93
|
+
try:
|
|
94
|
+
timestamp = float(value)
|
|
95
|
+
except (TypeError, ValueError):
|
|
96
|
+
return None
|
|
97
|
+
if not math.isfinite(timestamp):
|
|
98
|
+
return None
|
|
99
|
+
return datetime.fromtimestamp(timestamp, UTC)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _as_dict(value: Any) -> dict[str, Any]:
|
|
103
|
+
return value if isinstance(value, dict) else {}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _session_model(payload: Any, fallback: str) -> str:
|
|
107
|
+
model = _as_str(_as_dict(payload).get("model"))
|
|
108
|
+
return model or fallback
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _as_int(value: Any) -> int:
|
|
112
|
+
if isinstance(value, bool):
|
|
113
|
+
return 0
|
|
114
|
+
try:
|
|
115
|
+
number = float(value)
|
|
116
|
+
except (TypeError, ValueError):
|
|
117
|
+
return 0
|
|
118
|
+
if not math.isfinite(number):
|
|
119
|
+
return 0
|
|
120
|
+
return max(0, int(number))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _as_str(value: Any) -> str:
|
|
124
|
+
return value if isinstance(value, str) else ""
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _as_optional_float(value: Any) -> float | None:
|
|
128
|
+
if isinstance(value, bool):
|
|
129
|
+
return None
|
|
130
|
+
try:
|
|
131
|
+
number = float(value)
|
|
132
|
+
except (TypeError, ValueError):
|
|
133
|
+
return None
|
|
134
|
+
if not math.isfinite(number):
|
|
135
|
+
return None
|
|
136
|
+
return number
|