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/config.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import datetime as dt
|
|
4
|
+
import tomllib
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from codex_meter.models import RuntimeOptions
|
|
8
|
+
from codex_meter.pricing import normalize_service_tier
|
|
9
|
+
from codex_meter.timeutil import load_timezone, local_timezone, parse_datetime
|
|
10
|
+
|
|
11
|
+
DEFAULT_SESSION_ROOT = Path.home() / ".codex" / "sessions"
|
|
12
|
+
DEFAULT_STATE_DB = Path.home() / ".codex" / "state_5.sqlite"
|
|
13
|
+
DEFAULT_CODEX_CONFIG = Path.home() / ".codex" / "config.toml"
|
|
14
|
+
USER_CONFIG = Path.home() / ".config" / "codex-meter" / "config.toml"
|
|
15
|
+
LOCAL_CONFIG = Path(".codex-meter.toml")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load_config(explicit_path: Path | None = None) -> dict:
|
|
19
|
+
paths = [USER_CONFIG, LOCAL_CONFIG]
|
|
20
|
+
if explicit_path:
|
|
21
|
+
paths.append(explicit_path)
|
|
22
|
+
loaded: dict = {}
|
|
23
|
+
for path in paths:
|
|
24
|
+
if path is None:
|
|
25
|
+
continue
|
|
26
|
+
expanded = path.expanduser()
|
|
27
|
+
if not expanded.exists():
|
|
28
|
+
continue
|
|
29
|
+
try:
|
|
30
|
+
loaded |= tomllib.loads(expanded.read_text())
|
|
31
|
+
except (OSError, tomllib.TOMLDecodeError) as exc:
|
|
32
|
+
raise ValueError(f"Could not load config {expanded}: {exc}") from exc
|
|
33
|
+
return loaded
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def cfg(config: dict, key: str, value, default):
|
|
37
|
+
if value != default:
|
|
38
|
+
return value
|
|
39
|
+
return config.get(key, default)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def cfg_path(config: dict, key: str, value: Path | None, default: Path) -> Path:
|
|
43
|
+
if value is not None:
|
|
44
|
+
return Path(value).expanduser()
|
|
45
|
+
return Path(config.get(key, default)).expanduser()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def cfg_optional_path(config: dict, key: str, value: Path | None) -> Path | None:
|
|
49
|
+
if value is not None:
|
|
50
|
+
return Path(value).expanduser()
|
|
51
|
+
configured = config.get(key)
|
|
52
|
+
return Path(configured).expanduser() if configured else None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def cfg_bool(config: dict, key: str, value: bool, default: bool) -> bool:
|
|
56
|
+
if value != default:
|
|
57
|
+
return bool(value)
|
|
58
|
+
return bool(config.get(key, default))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def validate_choice(name: str, value: str, allowed: set[str]) -> str:
|
|
62
|
+
if value not in allowed:
|
|
63
|
+
choices = ", ".join(sorted(allowed))
|
|
64
|
+
raise ValueError(f"{name} must be one of: {choices}")
|
|
65
|
+
return value
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def build_options(
|
|
69
|
+
*,
|
|
70
|
+
since: str | None = None,
|
|
71
|
+
until: str | None = None,
|
|
72
|
+
days: float | None = None,
|
|
73
|
+
timezone: str = "local",
|
|
74
|
+
session_root: Path | None = None,
|
|
75
|
+
state_db: Path | None = None,
|
|
76
|
+
codex_config: Path | None = None,
|
|
77
|
+
config: Path | None = None,
|
|
78
|
+
pricing_mode: str = "model",
|
|
79
|
+
service_tier: str = "auto",
|
|
80
|
+
unknown_service_tier: str = "current-config",
|
|
81
|
+
tier_overrides: Path | None = None,
|
|
82
|
+
rates_file: Path | None = None,
|
|
83
|
+
no_dedupe: bool = False,
|
|
84
|
+
no_parse_cache: bool = False,
|
|
85
|
+
default_model: str = "gpt-5.5",
|
|
86
|
+
show_prompts: bool = False,
|
|
87
|
+
offline: bool = True,
|
|
88
|
+
compact: bool = False,
|
|
89
|
+
width: int | None = None,
|
|
90
|
+
top_threads: int = 10,
|
|
91
|
+
) -> RuntimeOptions:
|
|
92
|
+
loaded = load_config(config)
|
|
93
|
+
end = parse_datetime(until, dt.datetime.now(tz=local_timezone()))
|
|
94
|
+
if since:
|
|
95
|
+
start = parse_datetime(since)
|
|
96
|
+
elif days is not None:
|
|
97
|
+
if days <= 0:
|
|
98
|
+
raise ValueError("--days must be greater than 0")
|
|
99
|
+
start = end - dt.timedelta(days=days)
|
|
100
|
+
else:
|
|
101
|
+
default_days = float(loaded.get("default_days", 30))
|
|
102
|
+
if default_days <= 0:
|
|
103
|
+
raise ValueError("default_days must be greater than 0")
|
|
104
|
+
start = end - dt.timedelta(days=default_days)
|
|
105
|
+
if start >= end:
|
|
106
|
+
raise ValueError("--since/--start must be before --until/--end")
|
|
107
|
+
|
|
108
|
+
timezone_value = str(cfg(loaded, "timezone", timezone, "local"))
|
|
109
|
+
load_timezone(timezone_value)
|
|
110
|
+
pricing_mode_value = validate_choice(
|
|
111
|
+
"--pricing-mode",
|
|
112
|
+
str(cfg(loaded, "pricing_mode", pricing_mode, "model")),
|
|
113
|
+
{"flat", "model"},
|
|
114
|
+
)
|
|
115
|
+
service_tier_value = str(cfg(loaded, "service_tier", service_tier, "auto"))
|
|
116
|
+
if service_tier_value != "auto":
|
|
117
|
+
service_tier_value = normalize_service_tier(service_tier_value)
|
|
118
|
+
service_tier_value = validate_choice(
|
|
119
|
+
"--service-tier",
|
|
120
|
+
service_tier_value,
|
|
121
|
+
{"auto", "fast", "standard"},
|
|
122
|
+
)
|
|
123
|
+
unknown_service_tier_value = str(
|
|
124
|
+
cfg(loaded, "unknown_service_tier", unknown_service_tier, "current-config")
|
|
125
|
+
)
|
|
126
|
+
if unknown_service_tier_value != "current-config":
|
|
127
|
+
unknown_service_tier_value = normalize_service_tier(unknown_service_tier_value)
|
|
128
|
+
unknown_service_tier_value = validate_choice(
|
|
129
|
+
"--unknown-service-tier",
|
|
130
|
+
unknown_service_tier_value,
|
|
131
|
+
{"current-config", "fast", "standard"},
|
|
132
|
+
)
|
|
133
|
+
top_threads_value = int(cfg(loaded, "top_threads", top_threads, 10))
|
|
134
|
+
if top_threads_value <= 0:
|
|
135
|
+
raise ValueError("--top-threads must be greater than 0")
|
|
136
|
+
width_value = cfg(loaded, "width", width, None)
|
|
137
|
+
if width_value is not None:
|
|
138
|
+
width_value = int(width_value)
|
|
139
|
+
if width_value <= 0:
|
|
140
|
+
raise ValueError("--width must be greater than 0")
|
|
141
|
+
no_dedupe_value = cfg_bool(loaded, "no_dedupe", no_dedupe, False)
|
|
142
|
+
no_parse_cache_value = cfg_bool(loaded, "no_parse_cache", no_parse_cache, False)
|
|
143
|
+
|
|
144
|
+
return RuntimeOptions(
|
|
145
|
+
session_root=cfg_path(loaded, "session_root", session_root, DEFAULT_SESSION_ROOT),
|
|
146
|
+
state_db=cfg_path(loaded, "state_db", state_db, DEFAULT_STATE_DB),
|
|
147
|
+
config_path=cfg_path(loaded, "codex_config", codex_config, DEFAULT_CODEX_CONFIG),
|
|
148
|
+
start=start,
|
|
149
|
+
end=end,
|
|
150
|
+
timezone=timezone_value,
|
|
151
|
+
pricing_mode=pricing_mode_value,
|
|
152
|
+
service_tier=service_tier_value,
|
|
153
|
+
unknown_service_tier=unknown_service_tier_value,
|
|
154
|
+
tier_overrides=cfg_optional_path(loaded, "tier_overrides", tier_overrides),
|
|
155
|
+
rates_file=cfg_optional_path(loaded, "rates_file", rates_file),
|
|
156
|
+
dedupe=not no_dedupe_value,
|
|
157
|
+
parse_cache=not no_parse_cache_value,
|
|
158
|
+
default_model=str(cfg(loaded, "default_model", default_model, "gpt-5.5")),
|
|
159
|
+
show_prompts=cfg_bool(loaded, "show_prompts", show_prompts, False),
|
|
160
|
+
offline=cfg_bool(loaded, "offline", offline, True),
|
|
161
|
+
compact=cfg_bool(loaded, "compact", compact, False),
|
|
162
|
+
width=width_value,
|
|
163
|
+
top_threads=top_threads_value,
|
|
164
|
+
)
|
codex_meter/exporters.py
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""Export formats: monthly receipts (markdown/html) and Grafana dashboard JSON."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as dt
|
|
6
|
+
import html
|
|
7
|
+
import json
|
|
8
|
+
from collections.abc import Iterable
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
from codex_meter.models import Aggregate
|
|
12
|
+
|
|
13
|
+
DEFAULT_DASHBOARD_TITLE = "Codex Meter"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class ReceiptInputs:
|
|
18
|
+
month: str # YYYY-MM
|
|
19
|
+
totals: Aggregate
|
|
20
|
+
by_model: list[Aggregate]
|
|
21
|
+
top_sessions: list[Aggregate]
|
|
22
|
+
top_projects: list[Aggregate]
|
|
23
|
+
generated_at: dt.datetime
|
|
24
|
+
tier_sources: dict[str, int] | None = None
|
|
25
|
+
insights: list[str] | None = None
|
|
26
|
+
warning_count: int = 0
|
|
27
|
+
pricing_status: str = "exact"
|
|
28
|
+
pricing_warnings: list[str] | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def month_bounds(month: str, tz: dt.tzinfo) -> tuple[dt.datetime, dt.datetime]:
|
|
32
|
+
"""Parse 'YYYY-MM' into [start, end) local-tz datetimes."""
|
|
33
|
+
try:
|
|
34
|
+
year, month_num = month.split("-")
|
|
35
|
+
year_int = int(year)
|
|
36
|
+
month_int = int(month_num)
|
|
37
|
+
except ValueError as exc:
|
|
38
|
+
raise ValueError(f"Invalid --month {month!r}; expected YYYY-MM") from exc
|
|
39
|
+
if not 1 <= month_int <= 12:
|
|
40
|
+
raise ValueError(f"Month must be 1..12, got {month_int}")
|
|
41
|
+
start = dt.datetime(year_int, month_int, 1, tzinfo=tz)
|
|
42
|
+
if month_int == 12:
|
|
43
|
+
end = dt.datetime(year_int + 1, 1, 1, tzinfo=tz)
|
|
44
|
+
else:
|
|
45
|
+
end = dt.datetime(year_int, month_int + 1, 1, tzinfo=tz)
|
|
46
|
+
return start, end
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _format_money(value: float) -> str:
|
|
50
|
+
return f"${value:,.2f}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _format_credits(value: float) -> str:
|
|
54
|
+
return f"{value:,.2f}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _format_int(value: int) -> str:
|
|
58
|
+
return f"{value:,}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def render_receipt_markdown(payload: ReceiptInputs) -> str:
|
|
62
|
+
lines: list[str] = []
|
|
63
|
+
lines.append(f"# Codex Meter Receipt — {payload.month}")
|
|
64
|
+
lines.append("")
|
|
65
|
+
lines.append(f"Generated: {payload.generated_at.isoformat()}")
|
|
66
|
+
lines.append("")
|
|
67
|
+
lines.append("## Totals")
|
|
68
|
+
lines.append("")
|
|
69
|
+
lines.append("| Metric | Value |")
|
|
70
|
+
lines.append("| --- | ---: |")
|
|
71
|
+
lines.append(f"| Credits | {_format_credits(payload.totals.costs.adjusted_credits)} |")
|
|
72
|
+
lines.append(f"| API $ | {_format_money(payload.totals.costs.api_dollars)} |")
|
|
73
|
+
cache_savings = _format_credits(payload.totals.cache_savings.adjusted_credits)
|
|
74
|
+
lines.append(
|
|
75
|
+
f"| Cache savings | {cache_savings} credits / "
|
|
76
|
+
f"{_format_money(payload.totals.cache_savings.api_dollars)} |"
|
|
77
|
+
)
|
|
78
|
+
lines.append(f"| Events | {_format_int(payload.totals.totals.events)} |")
|
|
79
|
+
lines.append(f"| Tokens | {_format_int(payload.totals.totals.total_tokens)} |")
|
|
80
|
+
lines.append(
|
|
81
|
+
f"| Input tokens | {_format_int(payload.totals.totals.input_tokens)} (cached "
|
|
82
|
+
f"{_format_int(payload.totals.totals.cached_input_tokens)}) |"
|
|
83
|
+
)
|
|
84
|
+
lines.append(f"| Output tokens | {_format_int(payload.totals.totals.output_tokens)} |")
|
|
85
|
+
lines.append(
|
|
86
|
+
f"| Reasoning tokens | {_format_int(payload.totals.totals.reasoning_output_tokens)} |"
|
|
87
|
+
)
|
|
88
|
+
if payload.tier_sources:
|
|
89
|
+
sources = ", ".join(
|
|
90
|
+
f"{key}={value:,}" for key, value in sorted(payload.tier_sources.items())
|
|
91
|
+
)
|
|
92
|
+
lines.append(f"| Tier sources | {sources} |")
|
|
93
|
+
if payload.warning_count:
|
|
94
|
+
lines.append(f"| Parser warnings | {_format_int(payload.warning_count)} |")
|
|
95
|
+
if payload.pricing_status != "exact" or payload.pricing_warnings:
|
|
96
|
+
warnings = "; ".join(payload.pricing_warnings or []) or payload.pricing_status
|
|
97
|
+
lines.append(f"| Pricing status | {payload.pricing_status}: {warnings} |")
|
|
98
|
+
lines.append("")
|
|
99
|
+
if payload.insights:
|
|
100
|
+
lines.append("## Insights")
|
|
101
|
+
lines.append("")
|
|
102
|
+
for insight in payload.insights:
|
|
103
|
+
lines.append(f"- {insight}")
|
|
104
|
+
lines.append("")
|
|
105
|
+
lines.extend(_section_table("Models", payload.by_model))
|
|
106
|
+
lines.extend(_section_table("Top sessions", payload.top_sessions))
|
|
107
|
+
lines.extend(_section_table("Top projects", payload.top_projects))
|
|
108
|
+
return "\n".join(lines) + "\n"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _section_table(title: str, rows: Iterable[Aggregate]) -> list[str]:
|
|
112
|
+
rendered = list(rows)
|
|
113
|
+
if not rendered:
|
|
114
|
+
return [f"## {title}", "", "_No data for this month._", ""]
|
|
115
|
+
lines: list[str] = [f"## {title}", ""]
|
|
116
|
+
lines.append("| Label | Credits | API $ | Events | Tokens |")
|
|
117
|
+
lines.append("| --- | ---: | ---: | ---: | ---: |")
|
|
118
|
+
for row in rendered:
|
|
119
|
+
lines.append(
|
|
120
|
+
"| "
|
|
121
|
+
+ " | ".join(
|
|
122
|
+
[
|
|
123
|
+
row.label.replace("|", "\\|"),
|
|
124
|
+
_format_credits(row.costs.adjusted_credits),
|
|
125
|
+
_format_money(row.costs.api_dollars),
|
|
126
|
+
_format_int(row.totals.events),
|
|
127
|
+
_format_int(row.totals.total_tokens),
|
|
128
|
+
]
|
|
129
|
+
)
|
|
130
|
+
+ " |"
|
|
131
|
+
)
|
|
132
|
+
lines.append("")
|
|
133
|
+
return lines
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def render_receipt_html(payload: ReceiptInputs) -> str:
|
|
137
|
+
sections: list[str] = []
|
|
138
|
+
sections.append(f"<h1>Codex Meter Receipt — {html.escape(payload.month)}</h1>")
|
|
139
|
+
sections.append(f"<p><em>Generated: {payload.generated_at.isoformat()}</em></p>")
|
|
140
|
+
sections.append("<h2>Totals</h2>")
|
|
141
|
+
sections.append(
|
|
142
|
+
"<table><thead><tr><th>Metric</th><th>Value</th></tr></thead><tbody>"
|
|
143
|
+
+ "".join(
|
|
144
|
+
f"<tr><td>{name}</td><td>{value}</td></tr>"
|
|
145
|
+
for name, value in (
|
|
146
|
+
("Credits", _format_credits(payload.totals.costs.adjusted_credits)),
|
|
147
|
+
("API $", _format_money(payload.totals.costs.api_dollars)),
|
|
148
|
+
(
|
|
149
|
+
"Cache savings",
|
|
150
|
+
f"{_format_credits(payload.totals.cache_savings.adjusted_credits)} credits / "
|
|
151
|
+
f"{_format_money(payload.totals.cache_savings.api_dollars)}",
|
|
152
|
+
),
|
|
153
|
+
("Events", _format_int(payload.totals.totals.events)),
|
|
154
|
+
("Tokens", _format_int(payload.totals.totals.total_tokens)),
|
|
155
|
+
("Input tokens", _format_int(payload.totals.totals.input_tokens)),
|
|
156
|
+
("Cached input", _format_int(payload.totals.totals.cached_input_tokens)),
|
|
157
|
+
("Output tokens", _format_int(payload.totals.totals.output_tokens)),
|
|
158
|
+
("Reasoning tokens", _format_int(payload.totals.totals.reasoning_output_tokens)),
|
|
159
|
+
(
|
|
160
|
+
"Tier sources",
|
|
161
|
+
", ".join(
|
|
162
|
+
f"{key}={value:,}"
|
|
163
|
+
for key, value in sorted((payload.tier_sources or {}).items())
|
|
164
|
+
)
|
|
165
|
+
or "—",
|
|
166
|
+
),
|
|
167
|
+
("Parser warnings", _format_int(payload.warning_count)),
|
|
168
|
+
(
|
|
169
|
+
"Pricing status",
|
|
170
|
+
(f"{payload.pricing_status}: {'; '.join(payload.pricing_warnings or [])}")
|
|
171
|
+
if payload.pricing_status != "exact" or payload.pricing_warnings
|
|
172
|
+
else "exact",
|
|
173
|
+
),
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
+ "</tbody></table>"
|
|
177
|
+
)
|
|
178
|
+
if payload.insights:
|
|
179
|
+
sections.append(
|
|
180
|
+
"<h2>Insights</h2><ul>"
|
|
181
|
+
+ "".join(f"<li>{html.escape(item)}</li>" for item in payload.insights)
|
|
182
|
+
+ "</ul>"
|
|
183
|
+
)
|
|
184
|
+
sections.append(_html_section("Models", payload.by_model))
|
|
185
|
+
sections.append(_html_section("Top sessions", payload.top_sessions))
|
|
186
|
+
sections.append(_html_section("Top projects", payload.top_projects))
|
|
187
|
+
body = "\n".join(sections)
|
|
188
|
+
return (
|
|
189
|
+
'<!doctype html><html><head><meta charset="utf-8">'
|
|
190
|
+
f"<title>Codex Meter Receipt — {html.escape(payload.month)}</title>"
|
|
191
|
+
"<style>"
|
|
192
|
+
"body{font-family:system-ui,sans-serif;max-width:920px;margin:2rem auto;padding:0 1rem;}"
|
|
193
|
+
"table{border-collapse:collapse;width:100%;margin-bottom:1.5rem;}"
|
|
194
|
+
"th,td{border:1px solid #ddd;padding:.4rem .6rem;}"
|
|
195
|
+
"th{background:#f5f5f5;text-align:left;}"
|
|
196
|
+
"td:last-child,th:last-child{text-align:right;}"
|
|
197
|
+
"</style></head><body>"
|
|
198
|
+
f"{body}"
|
|
199
|
+
"</body></html>"
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _html_section(title: str, rows: Iterable[Aggregate]) -> str:
|
|
204
|
+
rendered = list(rows)
|
|
205
|
+
if not rendered:
|
|
206
|
+
return f"<h2>{html.escape(title)}</h2><p><em>No data for this month.</em></p>"
|
|
207
|
+
body = "".join(
|
|
208
|
+
"<tr>"
|
|
209
|
+
f"<td>{html.escape(row.label)}</td>"
|
|
210
|
+
f"<td>{_format_credits(row.costs.adjusted_credits)}</td>"
|
|
211
|
+
f"<td>{_format_money(row.costs.api_dollars)}</td>"
|
|
212
|
+
f"<td>{_format_int(row.totals.events)}</td>"
|
|
213
|
+
f"<td>{_format_int(row.totals.total_tokens)}</td>"
|
|
214
|
+
"</tr>"
|
|
215
|
+
for row in rendered
|
|
216
|
+
)
|
|
217
|
+
return (
|
|
218
|
+
f"<h2>{html.escape(title)}</h2>"
|
|
219
|
+
"<table><thead><tr>"
|
|
220
|
+
"<th>Label</th><th>Credits</th><th>API $</th><th>Events</th><th>Tokens</th>"
|
|
221
|
+
"</tr></thead><tbody>"
|
|
222
|
+
f"{body}"
|
|
223
|
+
"</tbody></table>"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def grafana_dashboard(title: str = DEFAULT_DASHBOARD_TITLE, datasource: str = "Prometheus") -> dict:
|
|
228
|
+
"""Generate a Grafana dashboard JSON tied to codex-meter Prometheus metrics."""
|
|
229
|
+
return {
|
|
230
|
+
"annotations": {"list": []},
|
|
231
|
+
"editable": True,
|
|
232
|
+
"schemaVersion": 39,
|
|
233
|
+
"tags": ["codex-meter"],
|
|
234
|
+
"title": title,
|
|
235
|
+
"timezone": "",
|
|
236
|
+
"time": {"from": "now-24h", "to": "now"},
|
|
237
|
+
"refresh": "30s",
|
|
238
|
+
"panels": [
|
|
239
|
+
_stat_panel(
|
|
240
|
+
id_=1,
|
|
241
|
+
title="Credits used (current 5h)",
|
|
242
|
+
expr="codex_meter_credits_used",
|
|
243
|
+
unit="none",
|
|
244
|
+
datasource=datasource,
|
|
245
|
+
grid={"h": 5, "w": 6, "x": 0, "y": 0},
|
|
246
|
+
),
|
|
247
|
+
_stat_panel(
|
|
248
|
+
id_=2,
|
|
249
|
+
title="Burn rate (credits/hour)",
|
|
250
|
+
expr="codex_meter_burn_per_hour",
|
|
251
|
+
unit="none",
|
|
252
|
+
datasource=datasource,
|
|
253
|
+
grid={"h": 5, "w": 6, "x": 6, "y": 0},
|
|
254
|
+
),
|
|
255
|
+
_gauge_panel(
|
|
256
|
+
id_=3,
|
|
257
|
+
title="Primary window %",
|
|
258
|
+
expr='codex_meter_window_used_percent{window="primary"}',
|
|
259
|
+
datasource=datasource,
|
|
260
|
+
grid={"h": 5, "w": 6, "x": 12, "y": 0},
|
|
261
|
+
),
|
|
262
|
+
_gauge_panel(
|
|
263
|
+
id_=4,
|
|
264
|
+
title="Secondary window %",
|
|
265
|
+
expr='codex_meter_window_used_percent{window="secondary"}',
|
|
266
|
+
datasource=datasource,
|
|
267
|
+
grid={"h": 5, "w": 6, "x": 18, "y": 0},
|
|
268
|
+
),
|
|
269
|
+
_timeseries_panel(
|
|
270
|
+
id_=5,
|
|
271
|
+
title="Tokens by model / tier / kind",
|
|
272
|
+
expr="sum by (model, tier, kind) (codex_meter_tokens_total)",
|
|
273
|
+
datasource=datasource,
|
|
274
|
+
grid={"h": 10, "w": 24, "x": 0, "y": 5},
|
|
275
|
+
),
|
|
276
|
+
],
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _stat_panel(
|
|
281
|
+
*,
|
|
282
|
+
id_: int,
|
|
283
|
+
title: str,
|
|
284
|
+
expr: str,
|
|
285
|
+
unit: str,
|
|
286
|
+
datasource: str,
|
|
287
|
+
grid: dict,
|
|
288
|
+
) -> dict:
|
|
289
|
+
return {
|
|
290
|
+
"id": id_,
|
|
291
|
+
"type": "stat",
|
|
292
|
+
"title": title,
|
|
293
|
+
"gridPos": grid,
|
|
294
|
+
"datasource": datasource,
|
|
295
|
+
"fieldConfig": {"defaults": {"unit": unit, "decimals": 2}},
|
|
296
|
+
"targets": [{"expr": expr, "refId": "A"}],
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _gauge_panel(*, id_: int, title: str, expr: str, datasource: str, grid: dict) -> dict:
|
|
301
|
+
return {
|
|
302
|
+
"id": id_,
|
|
303
|
+
"type": "gauge",
|
|
304
|
+
"title": title,
|
|
305
|
+
"gridPos": grid,
|
|
306
|
+
"datasource": datasource,
|
|
307
|
+
"fieldConfig": {
|
|
308
|
+
"defaults": {
|
|
309
|
+
"unit": "percent",
|
|
310
|
+
"min": 0,
|
|
311
|
+
"max": 100,
|
|
312
|
+
"thresholds": {
|
|
313
|
+
"mode": "absolute",
|
|
314
|
+
"steps": [
|
|
315
|
+
{"value": 0, "color": "green"},
|
|
316
|
+
{"value": 80, "color": "orange"},
|
|
317
|
+
{"value": 100, "color": "red"},
|
|
318
|
+
],
|
|
319
|
+
},
|
|
320
|
+
}
|
|
321
|
+
},
|
|
322
|
+
"targets": [{"expr": expr, "refId": "A"}],
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _timeseries_panel(*, id_: int, title: str, expr: str, datasource: str, grid: dict) -> dict:
|
|
327
|
+
return {
|
|
328
|
+
"id": id_,
|
|
329
|
+
"type": "timeseries",
|
|
330
|
+
"title": title,
|
|
331
|
+
"gridPos": grid,
|
|
332
|
+
"datasource": datasource,
|
|
333
|
+
"fieldConfig": {"defaults": {"unit": "none"}},
|
|
334
|
+
"targets": [{"expr": expr, "refId": "A"}],
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def render_grafana_dashboard(title: str = DEFAULT_DASHBOARD_TITLE) -> str:
|
|
339
|
+
return json.dumps(grafana_dashboard(title=title), indent=2) + "\n"
|
codex_meter/forecasts.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Forecast helpers: linear and EWMA projection, confidence band.
|
|
2
|
+
|
|
3
|
+
Pure functions over a list of per-day numeric samples (credits, dollars, tokens —
|
|
4
|
+
unit-agnostic). I/O lives in the CLI layer.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
import statistics
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
DEFAULT_EWMA_ALPHA = 0.3
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Projection:
|
|
18
|
+
unit: str
|
|
19
|
+
days_analyzed: int
|
|
20
|
+
daily_mean: float
|
|
21
|
+
daily_stdev: float
|
|
22
|
+
days_remaining: int
|
|
23
|
+
linear_total: float
|
|
24
|
+
ewma_total: float
|
|
25
|
+
linear_low: float
|
|
26
|
+
linear_high: float
|
|
27
|
+
cap: float | None
|
|
28
|
+
days_to_cap: float | None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def daily_mean(daily_values: list[float]) -> float:
|
|
32
|
+
if not daily_values:
|
|
33
|
+
return 0.0
|
|
34
|
+
return statistics.fmean(daily_values)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def daily_stdev(daily_values: list[float]) -> float:
|
|
38
|
+
if len(daily_values) < 2:
|
|
39
|
+
return 0.0
|
|
40
|
+
return statistics.pstdev(daily_values)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def ewma(daily_values: list[float], alpha: float = DEFAULT_EWMA_ALPHA) -> float:
|
|
44
|
+
"""Exponentially-weighted moving average. Newer days weighted more heavily."""
|
|
45
|
+
if not daily_values:
|
|
46
|
+
return 0.0
|
|
47
|
+
if not 0 < alpha <= 1:
|
|
48
|
+
raise ValueError("ewma alpha must be in (0, 1]")
|
|
49
|
+
smoothed = daily_values[0]
|
|
50
|
+
for value in daily_values[1:]:
|
|
51
|
+
smoothed = alpha * value + (1 - alpha) * smoothed
|
|
52
|
+
return smoothed
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def project(
|
|
56
|
+
daily_values: list[float],
|
|
57
|
+
days_remaining: int,
|
|
58
|
+
*,
|
|
59
|
+
unit: str = "credits",
|
|
60
|
+
cap: float | None = None,
|
|
61
|
+
alpha: float = DEFAULT_EWMA_ALPHA,
|
|
62
|
+
) -> Projection:
|
|
63
|
+
"""Project the rolling-sum total `days_remaining` days into the future.
|
|
64
|
+
|
|
65
|
+
`linear_total` uses the simple mean; `ewma_total` uses an exponentially
|
|
66
|
+
weighted mean that reacts faster to recent acceleration.
|
|
67
|
+
"""
|
|
68
|
+
if days_remaining < 0:
|
|
69
|
+
raise ValueError("days_remaining must be non-negative")
|
|
70
|
+
mean = daily_mean(daily_values)
|
|
71
|
+
sigma = daily_stdev(daily_values)
|
|
72
|
+
ewma_rate = ewma(daily_values, alpha)
|
|
73
|
+
linear_total = mean * days_remaining
|
|
74
|
+
ewma_total = ewma_rate * days_remaining
|
|
75
|
+
# 1σ band scales with sqrt(N) under iid assumption.
|
|
76
|
+
band = sigma * math.sqrt(days_remaining) if days_remaining > 0 else 0.0
|
|
77
|
+
days_to_cap: float | None = None
|
|
78
|
+
if cap is not None and mean > 0:
|
|
79
|
+
days_to_cap = max(0.0, cap / mean)
|
|
80
|
+
return Projection(
|
|
81
|
+
unit=unit,
|
|
82
|
+
days_analyzed=len(daily_values),
|
|
83
|
+
daily_mean=mean,
|
|
84
|
+
daily_stdev=sigma,
|
|
85
|
+
days_remaining=days_remaining,
|
|
86
|
+
linear_total=linear_total,
|
|
87
|
+
ewma_total=ewma_total,
|
|
88
|
+
linear_low=max(0.0, linear_total - band),
|
|
89
|
+
linear_high=linear_total + band,
|
|
90
|
+
cap=cap,
|
|
91
|
+
days_to_cap=days_to_cap,
|
|
92
|
+
)
|
codex_meter/humanize.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Small humanization helpers — formatting and redaction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
REDACTION_LIMIT = 72
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def format_int(value: int) -> str:
|
|
11
|
+
return f"{value:,}"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def compact_number(value: float, prefix: str = "") -> str:
|
|
15
|
+
sign = "-" if value < 0 else ""
|
|
16
|
+
amount = abs(float(value))
|
|
17
|
+
for threshold, suffix in ((1_000_000_000, "B"), (1_000_000, "M"), (1_000, "K")):
|
|
18
|
+
if amount >= threshold:
|
|
19
|
+
return f"{sign}{prefix}{amount / threshold:.3g}{suffix}"
|
|
20
|
+
if amount.is_integer():
|
|
21
|
+
return f"{sign}{prefix}{int(amount):,}"
|
|
22
|
+
return f"{sign}{prefix}{amount:,.2f}"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def redact(text: str, show: bool, limit: int = REDACTION_LIMIT) -> str:
|
|
26
|
+
"""Collapse whitespace; truncate with ellipsis when `show` is False."""
|
|
27
|
+
clean = " ".join(str(text).split())
|
|
28
|
+
if show or not clean or len(clean) <= limit:
|
|
29
|
+
return clean
|
|
30
|
+
return clean[: limit - 3] + "..."
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def short_table_label(text: str) -> str:
|
|
34
|
+
"""Prefer scannable labels for dense terminal tables."""
|
|
35
|
+
clean = " ".join(str(text).split())
|
|
36
|
+
if clean.startswith(("/", "~")):
|
|
37
|
+
return Path(clean).name or clean
|
|
38
|
+
return clean
|