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,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__"]
@@ -0,0 +1,4 @@
1
+ from codex_meter.cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
@@ -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)