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.
- codex_meter/__init__.py +12 -0
- codex_meter/__main__.py +4 -0
- codex_meter/aggregation.py +127 -0
- codex_meter/budgets.py +133 -0
- codex_meter/cli.py +2455 -0
- codex_meter/config.py +164 -0
- codex_meter/exporters.py +339 -0
- codex_meter/forecasts.py +92 -0
- codex_meter/humanize.py +38 -0
- codex_meter/insights.py +132 -0
- codex_meter/intervals.py +129 -0
- codex_meter/live.py +286 -0
- codex_meter/models.py +282 -0
- codex_meter/parse_cache.py +189 -0
- codex_meter/parser.py +498 -0
- codex_meter/pricing.py +311 -0
- codex_meter/prom_export.py +116 -0
- codex_meter/py.typed +0 -0
- codex_meter/render.py +545 -0
- codex_meter/timeutil.py +75 -0
- codex_meter/windows.py +153 -0
- codex_meter-0.3.0.dist-info/METADATA +304 -0
- codex_meter-0.3.0.dist-info/RECORD +26 -0
- codex_meter-0.3.0.dist-info/WHEEL +4 -0
- codex_meter-0.3.0.dist-info/entry_points.txt +2 -0
- codex_meter-0.3.0.dist-info/licenses/LICENSE +21 -0
codex_meter/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Codex Meter — offline-first Codex usage analytics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
__version__ = version("codex-meter")
|
|
9
|
+
except PackageNotFoundError:
|
|
10
|
+
__version__ = "0.0.0+local"
|
|
11
|
+
|
|
12
|
+
__all__ = ["__version__"]
|
codex_meter/__main__.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
|
|
5
|
+
from codex_meter.models import Aggregate, LoadResult, RuntimeOptions, UsageEvent
|
|
6
|
+
from codex_meter.pricing import RateCard
|
|
7
|
+
from codex_meter.timeutil import day_key, load_timezone, month_key, week_key
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def aggregate_events(
|
|
11
|
+
events: list[UsageEvent],
|
|
12
|
+
key_fn: Callable[[UsageEvent], tuple[str, str]],
|
|
13
|
+
options: RuntimeOptions,
|
|
14
|
+
rate_card: RateCard | None = None,
|
|
15
|
+
) -> list[Aggregate]:
|
|
16
|
+
card = rate_card or RateCard.load(options.rates_file, options.pricing_mode)
|
|
17
|
+
aggregates: dict[str, Aggregate] = {}
|
|
18
|
+
for event in events:
|
|
19
|
+
key, label = key_fn(event)
|
|
20
|
+
item = aggregates.setdefault(key, Aggregate(key=key, label=label))
|
|
21
|
+
costs, long_context, unknown_model = card.cost_for(
|
|
22
|
+
event.usage, event.model, event.service_tier
|
|
23
|
+
)
|
|
24
|
+
cache_savings = card.cache_savings_for(event.usage, event.model, event.service_tier)
|
|
25
|
+
unknown_tier = event.tier_source in {"current-config", "assumed"}
|
|
26
|
+
item.add_event(event, costs, cache_savings, long_context, unknown_model, unknown_tier)
|
|
27
|
+
return sorted(aggregates.values(), key=lambda item: item.key)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def aggregate_total(
|
|
31
|
+
result: LoadResult,
|
|
32
|
+
options: RuntimeOptions,
|
|
33
|
+
label: str = "Total",
|
|
34
|
+
rate_card: RateCard | None = None,
|
|
35
|
+
) -> Aggregate:
|
|
36
|
+
items = aggregate_events(
|
|
37
|
+
result.events, lambda _event: ("total", label), options, rate_card=rate_card
|
|
38
|
+
)
|
|
39
|
+
return items[0] if items else Aggregate(key="total", label=label)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def aggregate_daily(
|
|
43
|
+
result: LoadResult, options: RuntimeOptions, rate_card: RateCard | None = None
|
|
44
|
+
) -> list[Aggregate]:
|
|
45
|
+
tz = load_timezone(options.timezone)
|
|
46
|
+
return aggregate_events(
|
|
47
|
+
result.events,
|
|
48
|
+
lambda event: (day_key(event.timestamp, tz), day_key(event.timestamp, tz)),
|
|
49
|
+
options,
|
|
50
|
+
rate_card=rate_card,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def aggregate_weekly(
|
|
55
|
+
result: LoadResult, options: RuntimeOptions, rate_card: RateCard | None = None
|
|
56
|
+
) -> list[Aggregate]:
|
|
57
|
+
tz = load_timezone(options.timezone)
|
|
58
|
+
return aggregate_events(
|
|
59
|
+
result.events,
|
|
60
|
+
lambda event: (week_key(event.timestamp, tz), week_key(event.timestamp, tz)),
|
|
61
|
+
options,
|
|
62
|
+
rate_card=rate_card,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def aggregate_monthly(
|
|
67
|
+
result: LoadResult, options: RuntimeOptions, rate_card: RateCard | None = None
|
|
68
|
+
) -> list[Aggregate]:
|
|
69
|
+
tz = load_timezone(options.timezone)
|
|
70
|
+
return aggregate_events(
|
|
71
|
+
result.events,
|
|
72
|
+
lambda event: (month_key(event.timestamp, tz), month_key(event.timestamp, tz)),
|
|
73
|
+
options,
|
|
74
|
+
rate_card=rate_card,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def aggregate_sessions(
|
|
79
|
+
result: LoadResult, options: RuntimeOptions, rate_card: RateCard | None = None
|
|
80
|
+
) -> list[Aggregate]:
|
|
81
|
+
def key(event: UsageEvent) -> tuple[str, str]:
|
|
82
|
+
local_time = event.timestamp.astimezone(load_timezone(options.timezone)).strftime(
|
|
83
|
+
"%Y-%m-%d %H:%M"
|
|
84
|
+
)
|
|
85
|
+
if options.show_prompts:
|
|
86
|
+
title = event.thread.title or event.thread.first_user_message or event.session_id
|
|
87
|
+
else:
|
|
88
|
+
title = event.session_id
|
|
89
|
+
return event.session_id, f"{local_time} | {title}"
|
|
90
|
+
|
|
91
|
+
return sorted(
|
|
92
|
+
aggregate_events(result.events, key, options, rate_card=rate_card),
|
|
93
|
+
key=lambda item: item.costs.adjusted_credits,
|
|
94
|
+
reverse=True,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def aggregate_projects(
|
|
99
|
+
result: LoadResult, options: RuntimeOptions, rate_card: RateCard | None = None
|
|
100
|
+
) -> list[Aggregate]:
|
|
101
|
+
def key(event: UsageEvent) -> tuple[str, str]:
|
|
102
|
+
project = event.thread.cwd or "Unknown Project"
|
|
103
|
+
return project, project
|
|
104
|
+
|
|
105
|
+
return sorted(
|
|
106
|
+
aggregate_events(result.events, key, options, rate_card=rate_card),
|
|
107
|
+
key=lambda item: item.costs.adjusted_credits,
|
|
108
|
+
reverse=True,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def aggregate_model_mode(
|
|
113
|
+
result: LoadResult, options: RuntimeOptions, rate_card: RateCard | None = None
|
|
114
|
+
) -> list[Aggregate]:
|
|
115
|
+
return sorted(
|
|
116
|
+
aggregate_events(
|
|
117
|
+
result.events,
|
|
118
|
+
lambda event: (
|
|
119
|
+
f"{event.model}\0{event.service_tier}",
|
|
120
|
+
f"{event.model or 'unknown model'} / {event.service_tier or 'unknown tier'}",
|
|
121
|
+
),
|
|
122
|
+
options,
|
|
123
|
+
rate_card=rate_card,
|
|
124
|
+
),
|
|
125
|
+
key=lambda item: item.costs.adjusted_credits,
|
|
126
|
+
reverse=True,
|
|
127
|
+
)
|
codex_meter/budgets.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Budgets and threshold-based alerts.
|
|
2
|
+
|
|
3
|
+
Pure-function evaluation. Reads `.codex-meter.toml` `[budgets]` tables via the
|
|
4
|
+
CLI layer; this module just takes Budget dataclasses + a usage dict and emits
|
|
5
|
+
BudgetAlert results.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
VALID_PERIODS = ("daily", "weekly", "monthly")
|
|
13
|
+
VALID_METRICS = ("credits", "api_dollars", "tokens")
|
|
14
|
+
SEVERITY_OK = "ok"
|
|
15
|
+
SEVERITY_WARN = "warn"
|
|
16
|
+
SEVERITY_BREACH = "breach"
|
|
17
|
+
SEVERITY_ORDER = (SEVERITY_OK, SEVERITY_WARN, SEVERITY_BREACH)
|
|
18
|
+
SEVERITY_EXIT_CODE = {SEVERITY_OK: 0, SEVERITY_WARN: 1, SEVERITY_BREACH: 2}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Budget:
|
|
23
|
+
period: str
|
|
24
|
+
metric: str
|
|
25
|
+
limit: float
|
|
26
|
+
warn_at: float = 0.8
|
|
27
|
+
|
|
28
|
+
def key(self) -> str:
|
|
29
|
+
return f"{self.period}.{self.metric}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class BudgetAlert:
|
|
34
|
+
budget: Budget
|
|
35
|
+
used: float
|
|
36
|
+
used_percent: float
|
|
37
|
+
severity: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def severity_for(used_percent: float, warn_at_percent: float) -> str:
|
|
41
|
+
if used_percent >= 100.0:
|
|
42
|
+
return SEVERITY_BREACH
|
|
43
|
+
if used_percent >= warn_at_percent * 100.0:
|
|
44
|
+
return SEVERITY_WARN
|
|
45
|
+
return SEVERITY_OK
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def evaluate(budgets: list[Budget], usage: dict[str, float]) -> list[BudgetAlert]:
|
|
49
|
+
"""Score each budget against the matching usage value. Missing usage → 0."""
|
|
50
|
+
alerts: list[BudgetAlert] = []
|
|
51
|
+
for budget in budgets:
|
|
52
|
+
used = float(usage.get(budget.key(), 0.0))
|
|
53
|
+
used_percent = 0.0 if budget.limit <= 0 else used / budget.limit * 100.0
|
|
54
|
+
alerts.append(
|
|
55
|
+
BudgetAlert(
|
|
56
|
+
budget=budget,
|
|
57
|
+
used=used,
|
|
58
|
+
used_percent=used_percent,
|
|
59
|
+
severity=severity_for(used_percent, budget.warn_at),
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
return alerts
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def max_severity(alerts: list[BudgetAlert]) -> str:
|
|
66
|
+
if not alerts:
|
|
67
|
+
return SEVERITY_OK
|
|
68
|
+
worst = SEVERITY_OK
|
|
69
|
+
for alert in alerts:
|
|
70
|
+
if SEVERITY_ORDER.index(alert.severity) > SEVERITY_ORDER.index(worst):
|
|
71
|
+
worst = alert.severity
|
|
72
|
+
return worst
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def parse_budgets_table(table: dict) -> list[Budget]:
|
|
76
|
+
"""Convert a parsed TOML `[budgets]` table into a list of Budget dataclasses.
|
|
77
|
+
|
|
78
|
+
Recognized shapes:
|
|
79
|
+
- {"daily_credits": 25000, "weekly_dollars": 12.50}
|
|
80
|
+
- {"daily": {"credits": 25000, "api_dollars": 5.0, "warn_at": 0.9}}
|
|
81
|
+
- {"items": [{"period": "daily", "metric": "credits", "limit": 25000}]}
|
|
82
|
+
"""
|
|
83
|
+
if not table:
|
|
84
|
+
return []
|
|
85
|
+
|
|
86
|
+
if "items" in table and isinstance(table["items"], list):
|
|
87
|
+
return [_budget_from_dict(item) for item in table["items"]]
|
|
88
|
+
|
|
89
|
+
budgets: list[Budget] = []
|
|
90
|
+
for key, value in table.items():
|
|
91
|
+
if isinstance(value, dict) and key in VALID_PERIODS:
|
|
92
|
+
warn_at = float(value.get("warn_at", 0.8))
|
|
93
|
+
for metric, limit in value.items():
|
|
94
|
+
if metric == "warn_at":
|
|
95
|
+
continue
|
|
96
|
+
if metric in VALID_METRICS:
|
|
97
|
+
budgets.append(
|
|
98
|
+
Budget(
|
|
99
|
+
period=key,
|
|
100
|
+
metric=metric,
|
|
101
|
+
limit=float(limit),
|
|
102
|
+
warn_at=warn_at,
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
continue
|
|
106
|
+
if not isinstance(value, (int, float)):
|
|
107
|
+
continue
|
|
108
|
+
period, metric = _split_flat_key(key)
|
|
109
|
+
if period and metric:
|
|
110
|
+
budgets.append(Budget(period=period, metric=metric, limit=float(value)))
|
|
111
|
+
return budgets
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _split_flat_key(key: str) -> tuple[str, str]:
|
|
115
|
+
parts = key.split("_", 1)
|
|
116
|
+
if len(parts) != 2:
|
|
117
|
+
return "", ""
|
|
118
|
+
period, metric = parts
|
|
119
|
+
if period not in VALID_PERIODS or metric not in VALID_METRICS:
|
|
120
|
+
return "", ""
|
|
121
|
+
return period, metric
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _budget_from_dict(raw: dict) -> Budget:
|
|
125
|
+
period = str(raw["period"])
|
|
126
|
+
metric = str(raw["metric"])
|
|
127
|
+
limit = float(raw["limit"])
|
|
128
|
+
warn_at = float(raw.get("warn_at", 0.8))
|
|
129
|
+
if period not in VALID_PERIODS:
|
|
130
|
+
raise ValueError(f"unknown budget period {period!r}; expected {VALID_PERIODS}")
|
|
131
|
+
if metric not in VALID_METRICS:
|
|
132
|
+
raise ValueError(f"unknown budget metric {metric!r}; expected {VALID_METRICS}")
|
|
133
|
+
return Budget(period=period, metric=metric, limit=limit, warn_at=warn_at)
|