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
usage_dir_sweeper.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
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
|
+
"""Sweep orphaned ``mkstemp`` remnants out of ``~/.usage/``.
|
|
8
|
+
|
|
9
|
+
Every atomic write in this project has the same shape: ``mkstemp`` beside the
|
|
10
|
+
target, write, ``os.replace``, and a ``finally`` that unlinks the temp file if
|
|
11
|
+
anything went wrong. That cleanup cannot run when the process is SIGKILLed or
|
|
12
|
+
crashes mid-write, so the ``.tmp`` outlives it — one real install accumulated
|
|
13
|
+
40 MB of them over twelve days, mostly from the large JSONL caches whose long
|
|
14
|
+
writes are the likeliest to be interrupted.
|
|
15
|
+
|
|
16
|
+
Those write paths are correct and stay untouched. This module only collects
|
|
17
|
+
what a killed process left behind, once, at startup.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
import stat
|
|
25
|
+
import time
|
|
26
|
+
from contextlib import suppress
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
USAGE_DIR = Path(os.path.expanduser("~/.usage"))
|
|
30
|
+
# Another usage process (for example, the menu bar app and TUI together) may
|
|
31
|
+
# still be writing a large cache. Twenty-four hours is far longer than any
|
|
32
|
+
# single write, so one process cannot remove another process's active temp file.
|
|
33
|
+
STALE_TEMP_AGE_SECONDS = 24 * 3600
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _debug_warning(message: str, *args: object) -> None:
|
|
39
|
+
with suppress(Exception):
|
|
40
|
+
if os.environ.get("USAGE_DEBUG") == "1":
|
|
41
|
+
logger.warning(message, *args, exc_info=True)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _is_real_directory(path: Path) -> bool:
|
|
45
|
+
try:
|
|
46
|
+
return stat.S_ISDIR(path.lstat().st_mode)
|
|
47
|
+
except OSError:
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _sweep_directory(directory: Path, now: float) -> int:
|
|
52
|
+
deleted = 0
|
|
53
|
+
try:
|
|
54
|
+
entries = directory.iterdir()
|
|
55
|
+
for path in entries:
|
|
56
|
+
if not (path.name.startswith("tmp") and path.name.endswith(".tmp")):
|
|
57
|
+
continue
|
|
58
|
+
try:
|
|
59
|
+
file_stat = path.lstat()
|
|
60
|
+
if not stat.S_ISREG(file_stat.st_mode):
|
|
61
|
+
continue
|
|
62
|
+
if now - file_stat.st_mtime < STALE_TEMP_AGE_SECONDS:
|
|
63
|
+
continue
|
|
64
|
+
path.unlink()
|
|
65
|
+
deleted += 1
|
|
66
|
+
except OSError:
|
|
67
|
+
_debug_warning("failed to inspect or remove stale temp file %s", path)
|
|
68
|
+
except OSError:
|
|
69
|
+
_debug_warning("failed to scan usage directory %s", directory)
|
|
70
|
+
return deleted
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def sweep_stale_temp_files(root: Path | None = None, *, now: float | None = None) -> int:
|
|
74
|
+
"""Remove stale mkstemp remnants from the usage directory.
|
|
75
|
+
|
|
76
|
+
Scans ``root`` plus its direct ``*.d`` children (where the sharded caches
|
|
77
|
+
put their temp files) and no deeper. Symlinks and directories are never
|
|
78
|
+
touched no matter how well their name matches. Returns the number deleted;
|
|
79
|
+
never raises.
|
|
80
|
+
"""
|
|
81
|
+
deleted = 0
|
|
82
|
+
try:
|
|
83
|
+
scan_root = USAGE_DIR if root is None else root
|
|
84
|
+
current_time = time.time() if now is None else now
|
|
85
|
+
if not _is_real_directory(scan_root):
|
|
86
|
+
return 0
|
|
87
|
+
|
|
88
|
+
directories = [scan_root]
|
|
89
|
+
try:
|
|
90
|
+
for child in scan_root.iterdir():
|
|
91
|
+
if child.name.endswith(".d") and _is_real_directory(child):
|
|
92
|
+
directories.append(child)
|
|
93
|
+
except OSError:
|
|
94
|
+
_debug_warning("failed to inspect usage subdirectories in %s", scan_root)
|
|
95
|
+
|
|
96
|
+
for directory in directories:
|
|
97
|
+
deleted += _sweep_directory(directory, current_time)
|
|
98
|
+
except Exception:
|
|
99
|
+
_debug_warning("unexpected failure while sweeping stale usage temp files")
|
|
100
|
+
return deleted
|
usage_lang.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
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 os
|
|
10
|
+
import sys
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _normalize_lang(code: str | None) -> str:
|
|
15
|
+
if not code:
|
|
16
|
+
return "en"
|
|
17
|
+
normalized = code.split(".")[0].strip().lower().replace("_", "-")
|
|
18
|
+
|
|
19
|
+
if normalized in {"zh-tw", "zh-hk", "zh-hant"} or normalized.startswith(
|
|
20
|
+
("zh-tw-", "zh-hant")
|
|
21
|
+
):
|
|
22
|
+
return "zh-TW"
|
|
23
|
+
if normalized in {"zh-cn", "zh-sg", "zh-hans", "zh"} or normalized.startswith(
|
|
24
|
+
("zh-cn-", "zh-hans")
|
|
25
|
+
):
|
|
26
|
+
return "zh-CN"
|
|
27
|
+
if normalized.startswith("en"):
|
|
28
|
+
return "en"
|
|
29
|
+
if normalized.startswith("ja"):
|
|
30
|
+
return "ja"
|
|
31
|
+
if normalized.startswith("ko"):
|
|
32
|
+
return "ko"
|
|
33
|
+
return "en"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _detect_macos_lang() -> str:
|
|
37
|
+
try:
|
|
38
|
+
from Foundation import NSLocale
|
|
39
|
+
|
|
40
|
+
preferred = NSLocale.preferredLanguages()
|
|
41
|
+
if preferred:
|
|
42
|
+
return _normalize_lang(str(preferred[0]))
|
|
43
|
+
locale = NSLocale.currentLocale()
|
|
44
|
+
identifier_attr = getattr(locale, "localeIdentifier", None)
|
|
45
|
+
identifier = identifier_attr() if callable(identifier_attr) else identifier_attr
|
|
46
|
+
return _normalize_lang(str(identifier) if identifier is not None else None)
|
|
47
|
+
except Exception:
|
|
48
|
+
return "en"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _detect_windows_lang() -> str:
|
|
52
|
+
try:
|
|
53
|
+
import ctypes
|
|
54
|
+
import locale
|
|
55
|
+
|
|
56
|
+
windll = getattr(ctypes, "windll", None)
|
|
57
|
+
if windll is None:
|
|
58
|
+
return "en"
|
|
59
|
+
lang_id = int(windll.kernel32.GetUserDefaultUILanguage())
|
|
60
|
+
return _normalize_lang(locale.windows_locale.get(lang_id))
|
|
61
|
+
except Exception:
|
|
62
|
+
return "en"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def detect_lang(env: Mapping[str, str] | None = None) -> str:
|
|
66
|
+
source = os.environ if env is None else env
|
|
67
|
+
# Windows 上的 LANG 多半是 Git Bash / MSYS 帶進來的,不代表使用者的系統語言。
|
|
68
|
+
keys = (
|
|
69
|
+
("USAGE_LANG", "TT_LANG") if sys.platform == "win32" else ("USAGE_LANG", "TT_LANG", "LANG")
|
|
70
|
+
)
|
|
71
|
+
for key in keys:
|
|
72
|
+
value = source.get(key, "").strip()
|
|
73
|
+
if value:
|
|
74
|
+
return _normalize_lang(value)
|
|
75
|
+
if env is None:
|
|
76
|
+
if sys.platform == "win32":
|
|
77
|
+
return _detect_windows_lang()
|
|
78
|
+
return _detect_macos_lang()
|
|
79
|
+
return "en"
|
usage_logging.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
|
+
"""Application logging with bounded rotating file output."""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from logging.handlers import RotatingFileHandler
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
# macOS keeps app logs under ~/Library/Logs; every other platform reuses the
|
|
19
|
+
# ~/.usage directory that already holds the disk caches and the quarantine.
|
|
20
|
+
LOG_DIR = (
|
|
21
|
+
Path.home() / "Library" / "Logs" / "usage"
|
|
22
|
+
if sys.platform == "darwin"
|
|
23
|
+
else Path.home() / ".usage" / "logs"
|
|
24
|
+
)
|
|
25
|
+
LOG_FILENAME = "usage-app.log"
|
|
26
|
+
MAX_BYTES = 2 * 1024 * 1024
|
|
27
|
+
BACKUP_COUNT = 3
|
|
28
|
+
LOG_FORMAT = "%(asctime)s %(name)s %(levelname)s: %(message)s"
|
|
29
|
+
DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
|
|
30
|
+
_GLOBAL_DEBUG_FLAGS = {"1", "all", "*"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _PrivateRotatingFileHandler(RotatingFileHandler):
|
|
34
|
+
"""Keep the active log private when creation and rotation open a new file."""
|
|
35
|
+
|
|
36
|
+
def _open(self): # type: ignore[no-untyped-def]
|
|
37
|
+
stream = super()._open()
|
|
38
|
+
with contextlib.suppress(OSError):
|
|
39
|
+
os.chmod(self.baseFilename, 0o600)
|
|
40
|
+
return stream
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def parse_debug_flags(raw: str | None) -> tuple[bool, list[str]]:
|
|
44
|
+
"""Return whether debug is global and the requested logger names."""
|
|
45
|
+
flags = [flag.strip().lower() for flag in raw.split(",") if flag.strip()] if raw else []
|
|
46
|
+
if any(flag in _GLOBAL_DEBUG_FLAGS for flag in flags):
|
|
47
|
+
return True, []
|
|
48
|
+
return False, flags
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def setup_logging() -> None:
|
|
52
|
+
"""Configure console output and best-effort rotating file logging."""
|
|
53
|
+
global_debug, debug_loggers = parse_debug_flags(os.environ.get("USAGE_DEBUG"))
|
|
54
|
+
level = logging.DEBUG if global_debug else logging.WARNING
|
|
55
|
+
logging.basicConfig(level=level, format=LOG_FORMAT, datefmt=DATE_FORMAT)
|
|
56
|
+
|
|
57
|
+
root_logger = logging.getLogger()
|
|
58
|
+
root_logger.setLevel(level)
|
|
59
|
+
for name in debug_loggers:
|
|
60
|
+
logging.getLogger(name).setLevel(logging.DEBUG)
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
LOG_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
64
|
+
file_handler = _PrivateRotatingFileHandler(
|
|
65
|
+
LOG_DIR / LOG_FILENAME,
|
|
66
|
+
maxBytes=MAX_BYTES,
|
|
67
|
+
backupCount=BACKUP_COUNT,
|
|
68
|
+
encoding="utf-8",
|
|
69
|
+
)
|
|
70
|
+
except OSError:
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
file_handler.setLevel(logging.DEBUG)
|
|
74
|
+
file_handler.setFormatter(logging.Formatter(LOG_FORMAT, datefmt=DATE_FORMAT))
|
|
75
|
+
root_logger.addHandler(file_handler)
|
usage_notifications.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
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 dataclasses import dataclass, field
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
from burn_rate import RESET_DROP_PERCENT
|
|
13
|
+
|
|
14
|
+
NotificationKind = Literal["warn", "depleted", "restored"]
|
|
15
|
+
|
|
16
|
+
VALID_CHANNELS = frozenset(
|
|
17
|
+
{
|
|
18
|
+
"claude_session",
|
|
19
|
+
"claude_weekly",
|
|
20
|
+
"codex_session",
|
|
21
|
+
"codex_weekly",
|
|
22
|
+
}
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(slots=True)
|
|
27
|
+
class NotificationEvent:
|
|
28
|
+
kind: NotificationKind
|
|
29
|
+
channel: str
|
|
30
|
+
threshold: float | None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(slots=True)
|
|
34
|
+
class _ChannelState:
|
|
35
|
+
last_percent: float | None = None
|
|
36
|
+
warned_thresholds: set[float] = field(default_factory=set)
|
|
37
|
+
depleted: bool = False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class QuotaNotifier:
|
|
41
|
+
def __init__(self, thresholds: list[float] | None = None) -> None:
|
|
42
|
+
values = [90.0] if thresholds is None else thresholds
|
|
43
|
+
self.thresholds = sorted({float(value) for value in values})
|
|
44
|
+
self._channels = {channel: _ChannelState() for channel in VALID_CHANNELS}
|
|
45
|
+
|
|
46
|
+
def update(
|
|
47
|
+
self,
|
|
48
|
+
channels: dict[str, tuple[float | None, bool]],
|
|
49
|
+
) -> list[NotificationEvent]:
|
|
50
|
+
events: list[NotificationEvent] = []
|
|
51
|
+
for channel, (percent, available) in channels.items():
|
|
52
|
+
if channel not in VALID_CHANNELS:
|
|
53
|
+
continue
|
|
54
|
+
events.extend(self._update_channel(channel, percent, available))
|
|
55
|
+
return events
|
|
56
|
+
|
|
57
|
+
def _update_channel(
|
|
58
|
+
self,
|
|
59
|
+
channel: str,
|
|
60
|
+
percent: float | None,
|
|
61
|
+
available: bool,
|
|
62
|
+
) -> list[NotificationEvent]:
|
|
63
|
+
state = self._channels[channel]
|
|
64
|
+
events: list[NotificationEvent] = []
|
|
65
|
+
current = float(percent) if percent is not None else None
|
|
66
|
+
|
|
67
|
+
reset = (
|
|
68
|
+
current is not None
|
|
69
|
+
and state.last_percent is not None
|
|
70
|
+
and (state.last_percent - current) > RESET_DROP_PERCENT
|
|
71
|
+
)
|
|
72
|
+
if reset:
|
|
73
|
+
was_depleted = state.depleted
|
|
74
|
+
state.warned_thresholds.clear()
|
|
75
|
+
state.depleted = False
|
|
76
|
+
if was_depleted and available and current is not None and current < 100.0:
|
|
77
|
+
events.append(NotificationEvent("restored", channel, None))
|
|
78
|
+
|
|
79
|
+
depleted = current is not None and current >= 100.0
|
|
80
|
+
if depleted:
|
|
81
|
+
if not state.depleted:
|
|
82
|
+
events.append(NotificationEvent("depleted", channel, None))
|
|
83
|
+
state.depleted = True
|
|
84
|
+
elif current is not None:
|
|
85
|
+
previous = state.last_percent
|
|
86
|
+
if previous is not None:
|
|
87
|
+
for threshold in self.thresholds:
|
|
88
|
+
crossed = previous < threshold <= current
|
|
89
|
+
if crossed and threshold not in state.warned_thresholds:
|
|
90
|
+
events.append(NotificationEvent("warn", channel, threshold))
|
|
91
|
+
state.warned_thresholds.add(threshold)
|
|
92
|
+
state.depleted = False
|
|
93
|
+
|
|
94
|
+
if current is not None:
|
|
95
|
+
state.last_percent = current
|
|
96
|
+
return events
|
usage_rate.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
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 os
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
|
|
14
|
+
from history_loader import UsageEntry, load_entries
|
|
15
|
+
|
|
16
|
+
BURN_RATE_THRESH_NORMAL = 500.0 # tokens/min
|
|
17
|
+
BURN_RATE_THRESH_ACTIVE = 2500.0
|
|
18
|
+
BURN_RATE_THRESH_HEAVY = 6000.0
|
|
19
|
+
|
|
20
|
+
GROUP_NAMES = ["Idle", "Normal", "Active", "Heavy"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _utc_now() -> datetime:
|
|
24
|
+
return datetime.now(timezone.utc) # noqa: UP017 - keep timezone.utc explicit.
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class UsageRateTracker:
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
forced_group: int | None = None,
|
|
31
|
+
mock: bool = False,
|
|
32
|
+
load: Callable[[int], list[UsageEntry]] | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
self.forced_group = forced_group
|
|
35
|
+
self.mock = mock
|
|
36
|
+
self._load = load
|
|
37
|
+
self._cached_group: int | None = None
|
|
38
|
+
self._cache_expires_at = 0.0
|
|
39
|
+
|
|
40
|
+
def group(self) -> int:
|
|
41
|
+
forced_group = self._forced_group()
|
|
42
|
+
if forced_group is not None:
|
|
43
|
+
return forced_group
|
|
44
|
+
if self.mock:
|
|
45
|
+
return 0
|
|
46
|
+
|
|
47
|
+
now = time.monotonic()
|
|
48
|
+
if self._cached_group is not None and now < self._cache_expires_at:
|
|
49
|
+
return self._cached_group
|
|
50
|
+
|
|
51
|
+
entries = (
|
|
52
|
+
self._load(1)
|
|
53
|
+
if self._load is not None
|
|
54
|
+
else load_entries(hours_back=1)
|
|
55
|
+
)
|
|
56
|
+
if not entries:
|
|
57
|
+
result = 0
|
|
58
|
+
self._cached_group = result
|
|
59
|
+
self._cache_expires_at = time.monotonic() + 30
|
|
60
|
+
return result
|
|
61
|
+
|
|
62
|
+
active_tokens = sum(entry.active_tokens for entry in entries)
|
|
63
|
+
elapsed_seconds = (_utc_now() - entries[0].timestamp).total_seconds()
|
|
64
|
+
# Match burn_rate's 5-minute floor: a single cache-creation burst should
|
|
65
|
+
# not trigger Heavy on its own.
|
|
66
|
+
elapsed_minutes = max(elapsed_seconds / 60.0, 5.0)
|
|
67
|
+
burn_rate = active_tokens / min(elapsed_minutes, 60.0)
|
|
68
|
+
|
|
69
|
+
if burn_rate < BURN_RATE_THRESH_NORMAL:
|
|
70
|
+
result = 0
|
|
71
|
+
elif burn_rate < BURN_RATE_THRESH_ACTIVE:
|
|
72
|
+
result = 1
|
|
73
|
+
elif burn_rate < BURN_RATE_THRESH_HEAVY:
|
|
74
|
+
result = 2
|
|
75
|
+
else:
|
|
76
|
+
result = 3
|
|
77
|
+
|
|
78
|
+
self._cached_group = result
|
|
79
|
+
self._cache_expires_at = time.monotonic() + 30
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
def _forced_group(self) -> int | None:
|
|
83
|
+
if self.forced_group is not None:
|
|
84
|
+
return self.forced_group
|
|
85
|
+
|
|
86
|
+
raw_value = os.environ.get("USAGE_FORCE_GROUP")
|
|
87
|
+
if raw_value is None:
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
group = int(raw_value)
|
|
92
|
+
except ValueError:
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
if 0 <= group < len(GROUP_NAMES):
|
|
96
|
+
return group
|
|
97
|
+
return None
|