codex-meter 0.3.0__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.
@@ -0,0 +1,132 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass
4
+
5
+ from codex_meter.aggregation import aggregate_daily, aggregate_projects, aggregate_total
6
+ from codex_meter.models import CostTotals, LoadResult, RuntimeOptions
7
+ from codex_meter.pricing import RateCard
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class Insight:
12
+ severity: str
13
+ title: str
14
+ detail: str
15
+ action: str
16
+
17
+
18
+ def build_insights(
19
+ result: LoadResult,
20
+ options: RuntimeOptions,
21
+ rate_card: RateCard | None = None,
22
+ ) -> list[Insight]:
23
+ card = rate_card or RateCard.load(options.rates_file, options.pricing_mode)
24
+ insights: list[Insight] = []
25
+ total = aggregate_total(result, options, rate_card=card)
26
+
27
+ if total.totals.input_tokens:
28
+ cache_ratio = total.totals.cached_input_tokens / total.totals.input_tokens
29
+ savings = cache_savings(result, card)
30
+ if total.totals.cached_input_tokens:
31
+ insights.append(
32
+ Insight(
33
+ severity="info",
34
+ title="High cache reuse" if cache_ratio >= 0.5 else "Low cache reuse",
35
+ detail=(
36
+ f"{cache_ratio:.1%} of input tokens were served from cache, "
37
+ f"saving about {savings.adjusted_credits:,.2f} credits and "
38
+ f"${savings.api_dollars:,.2f}."
39
+ ),
40
+ action="Keep prompts and file context stable to preserve cache hits.",
41
+ )
42
+ )
43
+
44
+ if result.events:
45
+ assumed = result.tier_sources.get("assumed", 0) + result.tier_sources.get(
46
+ "current-config", 0
47
+ )
48
+ if assumed / len(result.events) >= 0.8:
49
+ insights.append(
50
+ Insight(
51
+ severity="warn",
52
+ title="Service tier inferred",
53
+ detail=(
54
+ f"{assumed:,} of {len(result.events):,} events used an inferred tier "
55
+ "rather than a logged tier."
56
+ ),
57
+ action=(
58
+ "Pin known usage with --service-tier or --tier-overrides for sharper costs."
59
+ ),
60
+ )
61
+ )
62
+
63
+ projects = aggregate_projects(result, options, rate_card=card)
64
+ if projects and total.costs.adjusted_credits:
65
+ top = projects[0]
66
+ share = float(top.costs.adjusted_credits / total.costs.adjusted_credits)
67
+ if share >= 0.4:
68
+ insights.append(
69
+ Insight(
70
+ severity="info",
71
+ title="Spend concentrated in one project",
72
+ detail=(
73
+ f"{top.label} accounts for {share:.1%} of credits "
74
+ f"({top.costs.adjusted_credits:,.2f})."
75
+ ),
76
+ action="Run codex-meter project --top 5 to inspect the largest workspaces.",
77
+ )
78
+ )
79
+
80
+ daily = aggregate_daily(result, options, rate_card=card)
81
+ if len(daily) >= 3:
82
+ midpoint = len(daily) // 2
83
+ earlier = sum(row.costs.adjusted_credits for row in daily[:midpoint]) / max(midpoint, 1)
84
+ later_count = len(daily) - midpoint
85
+ later = sum(row.costs.adjusted_credits for row in daily[midpoint:]) / max(later_count, 1)
86
+ if earlier and later / earlier >= 2:
87
+ insights.append(
88
+ Insight(
89
+ severity="warn",
90
+ title="Daily usage is accelerating",
91
+ detail=f"Recent daily credits are {later / earlier:.1f}x the earlier average.",
92
+ action="Run codex-meter forecast --cap <plan-credits> to check depletion risk.",
93
+ )
94
+ )
95
+
96
+ return insights
97
+
98
+
99
+ def cache_savings(result: LoadResult, rate_card: RateCard) -> CostTotals:
100
+ savings = CostTotals()
101
+ for event in result.events:
102
+ savings.add(rate_card.cache_savings_for(event.usage, event.model, event.service_tier))
103
+ return savings
104
+
105
+
106
+ def insights_payload(insights: list[Insight]) -> dict:
107
+ return {"insights": [asdict(item) for item in insights]}
108
+
109
+
110
+ def render_insights_markdown(insights: list[Insight]) -> str:
111
+ lines = [
112
+ "| Severity | Insight | Detail | Action |",
113
+ "| --- | --- | --- | --- |",
114
+ ]
115
+ for insight in insights:
116
+ lines.append(
117
+ "| "
118
+ + " | ".join(
119
+ [
120
+ insight.severity,
121
+ _escape_markdown(insight.title),
122
+ _escape_markdown(insight.detail),
123
+ _escape_markdown(insight.action),
124
+ ]
125
+ )
126
+ + " |"
127
+ )
128
+ return "\n".join(lines) + "\n"
129
+
130
+
131
+ def _escape_markdown(value: str) -> str:
132
+ return value.replace("|", "\\|")
@@ -0,0 +1,129 @@
1
+ """Natural-language window expressions: 'last 7 days', 'yesterday', 'this month', etc."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import calendar
6
+ import datetime as dt
7
+ import re
8
+ from dataclasses import dataclass
9
+
10
+ WINDOW_EXAMPLES = "Try: 'last 7 days', 'previous 7 days', 'this week', '2026-05-01..2026-05-12'."
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class Interval:
15
+ start: dt.datetime
16
+ end: dt.datetime
17
+ label: str
18
+
19
+
20
+ _LAST_N_DAYS = re.compile(r"^last\s+(\d+)\s+days?$")
21
+ _PREVIOUS_N_DAYS = re.compile(r"^previous\s+(\d+)\s+days?$")
22
+ _LAST_N_HOURS = re.compile(r"^last\s+(\d+)\s+hours?$")
23
+
24
+
25
+ def _start_of_day(when: dt.datetime) -> dt.datetime:
26
+ return when.replace(hour=0, minute=0, second=0, microsecond=0)
27
+
28
+
29
+ def _start_of_week(when: dt.datetime) -> dt.datetime:
30
+ monday = when - dt.timedelta(days=when.weekday())
31
+ return _start_of_day(monday)
32
+
33
+
34
+ def _start_of_month(when: dt.datetime) -> dt.datetime:
35
+ return _start_of_day(when.replace(day=1))
36
+
37
+
38
+ def _end_of_month(when: dt.datetime) -> dt.datetime:
39
+ last_day = calendar.monthrange(when.year, when.month)[1]
40
+ return when.replace(day=last_day, hour=23, minute=59, second=59, microsecond=0)
41
+
42
+
43
+ def parse_interval(expression: str, now: dt.datetime) -> Interval:
44
+ """Parse a window expression relative to `now`. Returns an Interval [start, end).
45
+
46
+ Supported forms (case-insensitive):
47
+ - "today", "yesterday"
48
+ - "this week", "last week", "previous week"
49
+ - "this month", "last month", "previous month"
50
+ - "last N days", "previous N days"
51
+ - "last N hours"
52
+ - ISO date "YYYY-MM-DD" (24h spanning that local day)
53
+ - ISO range "YYYY-MM-DD..YYYY-MM-DD" (24h spans inclusive)
54
+ """
55
+ if not expression:
56
+ raise ValueError("window expression must be non-empty")
57
+ raw = expression.strip().lower()
58
+
59
+ if raw == "today":
60
+ start = _start_of_day(now)
61
+ return Interval(start=start, end=now, label="today")
62
+ if raw == "yesterday":
63
+ start = _start_of_day(now) - dt.timedelta(days=1)
64
+ end = _start_of_day(now)
65
+ return Interval(start=start, end=end, label="yesterday")
66
+ if raw == "this week":
67
+ return Interval(start=_start_of_week(now), end=now, label="this week")
68
+ if raw in {"last week", "previous week"}:
69
+ this_week = _start_of_week(now)
70
+ start = this_week - dt.timedelta(days=7)
71
+ return Interval(start=start, end=this_week, label="last week")
72
+ if raw == "this month":
73
+ return Interval(start=_start_of_month(now), end=now, label="this month")
74
+ if raw in {"last month", "previous month"}:
75
+ this_month = _start_of_month(now)
76
+ prev_end = this_month
77
+ prev_start = (this_month - dt.timedelta(days=1)).replace(
78
+ day=1, hour=0, minute=0, second=0, microsecond=0
79
+ )
80
+ return Interval(start=prev_start, end=prev_end, label="last month")
81
+
82
+ match = _LAST_N_DAYS.match(raw)
83
+ if match:
84
+ days = int(match.group(1))
85
+ return Interval(start=now - dt.timedelta(days=days), end=now, label=raw)
86
+
87
+ match = _PREVIOUS_N_DAYS.match(raw)
88
+ if match:
89
+ days = int(match.group(1))
90
+ anchor = now - dt.timedelta(days=days)
91
+ return Interval(start=anchor - dt.timedelta(days=days), end=anchor, label=raw)
92
+
93
+ match = _LAST_N_HOURS.match(raw)
94
+ if match:
95
+ hours = int(match.group(1))
96
+ return Interval(start=now - dt.timedelta(hours=hours), end=now, label=raw)
97
+
98
+ if ".." in raw:
99
+ left, _, right = raw.partition("..")
100
+ return Interval(
101
+ start=_iso_to_dt(left, now.tzinfo, end_of_day=False),
102
+ end=_iso_to_dt(right, now.tzinfo, end_of_day=True),
103
+ label=raw,
104
+ )
105
+
106
+ return Interval(
107
+ start=_iso_to_dt(raw, now.tzinfo, end_of_day=False),
108
+ end=_iso_to_dt(raw, now.tzinfo, end_of_day=True),
109
+ label=raw,
110
+ )
111
+
112
+
113
+ def _iso_to_dt(raw: str, tz: dt.tzinfo | None, *, end_of_day: bool) -> dt.datetime:
114
+ text = raw.strip()
115
+ if len(text) == 10 and text[4] == "-" and text[7] == "-":
116
+ text = text + (" 23:59:59" if end_of_day else " 00:00:00")
117
+ elif len(text) == 8 and text.isdigit():
118
+ head = f"{text[:4]}-{text[4:6]}-{text[6:8]}"
119
+ text = head + (" 23:59:59" if end_of_day else " 00:00:00")
120
+ try:
121
+ parsed = dt.datetime.fromisoformat(text)
122
+ except ValueError as exc:
123
+ raise ValueError(f"Unrecognized window expression: {raw!r}. {WINDOW_EXAMPLES}") from exc
124
+ if parsed.tzinfo is None:
125
+ parsed = parsed.replace(tzinfo=tz or dt.UTC)
126
+ return parsed
127
+
128
+
129
+ _MONTH_END_MIN = _end_of_month # re-export keeps `end_of_month` discoverable
codex_meter/live.py ADDED
@@ -0,0 +1,286 @@
1
+ """Live TUI for codex-meter. Re-parses JSONL on each tick (cache is Phase 3)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as dt
6
+ import select
7
+ import signal
8
+ import sys
9
+ import time
10
+ from dataclasses import dataclass
11
+
12
+ from rich.console import Console, RenderableType
13
+ from rich.layout import Layout
14
+ from rich.live import Live
15
+ from rich.panel import Panel
16
+ from rich.progress_bar import ProgressBar
17
+ from rich.table import Table
18
+ from rich.text import Text
19
+
20
+ from codex_meter.aggregation import aggregate_total
21
+ from codex_meter.models import LoadResult, RuntimeOptions
22
+ from codex_meter.parser import load_usage
23
+ from codex_meter.pricing import RateCard
24
+ from codex_meter.render import pricing_status, pricing_warnings
25
+ from codex_meter.timeutil import local_timezone
26
+ from codex_meter.windows import (
27
+ WindowState,
28
+ compute_window_state,
29
+ format_burn_rate,
30
+ format_seconds_remaining,
31
+ )
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class LiveFrame:
36
+ now: dt.datetime
37
+ today_credits: float
38
+ today_api_dollars: float
39
+ week_credits: float
40
+ primary: WindowState
41
+ secondary: WindowState
42
+ plan_types: tuple[str, ...]
43
+ events_loaded: int
44
+ today_cache_savings: float = 0.0
45
+ today_sparkline: str = ""
46
+ refresh_ms: float = 0.0
47
+ pricing_status: str = "exact"
48
+ pricing_warnings: tuple[str, ...] = ()
49
+
50
+
51
+ def _window_subset(events, start: dt.datetime, end: dt.datetime):
52
+ return [event for event in events if start <= event.timestamp < end]
53
+
54
+
55
+ def collect_frame(options: RuntimeOptions, now: dt.datetime | None = None) -> LiveFrame:
56
+ """Load usage + compute one frame snapshot. Pure-ish (depends on parser + clock)."""
57
+ started = time.perf_counter()
58
+ result = load_usage(options)
59
+ rate_card = RateCard.load(options.rates_file, options.pricing_mode)
60
+ now = now or dt.datetime.now(tz=local_timezone())
61
+ today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
62
+ week_start = now - dt.timedelta(days=7)
63
+
64
+ today_events = _window_subset(result.events, today_start, now)
65
+ week_events = _window_subset(result.events, week_start, now)
66
+
67
+ today_result = LoadResult(
68
+ events=today_events,
69
+ duplicates=0,
70
+ tier_sources=result.tier_sources,
71
+ plan_types=result.plan_types,
72
+ credit_samples=[],
73
+ warnings=[],
74
+ )
75
+ week_result = LoadResult(
76
+ events=week_events,
77
+ duplicates=0,
78
+ tier_sources=result.tier_sources,
79
+ plan_types=result.plan_types,
80
+ credit_samples=[],
81
+ warnings=[],
82
+ )
83
+
84
+ today_total = aggregate_total(today_result, options, label="Today", rate_card=rate_card)
85
+ week_total = aggregate_total(week_result, options, label="Last 7 days", rate_card=rate_card)
86
+
87
+ primary = compute_window_state(result.credit_samples, now, "primary")
88
+ secondary = compute_window_state(result.credit_samples, now, "secondary")
89
+
90
+ return LiveFrame(
91
+ now=now,
92
+ today_credits=float(today_total.costs.adjusted_credits),
93
+ today_api_dollars=float(today_total.costs.api_dollars),
94
+ week_credits=float(week_total.costs.adjusted_credits),
95
+ primary=primary,
96
+ secondary=secondary,
97
+ plan_types=tuple(sorted(result.plan_types)),
98
+ events_loaded=len(result.events),
99
+ today_cache_savings=float(today_total.cache_savings.api_dollars),
100
+ today_sparkline=_sparkline(_hourly_credit_series(today_events, rate_card, now)),
101
+ refresh_ms=(time.perf_counter() - started) * 1000,
102
+ pricing_status=pricing_status(today_total),
103
+ pricing_warnings=tuple(pricing_warnings(today_total)),
104
+ )
105
+
106
+
107
+ def _hourly_credit_series(events, rate_card: RateCard, now: dt.datetime) -> list[float]:
108
+ local_now = now.astimezone(local_timezone())
109
+ buckets = [0.0] * (local_now.hour + 1)
110
+ for event in events:
111
+ local_event = event.timestamp.astimezone(local_timezone())
112
+ if local_event.date() != local_now.date():
113
+ continue
114
+ costs, _, _ = rate_card.cost_for(event.usage, event.model, event.service_tier)
115
+ buckets[local_event.hour] += float(costs.adjusted_credits)
116
+ return buckets
117
+
118
+
119
+ def _window_panel(label: str, state: WindowState, *, compact: bool = False) -> Panel:
120
+ grid = Table.grid(padding=(0, 1))
121
+ grid.add_column(no_wrap=True, justify="right", style="dim")
122
+ grid.add_column()
123
+ bar = ProgressBar(total=100, completed=state.used_percent or 0, width=14 if compact else 24)
124
+ grid.add_row("Used", bar)
125
+ grid.add_row(
126
+ "Percent",
127
+ f"{state.used_percent:.1f}%" if state.used_percent is not None else "—",
128
+ )
129
+ grid.add_row("Reset in", format_seconds_remaining(state.seconds_remaining))
130
+ if state.reset_at is not None:
131
+ local = state.reset_at.astimezone(local_timezone())
132
+ grid.add_row("Reset at", local.strftime("%Y-%m-%d %H:%M:%S %Z"))
133
+ grid.add_row("Burn rate", format_burn_rate(state.burn_rate_per_hour))
134
+ if state.eta_to_100 is not None:
135
+ eta_local = state.eta_to_100.astimezone(local_timezone())
136
+ grid.add_row("Hits 100% by", eta_local.strftime("%Y-%m-%d %H:%M"))
137
+ title = label
138
+ if state.window_minutes:
139
+ title = f"{label} ({state.window_minutes}m)"
140
+ border = "red" if (state.used_percent or 0) >= 80 else "cyan"
141
+ return Panel(grid, title=title, border_style=border)
142
+
143
+
144
+ def _usage_panel(frame: LiveFrame) -> Panel:
145
+ grid = Table.grid(padding=(0, 1))
146
+ grid.add_column(no_wrap=True, justify="right", style="dim")
147
+ grid.add_column()
148
+ grid.add_row(
149
+ "Today",
150
+ f"{frame.today_credits:,.2f} credits / ${frame.today_api_dollars:,.2f}",
151
+ )
152
+ grid.add_row("Last 7d", f"{frame.week_credits:,.2f} credits")
153
+ grid.add_row("Events loaded", f"{frame.events_loaded:,}")
154
+ if frame.plan_types:
155
+ grid.add_row("Plan", ", ".join(frame.plan_types))
156
+ if frame.today_cache_savings:
157
+ grid.add_row("Cache saved", f"${frame.today_cache_savings:,.2f}")
158
+ if frame.pricing_status != "exact":
159
+ grid.add_row("Pricing", frame.pricing_status)
160
+ for warning in frame.pricing_warnings[:2]:
161
+ grid.add_row("Warning", warning)
162
+ if frame.today_sparkline:
163
+ grid.add_row("Today trend", frame.today_sparkline)
164
+ grid.add_row("Refresh", f"{frame.refresh_ms:,.0f} ms")
165
+ return Panel(grid, title="Usage", border_style="green")
166
+
167
+
168
+ def render_frame(
169
+ frame: LiveFrame,
170
+ show_help: bool = False,
171
+ paused: bool = False,
172
+ width: int | None = None,
173
+ ) -> RenderableType:
174
+ layout = Layout()
175
+ layout.split_column(
176
+ Layout(name="header", size=3),
177
+ Layout(name="body"),
178
+ )
179
+ header_text = Text(
180
+ f"Codex Meter — {frame.now.strftime('%Y-%m-%d %H:%M:%S %Z')} "
181
+ f"(q quit · ? help · r refresh · p {'resume' if paused else 'pause'})",
182
+ style="bold white on blue",
183
+ )
184
+ layout["header"].update(Panel(header_text, border_style="blue"))
185
+ body = Layout()
186
+ if show_help:
187
+ body.update(_help_panel())
188
+ elif (width or 120) < 110:
189
+ body.split_column(
190
+ Layout(_usage_panel(frame), name="usage", size=10),
191
+ Layout(_window_panel("Primary 5h", frame.primary, compact=True), name="primary"),
192
+ Layout(
193
+ _window_panel("Secondary weekly", frame.secondary, compact=True),
194
+ name="secondary",
195
+ ),
196
+ )
197
+ else:
198
+ body.split_row(
199
+ Layout(_usage_panel(frame), name="usage"),
200
+ Layout(_window_panel("Primary 5h", frame.primary), name="primary"),
201
+ Layout(_window_panel("Secondary weekly", frame.secondary), name="secondary"),
202
+ )
203
+ layout["body"].update(body)
204
+ return layout
205
+
206
+
207
+ def _help_panel() -> Panel:
208
+ grid = Table.grid(padding=(0, 2))
209
+ grid.add_column(style="bold")
210
+ grid.add_column()
211
+ grid.add_row("q", "quit")
212
+ grid.add_row("?", "toggle this help")
213
+ grid.add_row("r", "refresh immediately")
214
+ grid.add_row("p", "pause or resume auto-refresh")
215
+ grid.add_row("Ctrl-C", "quit")
216
+ return Panel(grid, title="Live Help", border_style="blue")
217
+
218
+
219
+ def _sparkline(values: list[float]) -> str:
220
+ if not values:
221
+ return ""
222
+ bars = "▁▂▃▄▅▆▇█"
223
+ low = min(values)
224
+ high = max(values)
225
+ if high == low:
226
+ return bars[0] * len(values)
227
+ return "".join(bars[round((value - low) / (high - low) * (len(bars) - 1))] for value in values)
228
+
229
+
230
+ def _read_key() -> str:
231
+ if not sys.stdin.isatty():
232
+ return ""
233
+ readable, _, _ = select.select([sys.stdin], [], [], 0)
234
+ if not readable:
235
+ return ""
236
+ return sys.stdin.read(1)
237
+
238
+
239
+ def run_live(
240
+ options: RuntimeOptions,
241
+ interval: float = 2.0,
242
+ console: Console | None = None,
243
+ max_ticks: int | None = None,
244
+ ) -> None:
245
+ """Start the TUI. Loops until SIGINT (or `max_ticks` for tests)."""
246
+ stop = {"flag": False}
247
+
248
+ def _on_signal(*_: object) -> None:
249
+ stop["flag"] = True
250
+
251
+ original = signal.getsignal(signal.SIGINT)
252
+ signal.signal(signal.SIGINT, _on_signal)
253
+ ticks = 0
254
+ show_help = False
255
+ paused = False
256
+ try:
257
+ target = console or Console()
258
+ frame = collect_frame(options)
259
+ width = target.size.width
260
+ with Live(
261
+ render_frame(frame, show_help=show_help, paused=paused, width=width),
262
+ console=target,
263
+ refresh_per_second=4,
264
+ screen=False,
265
+ ) as live:
266
+ while not stop["flag"]:
267
+ key = _read_key()
268
+ if key == "q":
269
+ break
270
+ if key == "?":
271
+ show_help = not show_help
272
+ if key == "p":
273
+ paused = not paused
274
+ if key == "r" and not paused:
275
+ frame = collect_frame(options)
276
+ time.sleep(interval)
277
+ ticks += 1
278
+ if max_ticks is not None and ticks >= max_ticks:
279
+ break
280
+ if stop["flag"]:
281
+ break
282
+ if not paused and not show_help:
283
+ frame = collect_frame(options)
284
+ live.update(render_frame(frame, show_help=show_help, paused=paused, width=width))
285
+ finally:
286
+ signal.signal(signal.SIGINT, original)