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/reporter.py
ADDED
|
@@ -0,0 +1,989 @@
|
|
|
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 contextlib
|
|
10
|
+
from collections import defaultdict
|
|
11
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import date, datetime, timedelta, timezone
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
import tempfile
|
|
19
|
+
import time
|
|
20
|
+
from typing import Any, NotRequired, TypedDict, cast
|
|
21
|
+
|
|
22
|
+
import codex_loader
|
|
23
|
+
from analyzer import persona_loader
|
|
24
|
+
from analyzer import subscription
|
|
25
|
+
from adapters import agy, claude, codex
|
|
26
|
+
from adapters.types import AgentInfo, UsageEntry
|
|
27
|
+
from pricing import calculate_cost, is_model_priced
|
|
28
|
+
|
|
29
|
+
from .aggregator import aggregate_sessions
|
|
30
|
+
from . import diagnoser
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
AGENT_LOADERS = {"claude-code": claude, "codex": codex, "antigravity": agy}
|
|
35
|
+
AGENT_NAMES = {"claude-code": "Claude Code", "codex": "Codex"}
|
|
36
|
+
_YEAR_WEEKS = 53
|
|
37
|
+
YEAR_CACHE_PATH = Path(os.path.expanduser("~/.usage/year_cache.json"))
|
|
38
|
+
YEAR_LEDGER_PATH = YEAR_CACHE_PATH.with_name("year_ledger.json")
|
|
39
|
+
YEAR_CACHE_TTL_SECONDS = 6 * 3600
|
|
40
|
+
_YEAR_CACHE_SCHEMA = 1
|
|
41
|
+
_YEAR_LEDGER_SCHEMA = 1
|
|
42
|
+
_YEAR_LEDGER_TRIM_BUFFER_DAYS = 60
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _YearDay(TypedDict):
|
|
46
|
+
total_tokens: int
|
|
47
|
+
cost: float
|
|
48
|
+
model_tokens: dict[str, int]
|
|
49
|
+
project_tokens: dict[str, int]
|
|
50
|
+
agent_tokens: dict[str, int]
|
|
51
|
+
sessions: int
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class _YearLedger(TypedDict):
|
|
55
|
+
schema_version: int
|
|
56
|
+
days: dict[str, _YearDay]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class SummaryReportData(TypedDict):
|
|
60
|
+
total_tokens: int
|
|
61
|
+
cost_usd: float
|
|
62
|
+
sessions: int
|
|
63
|
+
messages: int
|
|
64
|
+
active_days: int
|
|
65
|
+
total_days: int
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class AgentReportRow(TypedDict):
|
|
69
|
+
id: str
|
|
70
|
+
name: str
|
|
71
|
+
tokens: int
|
|
72
|
+
cost: float
|
|
73
|
+
sessions: int
|
|
74
|
+
messages: int
|
|
75
|
+
pct: float
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ProjectReportRow(TypedDict):
|
|
79
|
+
project: str
|
|
80
|
+
tokens: int
|
|
81
|
+
cost: float
|
|
82
|
+
sessions: int
|
|
83
|
+
pct: float
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class ModelReportRow(TypedDict):
|
|
87
|
+
model: str
|
|
88
|
+
tokens: int
|
|
89
|
+
cost: float
|
|
90
|
+
cost_known: bool
|
|
91
|
+
pct: float
|
|
92
|
+
top_project: str | None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class DailyTrendPoint(TypedDict):
|
|
96
|
+
date: str
|
|
97
|
+
tokens: int
|
|
98
|
+
cost: float
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class TopSessionReportRow(TypedDict):
|
|
102
|
+
start_time: str
|
|
103
|
+
project: str
|
|
104
|
+
model: str
|
|
105
|
+
duration_min: float
|
|
106
|
+
tokens: int
|
|
107
|
+
cost: float
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class ComparisonReportData(TypedDict):
|
|
111
|
+
period: str
|
|
112
|
+
has_prev: bool
|
|
113
|
+
prev_tokens: int
|
|
114
|
+
prev_cost: float
|
|
115
|
+
prev_projects: list[str]
|
|
116
|
+
prev_model_share: dict[str, float]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class PersonaReportData(TypedDict):
|
|
120
|
+
hour_histogram: list[int]
|
|
121
|
+
recent_titles: list[str]
|
|
122
|
+
top_projects: NotRequired[list[tuple[str, int]]]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class ContributionDay(TypedDict):
|
|
126
|
+
date: str
|
|
127
|
+
tokens: int
|
|
128
|
+
level: int
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class BusiestDay(TypedDict):
|
|
132
|
+
date: str
|
|
133
|
+
tokens: int
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class ContributionReportData(TypedDict):
|
|
137
|
+
weeks: list[list[ContributionDay]]
|
|
138
|
+
start: str
|
|
139
|
+
end: str
|
|
140
|
+
max_tokens: int
|
|
141
|
+
total_tokens: int
|
|
142
|
+
active_days: int
|
|
143
|
+
current_streak: int
|
|
144
|
+
longest_streak: int
|
|
145
|
+
busiest_day: BusiestDay | None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class WrappedReportData(TypedDict):
|
|
149
|
+
year_label: str
|
|
150
|
+
total_tokens: int
|
|
151
|
+
total_cost: float
|
|
152
|
+
active_days: int
|
|
153
|
+
total_sessions: int
|
|
154
|
+
top_model: str | None
|
|
155
|
+
top_project: str | None
|
|
156
|
+
busiest_day: BusiestDay | None
|
|
157
|
+
longest_streak: int
|
|
158
|
+
claude_tokens: int
|
|
159
|
+
codex_tokens: int
|
|
160
|
+
beast: str | None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class YearReportData(TypedDict):
|
|
164
|
+
contribution: ContributionReportData
|
|
165
|
+
wrapped: WrappedReportData
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class ReportData(TypedDict):
|
|
169
|
+
period: str
|
|
170
|
+
period_label: str
|
|
171
|
+
date_from: str
|
|
172
|
+
date_to: str
|
|
173
|
+
summary: SummaryReportData
|
|
174
|
+
by_agent: list[AgentReportRow]
|
|
175
|
+
by_project: list[ProjectReportRow]
|
|
176
|
+
by_model: list[ModelReportRow]
|
|
177
|
+
daily_trend: list[DailyTrendPoint]
|
|
178
|
+
top_sessions: list[TopSessionReportRow]
|
|
179
|
+
comparison: ComparisonReportData
|
|
180
|
+
subscriptions: list[dict[str, str | None]]
|
|
181
|
+
persona: PersonaReportData | None
|
|
182
|
+
contribution: ContributionReportData
|
|
183
|
+
wrapped: WrappedReportData
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@dataclass(frozen=True)
|
|
187
|
+
class _PeriodSpec:
|
|
188
|
+
persona_days: int
|
|
189
|
+
has_comparison: bool
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# 每個時間範圍的所有屬性集中在這一張表 —— 加新範圍只改這裡,
|
|
193
|
+
# 不再散落到多個函式各維護一份名單(那正是 last30 漏掉前期比較的根因)。
|
|
194
|
+
PERIOD_SPECS: dict[str, _PeriodSpec] = {
|
|
195
|
+
"today": _PeriodSpec(persona_days=1, has_comparison=False),
|
|
196
|
+
"week": _PeriodSpec(persona_days=7, has_comparison=True),
|
|
197
|
+
"last7": _PeriodSpec(persona_days=7, has_comparison=True),
|
|
198
|
+
"month": _PeriodSpec(persona_days=30, has_comparison=True),
|
|
199
|
+
# last30 刻意不做前期比較:它是預設/最常開的報告,做比較要多載一倍歷史
|
|
200
|
+
# (v0.11.6「faster Codex reports」的效能取捨,由 test_report_last30_uses_expected_codex_hours_back 守護)。
|
|
201
|
+
"last30": _PeriodSpec(persona_days=30, has_comparison=False),
|
|
202
|
+
"all": _PeriodSpec(persona_days=3650, has_comparison=False),
|
|
203
|
+
}
|
|
204
|
+
# 未知 period 的保底:與收斂前各名單對未列出 period 的行為一致。
|
|
205
|
+
_DEFAULT_PERIOD_SPEC = _PeriodSpec(persona_days=30, has_comparison=False)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _period_spec(period: str) -> _PeriodSpec:
|
|
209
|
+
return PERIOD_SPECS.get(period, _DEFAULT_PERIOD_SPEC)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _entry_date(entry: UsageEntry) -> date:
|
|
213
|
+
ts = entry.timestamp
|
|
214
|
+
if ts.tzinfo:
|
|
215
|
+
ts = ts.astimezone()
|
|
216
|
+
return ts.date()
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _period_bounds(period: str, today: date) -> tuple[date | None, date]:
|
|
220
|
+
if period == "today":
|
|
221
|
+
return today, today
|
|
222
|
+
if period == "week":
|
|
223
|
+
return today - timedelta(days=today.weekday()), today
|
|
224
|
+
if period == "last7":
|
|
225
|
+
return today - timedelta(days=6), today
|
|
226
|
+
if period == "month":
|
|
227
|
+
return today.replace(day=1), today
|
|
228
|
+
if period == "all":
|
|
229
|
+
return None, today
|
|
230
|
+
if period == "last30":
|
|
231
|
+
return today - timedelta(days=29), today
|
|
232
|
+
return today.replace(day=1), today
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _load_agent_entries(
|
|
236
|
+
agent: AgentInfo,
|
|
237
|
+
hours_back: int = 0,
|
|
238
|
+
) -> list[UsageEntry]:
|
|
239
|
+
if hours_back > 0 and agent.id == "claude-code":
|
|
240
|
+
return _load_recent_claude_entries(hours_back)
|
|
241
|
+
if agent.id == "codex":
|
|
242
|
+
return _load_codex_entries(hours_back)
|
|
243
|
+
loader = AGENT_LOADERS.get(agent.id)
|
|
244
|
+
if loader is None:
|
|
245
|
+
return []
|
|
246
|
+
entries: list[UsageEntry] = loader.load_entries(hours_back=hours_back)
|
|
247
|
+
for entry in entries:
|
|
248
|
+
entry.agent_id = agent.id
|
|
249
|
+
return entries
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _load_recent_claude_entries(hours_back: int) -> list[UsageEntry]:
|
|
253
|
+
entries: list[UsageEntry] = []
|
|
254
|
+
seen: set[str] = set()
|
|
255
|
+
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours_back)
|
|
256
|
+
cutoff_ts = cutoff.timestamp()
|
|
257
|
+
jobs: list[tuple[Path, Path]] = []
|
|
258
|
+
for base_dir in claude.get_claude_dirs():
|
|
259
|
+
base = Path(base_dir)
|
|
260
|
+
if not base.is_dir():
|
|
261
|
+
continue
|
|
262
|
+
for jsonl_path in base.rglob("*.jsonl"):
|
|
263
|
+
try:
|
|
264
|
+
if jsonl_path.stat().st_mtime < cutoff_ts:
|
|
265
|
+
continue
|
|
266
|
+
except OSError:
|
|
267
|
+
continue
|
|
268
|
+
jobs.append((jsonl_path, base))
|
|
269
|
+
with ThreadPoolExecutor(max_workers=8) as executor:
|
|
270
|
+
results = executor.map(lambda job: _parse_claude_file(job[0], job[1], cutoff), jobs)
|
|
271
|
+
for parsed in results:
|
|
272
|
+
for entry in parsed:
|
|
273
|
+
if entry.dedup_key in seen:
|
|
274
|
+
continue
|
|
275
|
+
seen.add(entry.dedup_key)
|
|
276
|
+
entries.append(entry)
|
|
277
|
+
entries.sort(key=lambda entry: entry.timestamp)
|
|
278
|
+
return entries
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _parse_claude_file(path: Path, base: Path, cutoff: datetime) -> list[UsageEntry]:
|
|
282
|
+
parsed: list[UsageEntry] = []
|
|
283
|
+
local_seen: set[str] = set()
|
|
284
|
+
fallback_project = claude.extract_project_from_dir(path, base)
|
|
285
|
+
claude.parse_jsonl(path, fallback_project, parsed, local_seen, cutoff)
|
|
286
|
+
return parsed
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _load_codex_entries(hours_back: int) -> list[UsageEntry]:
|
|
290
|
+
return [
|
|
291
|
+
UsageEntry(
|
|
292
|
+
timestamp=entry.timestamp,
|
|
293
|
+
session_id=entry.session_id,
|
|
294
|
+
message_id=entry.message_id,
|
|
295
|
+
request_id=entry.request_id,
|
|
296
|
+
model=entry.model,
|
|
297
|
+
input_tokens=entry.input_tokens,
|
|
298
|
+
output_tokens=entry.output_tokens,
|
|
299
|
+
cache_creation_tokens=entry.cache_creation_tokens,
|
|
300
|
+
cache_read_tokens=entry.cache_read_tokens,
|
|
301
|
+
cost_usd=entry.cost_usd,
|
|
302
|
+
project=entry.project,
|
|
303
|
+
agent_id="codex",
|
|
304
|
+
message_count=getattr(entry, "message_count", 1),
|
|
305
|
+
)
|
|
306
|
+
for entry in codex_loader.load_entries(hours_back=hours_back)
|
|
307
|
+
]
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _pct(value: int, total: int) -> float:
|
|
311
|
+
return round((value / total * 100), 1) if total else 0.0
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _round_cost(value: float) -> float:
|
|
315
|
+
return round(value, 4)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _load_persona_for_period(period: str) -> PersonaReportData | None:
|
|
319
|
+
days_back = _period_spec(period).persona_days
|
|
320
|
+
try:
|
|
321
|
+
profile = persona_loader.load_profile(days_back)
|
|
322
|
+
except Exception:
|
|
323
|
+
return None
|
|
324
|
+
return {
|
|
325
|
+
"hour_histogram": list(profile.hour_histogram),
|
|
326
|
+
"recent_titles": list(profile.recent_titles),
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _empty_comparison(period: str) -> ComparisonReportData:
|
|
331
|
+
return {
|
|
332
|
+
"period": period,
|
|
333
|
+
"has_prev": False,
|
|
334
|
+
"prev_tokens": 0,
|
|
335
|
+
"prev_cost": 0.0,
|
|
336
|
+
"prev_projects": [],
|
|
337
|
+
"prev_model_share": {},
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _build_comparison(
|
|
342
|
+
raw_entries: list[UsageEntry],
|
|
343
|
+
entry_dates: dict[int, date],
|
|
344
|
+
period: str,
|
|
345
|
+
date_from: date,
|
|
346
|
+
date_to: date,
|
|
347
|
+
) -> ComparisonReportData:
|
|
348
|
+
if not _period_spec(period).has_comparison:
|
|
349
|
+
return _empty_comparison(period)
|
|
350
|
+
|
|
351
|
+
total_days = (date_to - date_from).days + 1
|
|
352
|
+
prev_date_to = date_from - timedelta(days=1)
|
|
353
|
+
prev_date_from = prev_date_to - timedelta(days=total_days - 1)
|
|
354
|
+
prev_entries = [
|
|
355
|
+
entry
|
|
356
|
+
for entry in raw_entries
|
|
357
|
+
if prev_date_from <= entry_dates[id(entry)] <= prev_date_to
|
|
358
|
+
]
|
|
359
|
+
|
|
360
|
+
prev_tokens = sum(entry.total_tokens for entry in prev_entries)
|
|
361
|
+
prev_cost = sum(calculate_cost(entry) for entry in prev_entries)
|
|
362
|
+
prev_projects = sorted(
|
|
363
|
+
{entry.project or "unknown" for entry in prev_entries}
|
|
364
|
+
)
|
|
365
|
+
model_tokens: dict[str, int] = defaultdict(int)
|
|
366
|
+
for entry in prev_entries:
|
|
367
|
+
model_tokens[entry.model or "unknown"] += entry.total_tokens
|
|
368
|
+
prev_model_share = {
|
|
369
|
+
model: _pct(tokens, prev_tokens)
|
|
370
|
+
for model, tokens in sorted(model_tokens.items())
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return {
|
|
374
|
+
"period": period,
|
|
375
|
+
"has_prev": bool(prev_entries),
|
|
376
|
+
"prev_tokens": prev_tokens,
|
|
377
|
+
"prev_cost": _round_cost(prev_cost),
|
|
378
|
+
"prev_projects": prev_projects,
|
|
379
|
+
"prev_model_share": prev_model_share,
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _top_project(project_tokens: dict[str, int]) -> str | None:
|
|
384
|
+
if not project_tokens:
|
|
385
|
+
return None
|
|
386
|
+
return max(project_tokens.items(), key=lambda item: item[1])[0]
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _top_name(bucket: dict[str, int]) -> str | None:
|
|
390
|
+
if not bucket:
|
|
391
|
+
return None
|
|
392
|
+
return sorted(bucket.items(), key=lambda item: (-item[1], item[0]))[0][0]
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _streaks(
|
|
396
|
+
daily_tokens: dict[date, int],
|
|
397
|
+
start: date,
|
|
398
|
+
end: date,
|
|
399
|
+
) -> tuple[int, int]:
|
|
400
|
+
current_streak = 0
|
|
401
|
+
cursor = end
|
|
402
|
+
while cursor >= start and daily_tokens.get(cursor, 0) > 0:
|
|
403
|
+
current_streak += 1
|
|
404
|
+
cursor -= timedelta(days=1)
|
|
405
|
+
|
|
406
|
+
longest_streak = 0
|
|
407
|
+
streak = 0
|
|
408
|
+
cursor = start
|
|
409
|
+
while cursor <= end:
|
|
410
|
+
if daily_tokens.get(cursor, 0) > 0:
|
|
411
|
+
streak += 1
|
|
412
|
+
longest_streak = max(longest_streak, streak)
|
|
413
|
+
else:
|
|
414
|
+
streak = 0
|
|
415
|
+
cursor += timedelta(days=1)
|
|
416
|
+
return current_streak, longest_streak
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _contribution_thresholds(active_tokens: list[int]) -> list[int]:
|
|
420
|
+
sorted_tokens = sorted(tokens for tokens in active_tokens if tokens > 0)
|
|
421
|
+
if not sorted_tokens:
|
|
422
|
+
return []
|
|
423
|
+
|
|
424
|
+
last_index = len(sorted_tokens) - 1
|
|
425
|
+
return [
|
|
426
|
+
sorted_tokens[min(last_index, (len(sorted_tokens) * quartile - 1) // 4)]
|
|
427
|
+
for quartile in range(1, 5)
|
|
428
|
+
]
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _contribution_level(tokens: int, thresholds: list[int]) -> int:
|
|
432
|
+
if tokens <= 0 or not thresholds:
|
|
433
|
+
return 0
|
|
434
|
+
for level, threshold in enumerate(thresholds, start=1):
|
|
435
|
+
if tokens <= threshold:
|
|
436
|
+
return level
|
|
437
|
+
return 4
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _empty_year_ledger() -> _YearLedger:
|
|
441
|
+
return {
|
|
442
|
+
"schema_version": _YEAR_LEDGER_SCHEMA,
|
|
443
|
+
"days": {},
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _token_map(value: object) -> dict[str, int]:
|
|
448
|
+
if not isinstance(value, dict):
|
|
449
|
+
return {}
|
|
450
|
+
tokens: dict[str, int] = {}
|
|
451
|
+
for key, raw_tokens in value.items():
|
|
452
|
+
if (
|
|
453
|
+
isinstance(key, str)
|
|
454
|
+
and isinstance(raw_tokens, int)
|
|
455
|
+
and not isinstance(raw_tokens, bool)
|
|
456
|
+
):
|
|
457
|
+
tokens[key] = raw_tokens
|
|
458
|
+
return tokens
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _year_day_from_json(value: object) -> _YearDay | None:
|
|
462
|
+
if not isinstance(value, dict):
|
|
463
|
+
return None
|
|
464
|
+
total_tokens = value.get("total_tokens")
|
|
465
|
+
cost = value.get("cost")
|
|
466
|
+
sessions = value.get("sessions")
|
|
467
|
+
if (
|
|
468
|
+
not isinstance(total_tokens, int)
|
|
469
|
+
or isinstance(total_tokens, bool)
|
|
470
|
+
or not isinstance(cost, int | float)
|
|
471
|
+
or isinstance(cost, bool)
|
|
472
|
+
or not isinstance(sessions, int)
|
|
473
|
+
or isinstance(sessions, bool)
|
|
474
|
+
):
|
|
475
|
+
return None
|
|
476
|
+
return {
|
|
477
|
+
"total_tokens": total_tokens,
|
|
478
|
+
"cost": float(cost),
|
|
479
|
+
"model_tokens": _token_map(value.get("model_tokens")),
|
|
480
|
+
"project_tokens": _token_map(value.get("project_tokens")),
|
|
481
|
+
"agent_tokens": _token_map(value.get("agent_tokens")),
|
|
482
|
+
"sessions": sessions,
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _read_year_ledger() -> _YearLedger:
|
|
487
|
+
try:
|
|
488
|
+
with YEAR_LEDGER_PATH.open(encoding="utf-8") as file:
|
|
489
|
+
ledger = json.load(file)
|
|
490
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
491
|
+
if os.environ.get("USAGE_DEBUG") == "1":
|
|
492
|
+
logger.warning(
|
|
493
|
+
"failed to read year ledger %s",
|
|
494
|
+
YEAR_LEDGER_PATH,
|
|
495
|
+
exc_info=True,
|
|
496
|
+
)
|
|
497
|
+
return _empty_year_ledger()
|
|
498
|
+
|
|
499
|
+
if not isinstance(ledger, dict):
|
|
500
|
+
return _empty_year_ledger()
|
|
501
|
+
if ledger.get("schema_version") != _YEAR_LEDGER_SCHEMA:
|
|
502
|
+
return _empty_year_ledger()
|
|
503
|
+
raw_days = ledger.get("days")
|
|
504
|
+
if not isinstance(raw_days, dict):
|
|
505
|
+
return _empty_year_ledger()
|
|
506
|
+
|
|
507
|
+
days: dict[str, _YearDay] = {}
|
|
508
|
+
for day_key, raw_day in raw_days.items():
|
|
509
|
+
if not isinstance(day_key, str):
|
|
510
|
+
continue
|
|
511
|
+
try:
|
|
512
|
+
date.fromisoformat(day_key)
|
|
513
|
+
except ValueError:
|
|
514
|
+
continue
|
|
515
|
+
day = _year_day_from_json(raw_day)
|
|
516
|
+
if day is not None:
|
|
517
|
+
days[day_key] = day
|
|
518
|
+
return {
|
|
519
|
+
"schema_version": _YEAR_LEDGER_SCHEMA,
|
|
520
|
+
"days": days,
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _write_year_ledger(ledger: _YearLedger) -> None:
|
|
525
|
+
tmp_path: str | None = None
|
|
526
|
+
try:
|
|
527
|
+
YEAR_LEDGER_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
528
|
+
fd, tmp_path = tempfile.mkstemp(dir=YEAR_LEDGER_PATH.parent, suffix=".tmp")
|
|
529
|
+
with os.fdopen(fd, "w", encoding="utf-8") as file:
|
|
530
|
+
json.dump(ledger, file, ensure_ascii=False, indent=2, sort_keys=True)
|
|
531
|
+
os.replace(tmp_path, YEAR_LEDGER_PATH)
|
|
532
|
+
tmp_path = None
|
|
533
|
+
except Exception as exc:
|
|
534
|
+
if os.environ.get("USAGE_DEBUG") == "1":
|
|
535
|
+
logger.warning("failed to write year ledger %s: %s", YEAR_LEDGER_PATH, exc)
|
|
536
|
+
finally:
|
|
537
|
+
if tmp_path and os.path.exists(tmp_path):
|
|
538
|
+
with contextlib.suppress(OSError):
|
|
539
|
+
os.unlink(tmp_path)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _new_year_day() -> _YearDay:
|
|
543
|
+
return {
|
|
544
|
+
"total_tokens": 0,
|
|
545
|
+
"cost": 0.0,
|
|
546
|
+
"model_tokens": {},
|
|
547
|
+
"project_tokens": {},
|
|
548
|
+
"agent_tokens": {},
|
|
549
|
+
"sessions": 0,
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _aggregate_year_days(entries: list[UsageEntry]) -> dict[str, _YearDay]:
|
|
554
|
+
days: dict[str, _YearDay] = {}
|
|
555
|
+
session_ids: dict[str, set[str]] = defaultdict(set)
|
|
556
|
+
for entry in entries:
|
|
557
|
+
day_key = _entry_date(entry).isoformat()
|
|
558
|
+
day = days.setdefault(day_key, _new_year_day())
|
|
559
|
+
entry_tokens = entry.total_tokens
|
|
560
|
+
day["total_tokens"] += entry_tokens
|
|
561
|
+
day["cost"] += calculate_cost(entry)
|
|
562
|
+
day["model_tokens"][entry.model or "unknown"] = (
|
|
563
|
+
day["model_tokens"].get(entry.model or "unknown", 0) + entry_tokens
|
|
564
|
+
)
|
|
565
|
+
day["project_tokens"][entry.project or "unknown"] = (
|
|
566
|
+
day["project_tokens"].get(entry.project or "unknown", 0) + entry_tokens
|
|
567
|
+
)
|
|
568
|
+
day["agent_tokens"][entry.agent_id or "unknown"] = (
|
|
569
|
+
day["agent_tokens"].get(entry.agent_id or "unknown", 0) + entry_tokens
|
|
570
|
+
)
|
|
571
|
+
session_ids[day_key].add(entry.session_id)
|
|
572
|
+
|
|
573
|
+
for day_key, ids in session_ids.items():
|
|
574
|
+
days[day_key]["sessions"] = len(ids)
|
|
575
|
+
return days
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _merge_year_ledger(
|
|
579
|
+
current_days: dict[str, _YearDay],
|
|
580
|
+
*,
|
|
581
|
+
trim_before: date,
|
|
582
|
+
) -> _YearLedger:
|
|
583
|
+
ledger = _read_year_ledger()
|
|
584
|
+
days = ledger["days"]
|
|
585
|
+
for day_key, current_day in current_days.items():
|
|
586
|
+
ledger_day = days.get(day_key)
|
|
587
|
+
if ledger_day is None or current_day["total_tokens"] >= ledger_day["total_tokens"]:
|
|
588
|
+
days[day_key] = current_day
|
|
589
|
+
|
|
590
|
+
ledger["days"] = {
|
|
591
|
+
day_key: day
|
|
592
|
+
for day_key, day in days.items()
|
|
593
|
+
if date.fromisoformat(day_key) >= trim_before
|
|
594
|
+
}
|
|
595
|
+
_write_year_ledger(ledger)
|
|
596
|
+
return ledger
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _build_year_output_from_ledger(
|
|
600
|
+
ledger: _YearLedger,
|
|
601
|
+
*,
|
|
602
|
+
grid_start: date,
|
|
603
|
+
grid_end: date,
|
|
604
|
+
today: date,
|
|
605
|
+
) -> YearReportData:
|
|
606
|
+
daily_tokens: dict[date, int] = {}
|
|
607
|
+
model_tokens: dict[str, int] = defaultdict(int)
|
|
608
|
+
project_tokens: dict[str, int] = defaultdict(int)
|
|
609
|
+
agent_tokens: dict[str, int] = defaultdict(int)
|
|
610
|
+
total_tokens = 0
|
|
611
|
+
total_cost = 0.0
|
|
612
|
+
total_sessions = 0
|
|
613
|
+
|
|
614
|
+
for day_key, day in ledger["days"].items():
|
|
615
|
+
day_date = date.fromisoformat(day_key)
|
|
616
|
+
if not grid_start <= day_date <= today:
|
|
617
|
+
continue
|
|
618
|
+
daily_tokens[day_date] = day["total_tokens"]
|
|
619
|
+
total_tokens += day["total_tokens"]
|
|
620
|
+
total_cost += day["cost"]
|
|
621
|
+
total_sessions += day["sessions"]
|
|
622
|
+
for model, tokens in day["model_tokens"].items():
|
|
623
|
+
model_tokens[model] += tokens
|
|
624
|
+
for project, tokens in day["project_tokens"].items():
|
|
625
|
+
project_tokens[project] += tokens
|
|
626
|
+
for agent_id, tokens in day["agent_tokens"].items():
|
|
627
|
+
agent_tokens[agent_id] += tokens
|
|
628
|
+
|
|
629
|
+
active_days = sum(1 for tokens in daily_tokens.values() if tokens > 0)
|
|
630
|
+
contribution_thresholds = _contribution_thresholds(list(daily_tokens.values()))
|
|
631
|
+
max_tokens = max(daily_tokens.values(), default=0)
|
|
632
|
+
busiest_day: BusiestDay | None = None
|
|
633
|
+
if max_tokens > 0:
|
|
634
|
+
busiest_date = min(
|
|
635
|
+
day for day, tokens in daily_tokens.items() if tokens == max_tokens
|
|
636
|
+
)
|
|
637
|
+
busiest_day = {
|
|
638
|
+
"date": busiest_date.isoformat(),
|
|
639
|
+
"tokens": max_tokens,
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
current_streak, longest_streak = _streaks(daily_tokens, grid_start, today)
|
|
643
|
+
|
|
644
|
+
weeks: list[list[ContributionDay]] = []
|
|
645
|
+
cursor = grid_start
|
|
646
|
+
while cursor <= grid_end:
|
|
647
|
+
week: list[ContributionDay] = []
|
|
648
|
+
for _ in range(7):
|
|
649
|
+
tokens = daily_tokens.get(cursor, 0) if cursor <= today else 0
|
|
650
|
+
week.append(
|
|
651
|
+
{
|
|
652
|
+
"date": cursor.isoformat(),
|
|
653
|
+
"tokens": tokens,
|
|
654
|
+
"level": _contribution_level(tokens, contribution_thresholds),
|
|
655
|
+
}
|
|
656
|
+
)
|
|
657
|
+
cursor += timedelta(days=1)
|
|
658
|
+
weeks.append(week)
|
|
659
|
+
|
|
660
|
+
claude_tokens = agent_tokens.get("claude-code", 0)
|
|
661
|
+
codex_tokens = agent_tokens.get("codex", 0)
|
|
662
|
+
beast = None
|
|
663
|
+
if total_tokens > 0:
|
|
664
|
+
beast = "phoenix" if claude_tokens >= codex_tokens else "dragon"
|
|
665
|
+
|
|
666
|
+
contribution: ContributionReportData = {
|
|
667
|
+
"weeks": weeks,
|
|
668
|
+
"start": grid_start.isoformat(),
|
|
669
|
+
"end": today.isoformat(),
|
|
670
|
+
"max_tokens": max_tokens,
|
|
671
|
+
"total_tokens": total_tokens,
|
|
672
|
+
"active_days": active_days,
|
|
673
|
+
"current_streak": current_streak,
|
|
674
|
+
"longest_streak": longest_streak,
|
|
675
|
+
"busiest_day": busiest_day,
|
|
676
|
+
}
|
|
677
|
+
wrapped: WrappedReportData = {
|
|
678
|
+
"year_label": str(today.year),
|
|
679
|
+
"total_tokens": total_tokens,
|
|
680
|
+
"total_cost": _round_cost(total_cost),
|
|
681
|
+
"active_days": active_days,
|
|
682
|
+
"total_sessions": total_sessions,
|
|
683
|
+
"top_model": _top_name(model_tokens),
|
|
684
|
+
"top_project": _top_name(project_tokens),
|
|
685
|
+
"busiest_day": busiest_day,
|
|
686
|
+
"longest_streak": longest_streak,
|
|
687
|
+
"claude_tokens": claude_tokens,
|
|
688
|
+
"codex_tokens": codex_tokens,
|
|
689
|
+
"beast": beast,
|
|
690
|
+
}
|
|
691
|
+
return {
|
|
692
|
+
"contribution": contribution,
|
|
693
|
+
"wrapped": wrapped,
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def build_year_data(agents: list[AgentInfo]) -> YearReportData:
|
|
698
|
+
today = datetime.now().astimezone().date()
|
|
699
|
+
grid_end = today + timedelta(days=(5 - today.weekday()) % 7)
|
|
700
|
+
grid_start = grid_end - timedelta(days=_YEAR_WEEKS * 7 - 1)
|
|
701
|
+
hours_back = ((today - grid_start).days + 2) * 24
|
|
702
|
+
|
|
703
|
+
raw_entries: list[UsageEntry] = []
|
|
704
|
+
for agent in agents:
|
|
705
|
+
raw_entries.extend(_load_agent_entries(agent, hours_back))
|
|
706
|
+
|
|
707
|
+
entries = [
|
|
708
|
+
entry
|
|
709
|
+
for entry in raw_entries
|
|
710
|
+
if grid_start <= _entry_date(entry) <= today
|
|
711
|
+
]
|
|
712
|
+
|
|
713
|
+
current_days = _aggregate_year_days(entries)
|
|
714
|
+
ledger = _merge_year_ledger(
|
|
715
|
+
current_days,
|
|
716
|
+
trim_before=grid_start - timedelta(days=_YEAR_LEDGER_TRIM_BUFFER_DAYS),
|
|
717
|
+
)
|
|
718
|
+
return _build_year_output_from_ledger(
|
|
719
|
+
ledger,
|
|
720
|
+
grid_start=grid_start,
|
|
721
|
+
grid_end=grid_end,
|
|
722
|
+
today=today,
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def _load_year_data_cached(agents: list[AgentInfo]) -> YearReportData:
|
|
727
|
+
cached = _read_year_cache()
|
|
728
|
+
if cached is not None:
|
|
729
|
+
return cast(YearReportData, cached)
|
|
730
|
+
|
|
731
|
+
data = build_year_data(agents)
|
|
732
|
+
_write_year_cache(data)
|
|
733
|
+
return data
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def _read_year_cache() -> dict[str, Any] | None:
|
|
737
|
+
try:
|
|
738
|
+
with YEAR_CACHE_PATH.open(encoding="utf-8") as file:
|
|
739
|
+
cache = json.load(file)
|
|
740
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
741
|
+
if os.environ.get("USAGE_DEBUG") == "1":
|
|
742
|
+
logger.warning("failed to read year cache %s", YEAR_CACHE_PATH, exc_info=True)
|
|
743
|
+
return None
|
|
744
|
+
|
|
745
|
+
if not isinstance(cache, dict):
|
|
746
|
+
return None
|
|
747
|
+
if cache.get("schema_version") != _YEAR_CACHE_SCHEMA:
|
|
748
|
+
return None
|
|
749
|
+
|
|
750
|
+
cached_at = cache.get("cached_at")
|
|
751
|
+
if not isinstance(cached_at, int | float):
|
|
752
|
+
return None
|
|
753
|
+
if (time.time() - float(cached_at)) > YEAR_CACHE_TTL_SECONDS:
|
|
754
|
+
return None
|
|
755
|
+
|
|
756
|
+
data = cache.get("data")
|
|
757
|
+
return data if isinstance(data, dict) else None
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def _write_year_cache(data: YearReportData) -> None:
|
|
761
|
+
tmp_path: str | None = None
|
|
762
|
+
try:
|
|
763
|
+
YEAR_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
764
|
+
fd, tmp_path = tempfile.mkstemp(dir=YEAR_CACHE_PATH.parent, suffix=".tmp")
|
|
765
|
+
payload = {
|
|
766
|
+
"schema_version": _YEAR_CACHE_SCHEMA,
|
|
767
|
+
"cached_at": time.time(),
|
|
768
|
+
"data": data,
|
|
769
|
+
}
|
|
770
|
+
with os.fdopen(fd, "w", encoding="utf-8") as file:
|
|
771
|
+
json.dump(payload, file, ensure_ascii=False, indent=2, sort_keys=True)
|
|
772
|
+
os.replace(tmp_path, YEAR_CACHE_PATH)
|
|
773
|
+
tmp_path = None
|
|
774
|
+
except Exception as exc:
|
|
775
|
+
if os.environ.get("USAGE_DEBUG") == "1":
|
|
776
|
+
logger.warning("failed to write year cache %s: %s", YEAR_CACHE_PATH, exc)
|
|
777
|
+
finally:
|
|
778
|
+
if tmp_path and os.path.exists(tmp_path):
|
|
779
|
+
with contextlib.suppress(OSError):
|
|
780
|
+
os.unlink(tmp_path)
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def serialize_diagnosis(
|
|
784
|
+
result: diagnoser.DiagnosisResult,
|
|
785
|
+
*,
|
|
786
|
+
total_corpus_tokens: int,
|
|
787
|
+
) -> dict[str, Any]:
|
|
788
|
+
waste_pct = (
|
|
789
|
+
result.total_waste_tokens / total_corpus_tokens * 100
|
|
790
|
+
if total_corpus_tokens
|
|
791
|
+
else 0.0
|
|
792
|
+
)
|
|
793
|
+
fixable_pct = (
|
|
794
|
+
result.fixable_waste_tokens / total_corpus_tokens * 100
|
|
795
|
+
if total_corpus_tokens
|
|
796
|
+
else 0.0
|
|
797
|
+
)
|
|
798
|
+
return {
|
|
799
|
+
"has_data": result.has_data,
|
|
800
|
+
"total_waste_usd": _round_cost(result.total_waste_usd),
|
|
801
|
+
"monthly_savings_estimate_usd": _round_cost(
|
|
802
|
+
result.monthly_savings_estimate_usd
|
|
803
|
+
),
|
|
804
|
+
"total_waste_tokens": int(result.total_waste_tokens),
|
|
805
|
+
"fixable_waste_tokens": int(result.fixable_waste_tokens),
|
|
806
|
+
"total_corpus_tokens": int(total_corpus_tokens),
|
|
807
|
+
"waste_pct": round(waste_pct, 1),
|
|
808
|
+
"fixable_pct": round(fixable_pct, 1),
|
|
809
|
+
"findings": [
|
|
810
|
+
{
|
|
811
|
+
"severity": finding.severity,
|
|
812
|
+
"kind": finding.kind,
|
|
813
|
+
"headline_plain": finding.headline_plain,
|
|
814
|
+
"headline_detail": finding.headline_detail,
|
|
815
|
+
"estimated_waste_usd": _round_cost(finding.estimated_waste_usd),
|
|
816
|
+
"estimated_waste_tokens": int(finding.estimated_waste_tokens),
|
|
817
|
+
"items": finding.items,
|
|
818
|
+
}
|
|
819
|
+
for finding in result.findings
|
|
820
|
+
],
|
|
821
|
+
"suggested_claudeignore": result.suggested_claudeignore,
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def build_report_data(agents: list[AgentInfo], period: str = "month") -> ReportData:
|
|
826
|
+
"""
|
|
827
|
+
period: "today" | "week" | "last7" | "month" | "all"
|
|
828
|
+
回傳 dict,包含:
|
|
829
|
+
period_label: str
|
|
830
|
+
date_from: str
|
|
831
|
+
date_to: str
|
|
832
|
+
summary: dict
|
|
833
|
+
by_agent: list[dict]
|
|
834
|
+
by_project: list[dict]
|
|
835
|
+
by_model: list[dict]
|
|
836
|
+
daily_trend: list[dict]
|
|
837
|
+
top_sessions: list[dict]
|
|
838
|
+
"""
|
|
839
|
+
today = datetime.now().astimezone().date()
|
|
840
|
+
date_from, date_to = _period_bounds(period, today)
|
|
841
|
+
hours_back = 0 if date_from is None else ((date_to - date_from).days + 2) * 24
|
|
842
|
+
if date_from is not None and _period_spec(period).has_comparison:
|
|
843
|
+
total_days_for_comparison = (date_to - date_from).days + 1
|
|
844
|
+
prev_date_from = date_from - timedelta(days=total_days_for_comparison)
|
|
845
|
+
hours_back = ((date_to - prev_date_from).days + 2) * 24
|
|
846
|
+
|
|
847
|
+
raw_entries: list[UsageEntry] = []
|
|
848
|
+
for agent in agents:
|
|
849
|
+
raw_entries.extend(_load_agent_entries(agent, hours_back))
|
|
850
|
+
entry_dates = {id(entry): _entry_date(entry) for entry in raw_entries}
|
|
851
|
+
|
|
852
|
+
if date_from is None and raw_entries:
|
|
853
|
+
date_from = min(entry_dates[id(entry)] for entry in raw_entries)
|
|
854
|
+
if date_from is None:
|
|
855
|
+
date_from = date_to
|
|
856
|
+
|
|
857
|
+
entries = [
|
|
858
|
+
entry
|
|
859
|
+
for entry in raw_entries
|
|
860
|
+
if date_from <= entry_dates[id(entry)] <= date_to
|
|
861
|
+
]
|
|
862
|
+
|
|
863
|
+
total_tokens = sum(entry.total_tokens for entry in entries)
|
|
864
|
+
total_cost = 0.0
|
|
865
|
+
session_ids = {entry.session_id for entry in entries}
|
|
866
|
+
active_dates = {entry_dates[id(entry)] for entry in entries}
|
|
867
|
+
total_days = (date_to - date_from).days + 1
|
|
868
|
+
comparison = _build_comparison(raw_entries, entry_dates, period, date_from, date_to)
|
|
869
|
+
|
|
870
|
+
by_agent_totals: dict[str, dict[str, Any]] = defaultdict(lambda: {"tokens": 0, "cost": 0.0, "sessions": set(), "messages": 0})
|
|
871
|
+
by_project_totals: dict[str, dict[str, Any]] = defaultdict(lambda: {"tokens": 0, "cost": 0.0, "sessions": set()})
|
|
872
|
+
by_model_totals: dict[str, dict[str, Any]] = defaultdict(lambda: {"tokens": 0, "cost": 0.0})
|
|
873
|
+
by_model_project: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
|
874
|
+
daily_totals: dict[date, dict[str, Any]] = defaultdict(lambda: {"tokens": 0, "cost": 0.0})
|
|
875
|
+
|
|
876
|
+
for entry in entries:
|
|
877
|
+
cost = calculate_cost(entry)
|
|
878
|
+
total_cost += cost
|
|
879
|
+
agent_totals = by_agent_totals[entry.agent_id or "unknown"]
|
|
880
|
+
agent_totals["tokens"] += entry.total_tokens
|
|
881
|
+
agent_totals["cost"] += cost
|
|
882
|
+
agent_totals["sessions"].add(entry.session_id)
|
|
883
|
+
agent_totals["messages"] += entry.message_count
|
|
884
|
+
|
|
885
|
+
project = by_project_totals[entry.project or "unknown"]
|
|
886
|
+
project["tokens"] += entry.total_tokens
|
|
887
|
+
project["cost"] += cost
|
|
888
|
+
project["sessions"].add(entry.session_id)
|
|
889
|
+
|
|
890
|
+
model = by_model_totals[entry.model or "unknown"]
|
|
891
|
+
model["tokens"] += entry.total_tokens
|
|
892
|
+
model["cost"] += cost
|
|
893
|
+
by_model_project[entry.model or "unknown"][entry.project or "unknown"] += (
|
|
894
|
+
entry.total_tokens
|
|
895
|
+
)
|
|
896
|
+
|
|
897
|
+
day = daily_totals[entry_dates[id(entry)]]
|
|
898
|
+
day["tokens"] += entry.total_tokens
|
|
899
|
+
day["cost"] += cost
|
|
900
|
+
|
|
901
|
+
agent_names = {agent.id: agent.name for agent in agents}
|
|
902
|
+
by_agent: list[AgentReportRow] = [
|
|
903
|
+
{
|
|
904
|
+
"id": agent_id,
|
|
905
|
+
"name": agent_names.get(agent_id, AGENT_NAMES.get(agent_id, agent_id)),
|
|
906
|
+
"tokens": data["tokens"],
|
|
907
|
+
"cost": _round_cost(data["cost"]),
|
|
908
|
+
"sessions": len(data["sessions"]),
|
|
909
|
+
"messages": data["messages"],
|
|
910
|
+
"pct": _pct(data["tokens"], total_tokens),
|
|
911
|
+
}
|
|
912
|
+
for agent_id, data in by_agent_totals.items()
|
|
913
|
+
]
|
|
914
|
+
by_agent.sort(key=lambda item: item["tokens"], reverse=True)
|
|
915
|
+
|
|
916
|
+
by_project: list[ProjectReportRow] = [
|
|
917
|
+
{
|
|
918
|
+
"project": project,
|
|
919
|
+
"tokens": data["tokens"],
|
|
920
|
+
"cost": _round_cost(data["cost"]),
|
|
921
|
+
"sessions": len(data["sessions"]),
|
|
922
|
+
"pct": _pct(data["tokens"], total_tokens),
|
|
923
|
+
}
|
|
924
|
+
for project, data in by_project_totals.items()
|
|
925
|
+
]
|
|
926
|
+
by_project.sort(key=lambda item: item["tokens"], reverse=True)
|
|
927
|
+
|
|
928
|
+
by_model: list[ModelReportRow] = [
|
|
929
|
+
{
|
|
930
|
+
"model": model,
|
|
931
|
+
"tokens": data["tokens"],
|
|
932
|
+
"cost": _round_cost(data["cost"]),
|
|
933
|
+
"cost_known": is_model_priced(model),
|
|
934
|
+
"pct": _pct(data["tokens"], total_tokens),
|
|
935
|
+
"top_project": _top_project(by_model_project.get(model, {})),
|
|
936
|
+
}
|
|
937
|
+
for model, data in by_model_totals.items()
|
|
938
|
+
]
|
|
939
|
+
by_model.sort(key=lambda item: item["tokens"], reverse=True)
|
|
940
|
+
|
|
941
|
+
daily_trend: list[DailyTrendPoint] = []
|
|
942
|
+
cursor = date_from
|
|
943
|
+
while cursor <= date_to:
|
|
944
|
+
day = daily_totals[cursor]
|
|
945
|
+
daily_trend.append({
|
|
946
|
+
"date": cursor.isoformat(),
|
|
947
|
+
"tokens": day["tokens"],
|
|
948
|
+
"cost": _round_cost(day["cost"]),
|
|
949
|
+
})
|
|
950
|
+
cursor += timedelta(days=1)
|
|
951
|
+
|
|
952
|
+
top_sessions: list[TopSessionReportRow] = []
|
|
953
|
+
sessions_by_cost = sorted(aggregate_sessions(entries), key=lambda session: session.cost_usd, reverse=True)
|
|
954
|
+
for session in sessions_by_cost[:5]:
|
|
955
|
+
top_sessions.append({
|
|
956
|
+
"start_time": session.start_time.astimezone().strftime("%Y-%m-%d %H:%M") if session.start_time.tzinfo else session.start_time.strftime("%Y-%m-%d %H:%M"),
|
|
957
|
+
"project": session.project or "unknown",
|
|
958
|
+
"model": session.model or "unknown",
|
|
959
|
+
"duration_min": session.duration_minutes,
|
|
960
|
+
"tokens": session.total_tokens,
|
|
961
|
+
"cost": _round_cost(session.cost_usd),
|
|
962
|
+
})
|
|
963
|
+
|
|
964
|
+
year_data = _load_year_data_cached(agents)
|
|
965
|
+
|
|
966
|
+
return {
|
|
967
|
+
"period": period,
|
|
968
|
+
"period_label": f"{date_from.isoformat()} -> {date_to.isoformat()}",
|
|
969
|
+
"date_from": date_from.isoformat(),
|
|
970
|
+
"date_to": date_to.isoformat(),
|
|
971
|
+
"summary": {
|
|
972
|
+
"total_tokens": total_tokens,
|
|
973
|
+
"cost_usd": _round_cost(total_cost),
|
|
974
|
+
"sessions": len(session_ids),
|
|
975
|
+
"messages": sum(entry.message_count for entry in entries),
|
|
976
|
+
"active_days": len(active_dates),
|
|
977
|
+
"total_days": total_days,
|
|
978
|
+
},
|
|
979
|
+
"by_agent": by_agent,
|
|
980
|
+
"by_project": by_project[:10],
|
|
981
|
+
"by_model": by_model,
|
|
982
|
+
"daily_trend": daily_trend,
|
|
983
|
+
"top_sessions": top_sessions,
|
|
984
|
+
"comparison": comparison,
|
|
985
|
+
"subscriptions": subscription.load_subscriptions(),
|
|
986
|
+
"persona": _load_persona_for_period(period),
|
|
987
|
+
"contribution": year_data["contribution"],
|
|
988
|
+
"wrapped": year_data["wrapped"],
|
|
989
|
+
}
|