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
agy_window_keeper.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
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
|
+
"""Auto-open the next Antigravity 5-hour window."""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import tempfile
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
from contextlib import suppress
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from menubar_agy import AgyRefreshResult
|
|
23
|
+
from menubar_prefs import _agy_window_keeper_enabled
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
AGY_WINDOW_KEEPER_STATE_PATH = Path(
|
|
28
|
+
os.path.expanduser("~/.usage/agy_window_keeper.json")
|
|
29
|
+
)
|
|
30
|
+
PING_COOLDOWN_SECONDS = 5 * 3600
|
|
31
|
+
PING_TIMEOUT_SECONDS = 180
|
|
32
|
+
AGY_MODEL = "Gemini 3.5 Flash (Low)"
|
|
33
|
+
_AGY_BIN_FALLBACKS = (
|
|
34
|
+
"~/.local/bin/agy",
|
|
35
|
+
"~/AppData/Local/agy/bin/agy.exe",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
_lock = threading.Lock()
|
|
39
|
+
_ping_in_flight = False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def should_ping(
|
|
43
|
+
now: float,
|
|
44
|
+
enabled: bool,
|
|
45
|
+
last_ping_at: float | None,
|
|
46
|
+
remaining_percent: float | None,
|
|
47
|
+
stale: object | None,
|
|
48
|
+
fallback_projection: bool,
|
|
49
|
+
mock: bool,
|
|
50
|
+
) -> bool:
|
|
51
|
+
"""Return whether fresh quota data shows no active five-hour window.
|
|
52
|
+
|
|
53
|
+
At 100% remaining, the API reset time is a sliding placeholder, so it
|
|
54
|
+
cannot indicate an active window.
|
|
55
|
+
"""
|
|
56
|
+
if not enabled or mock or fallback_projection or stale is not None:
|
|
57
|
+
return False
|
|
58
|
+
if remaining_percent is None or remaining_percent < 100.0:
|
|
59
|
+
return False
|
|
60
|
+
return last_ping_at is None or now - last_ping_at >= PING_COOLDOWN_SECONDS
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _load_last_ping(path: Path | None = None) -> float | None:
|
|
64
|
+
state_path = AGY_WINDOW_KEEPER_STATE_PATH if path is None else path
|
|
65
|
+
if not state_path.exists():
|
|
66
|
+
return None
|
|
67
|
+
try:
|
|
68
|
+
data = json.loads(state_path.read_text(encoding="utf-8"))
|
|
69
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
70
|
+
return None
|
|
71
|
+
if not isinstance(data, dict):
|
|
72
|
+
return None
|
|
73
|
+
value = data.get("last_ping_at")
|
|
74
|
+
if isinstance(value, bool) or not isinstance(value, int | float):
|
|
75
|
+
return None
|
|
76
|
+
return float(value)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _save_last_ping(ts: float, path: Path | None = None) -> None:
|
|
80
|
+
state_path = AGY_WINDOW_KEEPER_STATE_PATH if path is None else path
|
|
81
|
+
state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
payload = json.dumps({"last_ping_at": ts}) + "\n"
|
|
83
|
+
tmp_path: str | None = None
|
|
84
|
+
try:
|
|
85
|
+
fd, tmp_path = tempfile.mkstemp(dir=state_path.parent, suffix=".tmp")
|
|
86
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
87
|
+
handle.write(payload)
|
|
88
|
+
os.replace(tmp_path, state_path)
|
|
89
|
+
tmp_path = None
|
|
90
|
+
except OSError:
|
|
91
|
+
_debug_log("agy-window-keeper state write failed", exc_info=True)
|
|
92
|
+
finally:
|
|
93
|
+
if tmp_path and os.path.exists(tmp_path):
|
|
94
|
+
with suppress(OSError):
|
|
95
|
+
os.unlink(tmp_path)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _resolve_agy_bin() -> str | None:
|
|
99
|
+
found = shutil.which("agy")
|
|
100
|
+
if found:
|
|
101
|
+
return found
|
|
102
|
+
for candidate in _AGY_BIN_FALLBACKS:
|
|
103
|
+
resolved = os.path.expanduser(candidate)
|
|
104
|
+
if os.path.isfile(resolved) and os.access(resolved, os.X_OK):
|
|
105
|
+
return resolved
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _try_acquire() -> bool:
|
|
110
|
+
global _ping_in_flight
|
|
111
|
+
with _lock:
|
|
112
|
+
if _ping_in_flight:
|
|
113
|
+
return False
|
|
114
|
+
_ping_in_flight = True
|
|
115
|
+
return True
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _release() -> None:
|
|
119
|
+
global _ping_in_flight
|
|
120
|
+
with _lock:
|
|
121
|
+
_ping_in_flight = False
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _run_agy_ping(agy_bin: str) -> None:
|
|
125
|
+
subprocess.run( # noqa: S603 - resolved local Antigravity CLI
|
|
126
|
+
[agy_bin, "-p", "ok", "--model", AGY_MODEL],
|
|
127
|
+
stdin=subprocess.DEVNULL,
|
|
128
|
+
capture_output=True,
|
|
129
|
+
encoding="utf-8",
|
|
130
|
+
errors="replace",
|
|
131
|
+
timeout=PING_TIMEOUT_SECONDS,
|
|
132
|
+
cwd=os.path.expanduser("~"),
|
|
133
|
+
check=False,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _ping_worker(started_at: float) -> None:
|
|
138
|
+
try:
|
|
139
|
+
agy_bin = _resolve_agy_bin()
|
|
140
|
+
if agy_bin is None:
|
|
141
|
+
_debug_log("agy-window-keeper: agy binary not found, skipping ping")
|
|
142
|
+
return
|
|
143
|
+
_run_agy_ping(agy_bin)
|
|
144
|
+
_debug_log(f"agy-window-keeper: ping completed (started_at={started_at})")
|
|
145
|
+
except subprocess.TimeoutExpired:
|
|
146
|
+
_debug_log(f"agy-window-keeper: ping timed out after {PING_TIMEOUT_SECONDS}s")
|
|
147
|
+
except Exception:
|
|
148
|
+
_debug_log("agy-window-keeper: ping failed", exc_info=True)
|
|
149
|
+
finally:
|
|
150
|
+
_release()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def maybe_ping(result: AgyRefreshResult, mock: bool) -> None:
|
|
154
|
+
"""Read preferences and state, then dispatch a background ping if eligible."""
|
|
155
|
+
if mock:
|
|
156
|
+
return
|
|
157
|
+
enabled = _agy_window_keeper_enabled()
|
|
158
|
+
if not enabled:
|
|
159
|
+
return
|
|
160
|
+
projection = result.projection
|
|
161
|
+
five_hour = projection.five_hour if projection is not None else None
|
|
162
|
+
now = time.time()
|
|
163
|
+
last_ping_at = _load_last_ping()
|
|
164
|
+
if not should_ping(
|
|
165
|
+
now=now,
|
|
166
|
+
enabled=enabled,
|
|
167
|
+
last_ping_at=last_ping_at,
|
|
168
|
+
remaining_percent=(
|
|
169
|
+
five_hour.remaining_percent if five_hour is not None else None
|
|
170
|
+
),
|
|
171
|
+
stale=projection.stale if projection is not None else None,
|
|
172
|
+
fallback_projection=projection is None,
|
|
173
|
+
mock=mock,
|
|
174
|
+
):
|
|
175
|
+
return
|
|
176
|
+
if not _try_acquire():
|
|
177
|
+
return
|
|
178
|
+
# Stamp dispatch even on failure to avoid retrying every refresh.
|
|
179
|
+
_save_last_ping(now)
|
|
180
|
+
threading.Thread(target=_ping_worker, args=(now,), daemon=True).start()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _debug_log(message: str, *, exc_info: bool = False) -> None:
|
|
184
|
+
if os.environ.get("USAGE_DEBUG") == "1":
|
|
185
|
+
logger.warning(message, exc_info=exc_info)
|
analyzer/__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.
|
analyzer/aggregator.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
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 collections import defaultdict
|
|
8
|
+
|
|
9
|
+
from adapters.types import DailyStats, MonthlyStats, SessionStats, UsageEntry, WeeklyStats
|
|
10
|
+
from pricing import calculate_cost
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def aggregate_daily(entries: list[UsageEntry]) -> list[DailyStats]:
|
|
14
|
+
by_date: dict[str, DailyStats] = {}
|
|
15
|
+
sessions_by_date: dict[str, set[str]] = defaultdict(set)
|
|
16
|
+
|
|
17
|
+
for e in entries:
|
|
18
|
+
date_str = e.timestamp.astimezone().strftime("%Y-%m-%d")
|
|
19
|
+
if date_str not in by_date:
|
|
20
|
+
by_date[date_str] = DailyStats(date=date_str)
|
|
21
|
+
s = by_date[date_str]
|
|
22
|
+
cost = calculate_cost(e)
|
|
23
|
+
s.input_tokens += e.input_tokens
|
|
24
|
+
s.output_tokens += e.output_tokens
|
|
25
|
+
s.cache_creation_tokens += e.cache_creation_tokens
|
|
26
|
+
s.cache_read_tokens += e.cache_read_tokens
|
|
27
|
+
s.total_tokens += e.total_tokens
|
|
28
|
+
s.cost_usd += cost
|
|
29
|
+
s.message_count += e.message_count
|
|
30
|
+
s.models[e.model] = s.models.get(e.model, 0) + e.total_tokens
|
|
31
|
+
sessions_by_date[date_str].add(e.session_id)
|
|
32
|
+
|
|
33
|
+
for date_str, sessions in sessions_by_date.items():
|
|
34
|
+
by_date[date_str].session_count = len(sessions)
|
|
35
|
+
|
|
36
|
+
return sorted(by_date.values(), key=lambda s: s.date)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def aggregate_monthly(entries: list[UsageEntry]) -> list[MonthlyStats]:
|
|
40
|
+
by_month: dict[str, MonthlyStats] = {}
|
|
41
|
+
sessions_by_month: dict[str, set[str]] = defaultdict(set)
|
|
42
|
+
|
|
43
|
+
for e in entries:
|
|
44
|
+
month_str = e.timestamp.astimezone().strftime("%Y-%m")
|
|
45
|
+
if month_str not in by_month:
|
|
46
|
+
by_month[month_str] = MonthlyStats(month=month_str)
|
|
47
|
+
s = by_month[month_str]
|
|
48
|
+
cost = calculate_cost(e)
|
|
49
|
+
s.input_tokens += e.input_tokens
|
|
50
|
+
s.output_tokens += e.output_tokens
|
|
51
|
+
s.cache_creation_tokens += e.cache_creation_tokens
|
|
52
|
+
s.cache_read_tokens += e.cache_read_tokens
|
|
53
|
+
s.total_tokens += e.total_tokens
|
|
54
|
+
s.cost_usd += cost
|
|
55
|
+
s.message_count += e.message_count
|
|
56
|
+
s.models[e.model] = s.models.get(e.model, 0) + e.total_tokens
|
|
57
|
+
sessions_by_month[month_str].add(e.session_id)
|
|
58
|
+
|
|
59
|
+
for month_str, sessions in sessions_by_month.items():
|
|
60
|
+
by_month[month_str].session_count = len(sessions)
|
|
61
|
+
|
|
62
|
+
return sorted(by_month.values(), key=lambda s: s.month)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def aggregate_weekly(entries: list[UsageEntry]) -> list[WeeklyStats]:
|
|
66
|
+
from datetime import timedelta
|
|
67
|
+
|
|
68
|
+
by_week: dict[str, WeeklyStats] = {}
|
|
69
|
+
sessions_by_week: dict[str, set[str]] = defaultdict(set)
|
|
70
|
+
|
|
71
|
+
for e in entries:
|
|
72
|
+
local_ts = e.timestamp.astimezone()
|
|
73
|
+
monday = local_ts.date() - timedelta(days=local_ts.weekday())
|
|
74
|
+
sunday = monday + timedelta(days=6)
|
|
75
|
+
week_key = monday.isoformat()
|
|
76
|
+
if week_key not in by_week:
|
|
77
|
+
by_week[week_key] = WeeklyStats(
|
|
78
|
+
week=week_key,
|
|
79
|
+
week_start=monday.strftime("%m-%d"),
|
|
80
|
+
week_end=sunday.strftime("%m-%d"),
|
|
81
|
+
)
|
|
82
|
+
s = by_week[week_key]
|
|
83
|
+
cost = calculate_cost(e)
|
|
84
|
+
s.input_tokens += e.input_tokens
|
|
85
|
+
s.output_tokens += e.output_tokens
|
|
86
|
+
s.cache_creation_tokens += e.cache_creation_tokens
|
|
87
|
+
s.cache_read_tokens += e.cache_read_tokens
|
|
88
|
+
s.total_tokens += e.total_tokens
|
|
89
|
+
s.cost_usd += cost
|
|
90
|
+
s.message_count += e.message_count
|
|
91
|
+
s.models[e.model] = s.models.get(e.model, 0) + e.total_tokens
|
|
92
|
+
sessions_by_week[week_key].add(e.session_id)
|
|
93
|
+
|
|
94
|
+
for week_key, sessions in sessions_by_week.items():
|
|
95
|
+
by_week[week_key].session_count = len(sessions)
|
|
96
|
+
|
|
97
|
+
return sorted(by_week.values(), key=lambda s: s.week)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def aggregate_sessions(entries: list[UsageEntry]) -> list[SessionStats]:
|
|
101
|
+
by_session: dict[str, list[UsageEntry]] = defaultdict(list)
|
|
102
|
+
|
|
103
|
+
for e in entries:
|
|
104
|
+
by_session[e.session_id].append(e)
|
|
105
|
+
|
|
106
|
+
sessions: list[SessionStats] = []
|
|
107
|
+
for session_id, session_entries in by_session.items():
|
|
108
|
+
session_entries.sort(key=lambda e: e.timestamp)
|
|
109
|
+
first = session_entries[0]
|
|
110
|
+
last = session_entries[-1]
|
|
111
|
+
duration = (last.timestamp - first.timestamp).total_seconds() / 60
|
|
112
|
+
|
|
113
|
+
models: dict[str, int] = defaultdict(int)
|
|
114
|
+
for e in session_entries:
|
|
115
|
+
models[e.model] += e.total_tokens
|
|
116
|
+
primary_model = max(models, key=lambda model: models[model]) if models else "unknown"
|
|
117
|
+
|
|
118
|
+
s = SessionStats(
|
|
119
|
+
session_id=session_id,
|
|
120
|
+
project=first.project,
|
|
121
|
+
model=primary_model,
|
|
122
|
+
start_time=first.timestamp,
|
|
123
|
+
end_time=last.timestamp,
|
|
124
|
+
duration_minutes=round(duration, 1),
|
|
125
|
+
)
|
|
126
|
+
for e in session_entries:
|
|
127
|
+
cost = calculate_cost(e)
|
|
128
|
+
s.input_tokens += e.input_tokens
|
|
129
|
+
s.output_tokens += e.output_tokens
|
|
130
|
+
s.cache_creation_tokens += e.cache_creation_tokens
|
|
131
|
+
s.cache_read_tokens += e.cache_read_tokens
|
|
132
|
+
s.total_tokens += e.total_tokens
|
|
133
|
+
s.cost_usd += cost
|
|
134
|
+
s.message_count += e.message_count
|
|
135
|
+
|
|
136
|
+
sessions.append(s)
|
|
137
|
+
|
|
138
|
+
sessions.sort(key=lambda s: s.start_time, reverse=True)
|
|
139
|
+
return sessions
|
analyzer/blocks.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
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 collections.abc import Sequence
|
|
8
|
+
from datetime import datetime, timedelta, timezone
|
|
9
|
+
|
|
10
|
+
from adapters.types import DailyStats, P90Limits, SessionBlock, UsageEntry
|
|
11
|
+
from pricing import calculate_cost
|
|
12
|
+
|
|
13
|
+
BLOCK_DURATION = timedelta(hours=5)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def analyze_blocks(entries: list[UsageEntry]) -> list[SessionBlock]:
|
|
17
|
+
if not entries:
|
|
18
|
+
return []
|
|
19
|
+
|
|
20
|
+
sorted_entries = sorted(entries, key=lambda e: e.timestamp)
|
|
21
|
+
blocks: list[SessionBlock] = []
|
|
22
|
+
current_block: SessionBlock | None = None
|
|
23
|
+
|
|
24
|
+
for entry in sorted_entries:
|
|
25
|
+
if current_block is None or entry.timestamp >= current_block.end_time:
|
|
26
|
+
if current_block and entry.timestamp >= current_block.end_time:
|
|
27
|
+
gap_duration = entry.timestamp - current_block.end_time
|
|
28
|
+
if gap_duration > timedelta(minutes=5):
|
|
29
|
+
gap = SessionBlock(
|
|
30
|
+
start_time=current_block.end_time,
|
|
31
|
+
end_time=entry.timestamp,
|
|
32
|
+
is_gap=True,
|
|
33
|
+
)
|
|
34
|
+
blocks.append(gap)
|
|
35
|
+
|
|
36
|
+
current_block = SessionBlock(
|
|
37
|
+
start_time=entry.timestamp,
|
|
38
|
+
end_time=entry.timestamp + BLOCK_DURATION,
|
|
39
|
+
)
|
|
40
|
+
blocks.append(current_block)
|
|
41
|
+
|
|
42
|
+
cost = calculate_cost(entry)
|
|
43
|
+
current_block.entries.append(entry)
|
|
44
|
+
current_block.input_tokens += entry.input_tokens
|
|
45
|
+
current_block.output_tokens += entry.output_tokens
|
|
46
|
+
current_block.cache_creation_tokens += entry.cache_creation_tokens
|
|
47
|
+
current_block.cache_read_tokens += entry.cache_read_tokens
|
|
48
|
+
current_block.total_tokens += entry.total_tokens
|
|
49
|
+
current_block.cost_usd += cost
|
|
50
|
+
|
|
51
|
+
now = datetime.now(timezone.utc)
|
|
52
|
+
for block in blocks:
|
|
53
|
+
if block.is_gap:
|
|
54
|
+
continue
|
|
55
|
+
if block.end_time > now and block.entries:
|
|
56
|
+
block.is_active = True
|
|
57
|
+
elapsed = (now - block.start_time).total_seconds() / 60
|
|
58
|
+
if elapsed > 0:
|
|
59
|
+
block.burn_rate = block.total_tokens / elapsed
|
|
60
|
+
|
|
61
|
+
return blocks
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def calculate_p90(daily_stats: list[DailyStats]) -> P90Limits:
|
|
65
|
+
if len(daily_stats) < 3:
|
|
66
|
+
return P90Limits()
|
|
67
|
+
|
|
68
|
+
token_values = sorted(d.total_tokens for d in daily_stats)
|
|
69
|
+
cost_values = sorted(d.cost_usd for d in daily_stats)
|
|
70
|
+
msg_values = sorted(d.message_count for d in daily_stats)
|
|
71
|
+
|
|
72
|
+
def p90(values: Sequence[float]) -> float:
|
|
73
|
+
idx = int(len(values) * 0.9)
|
|
74
|
+
return values[min(idx, len(values) - 1)]
|
|
75
|
+
|
|
76
|
+
return P90Limits(
|
|
77
|
+
token_limit=int(p90(token_values)),
|
|
78
|
+
cost_limit=round(p90(cost_values), 2),
|
|
79
|
+
message_limit=int(p90(msg_values)),
|
|
80
|
+
)
|