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/render.py
ADDED
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import datetime as dt
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import shutil
|
|
8
|
+
import sys
|
|
9
|
+
from dataclasses import asdict
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
|
|
15
|
+
from codex_meter.aggregation import aggregate_model_mode, aggregate_total
|
|
16
|
+
from codex_meter.humanize import compact_number, format_int, redact, short_table_label
|
|
17
|
+
from codex_meter.models import (
|
|
18
|
+
Aggregate,
|
|
19
|
+
CostTotals,
|
|
20
|
+
LoadResult,
|
|
21
|
+
RuntimeOptions,
|
|
22
|
+
TokenTotals,
|
|
23
|
+
decimal_string,
|
|
24
|
+
)
|
|
25
|
+
from codex_meter.pricing import PRICING_SOURCES, RateCard
|
|
26
|
+
from codex_meter.timeutil import iso_z, window_label
|
|
27
|
+
|
|
28
|
+
__all__ = ["format_int", "redact", "render", "render_limits"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _rate_card(options: RuntimeOptions) -> RateCard:
|
|
32
|
+
return RateCard.load(options.rates_file, options.pricing_mode)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _make_console(buffer: io.StringIO, options: RuntimeOptions) -> Console:
|
|
36
|
+
if options.width is not None:
|
|
37
|
+
width = options.width
|
|
38
|
+
elif options.compact:
|
|
39
|
+
width = 100
|
|
40
|
+
else:
|
|
41
|
+
width = shutil.get_terminal_size((140, 24)).columns
|
|
42
|
+
return Console(file=buffer, width=width, soft_wrap=False, _environ={})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def aggregate_to_dict(item: Aggregate, show_prompts: bool = False) -> dict:
|
|
46
|
+
return {
|
|
47
|
+
"key": item.key,
|
|
48
|
+
"label": redact(item.label, show_prompts),
|
|
49
|
+
"events": item.totals.events,
|
|
50
|
+
"input_tokens": item.totals.input_tokens,
|
|
51
|
+
"cached_input_tokens": item.totals.cached_input_tokens,
|
|
52
|
+
"uncached_input_tokens": item.totals.uncached_input_tokens,
|
|
53
|
+
"output_tokens": item.totals.output_tokens,
|
|
54
|
+
"reasoning_output_tokens": item.totals.reasoning_output_tokens,
|
|
55
|
+
"total_tokens": item.totals.total_tokens,
|
|
56
|
+
"credits": float(item.costs.adjusted_credits),
|
|
57
|
+
"standard_credits": float(item.costs.standard_credits),
|
|
58
|
+
"api_dollars": float(item.costs.api_dollars),
|
|
59
|
+
"credits_exact": decimal_string(item.costs.adjusted_credits),
|
|
60
|
+
"standard_credits_exact": decimal_string(item.costs.standard_credits),
|
|
61
|
+
"api_dollars_exact": decimal_string(item.costs.api_dollars),
|
|
62
|
+
"cache_savings_credits": float(item.cache_savings.adjusted_credits),
|
|
63
|
+
"cache_savings_standard_credits": float(item.cache_savings.standard_credits),
|
|
64
|
+
"cache_savings_api_dollars": float(item.cache_savings.api_dollars),
|
|
65
|
+
"cache_savings_credits_exact": decimal_string(item.cache_savings.adjusted_credits),
|
|
66
|
+
"cache_savings_standard_credits_exact": decimal_string(item.cache_savings.standard_credits),
|
|
67
|
+
"cache_savings_api_dollars_exact": decimal_string(item.cache_savings.api_dollars),
|
|
68
|
+
"models": sorted(item.models),
|
|
69
|
+
"service_tiers": sorted(item.service_tiers),
|
|
70
|
+
"plan_types": sorted(item.plan_types),
|
|
71
|
+
"usage_sources": sorted(item.usage_sources),
|
|
72
|
+
"model_context_window": item.model_context_window,
|
|
73
|
+
"long_context_events": item.long_context_events,
|
|
74
|
+
"unknown_model_events": item.unknown_model_events,
|
|
75
|
+
"unknown_tier_events": item.unknown_tier_events,
|
|
76
|
+
"pricing_status": pricing_status(item),
|
|
77
|
+
"unpriced_events": item.costs.unpriced_events,
|
|
78
|
+
"api_unpriced_events": item.costs.api_unpriced_events,
|
|
79
|
+
"credit_unpriced_events": item.costs.credit_unpriced_events,
|
|
80
|
+
"estimated_events": pricing_estimated_events(item),
|
|
81
|
+
"ambiguous_reasoning_events": item.costs.ambiguous_reasoning_events,
|
|
82
|
+
"local_rate_override_events": item.costs.local_override_events,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def pricing_estimated_events(item: Aggregate) -> int:
|
|
87
|
+
return (
|
|
88
|
+
item.costs.estimated_events
|
|
89
|
+
+ item.unknown_tier_events
|
|
90
|
+
+ item.costs.ambiguous_reasoning_events
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def pricing_status(item: Aggregate) -> str:
|
|
95
|
+
if item.costs.unpriced_events or item.unknown_model_events:
|
|
96
|
+
return "partial"
|
|
97
|
+
if pricing_estimated_events(item):
|
|
98
|
+
return "estimated"
|
|
99
|
+
return "exact"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def pricing_warnings(item: Aggregate) -> list[str]:
|
|
103
|
+
warnings: list[str] = []
|
|
104
|
+
if item.costs.api_unpriced_events:
|
|
105
|
+
warnings.append(f"{item.costs.api_unpriced_events:,} events have no API-dollar rate.")
|
|
106
|
+
if item.costs.credit_unpriced_events:
|
|
107
|
+
warnings.append(f"{item.costs.credit_unpriced_events:,} events have no Codex credit rate.")
|
|
108
|
+
if item.unknown_model_events:
|
|
109
|
+
warnings.append(
|
|
110
|
+
f"{item.unknown_model_events:,} events used models with no known rate card."
|
|
111
|
+
)
|
|
112
|
+
if item.unknown_tier_events:
|
|
113
|
+
warnings.append(
|
|
114
|
+
f"{item.unknown_tier_events:,} events used inferred service tiers; costs are estimates."
|
|
115
|
+
)
|
|
116
|
+
if item.costs.estimated_events:
|
|
117
|
+
warnings.append(f"{item.costs.estimated_events:,} events used explicit estimate pricing.")
|
|
118
|
+
if item.costs.ambiguous_reasoning_events:
|
|
119
|
+
warnings.append(
|
|
120
|
+
f"{item.costs.ambiguous_reasoning_events:,} events had ambiguous reasoning token shape."
|
|
121
|
+
)
|
|
122
|
+
return warnings
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def rate_limit_sample_to_dict(sample) -> dict:
|
|
126
|
+
item = asdict(sample)
|
|
127
|
+
item["path"] = str(sample.path)
|
|
128
|
+
item["timestamp"] = iso_z(sample.timestamp)
|
|
129
|
+
return item
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def report_payload(
|
|
133
|
+
result: LoadResult,
|
|
134
|
+
options: RuntimeOptions,
|
|
135
|
+
rows: list[Aggregate],
|
|
136
|
+
command: str,
|
|
137
|
+
rate_card: RateCard | None = None,
|
|
138
|
+
) -> dict:
|
|
139
|
+
card = rate_card or _rate_card(options)
|
|
140
|
+
total = aggregate_total(result, options, rate_card=card)
|
|
141
|
+
return {
|
|
142
|
+
"command": command,
|
|
143
|
+
"generated_at": iso_z(options.end),
|
|
144
|
+
"window": {
|
|
145
|
+
"start": iso_z(options.start),
|
|
146
|
+
"end": iso_z(options.end),
|
|
147
|
+
"label": window_label(options.start, options.end, options.timezone),
|
|
148
|
+
"timezone": options.timezone,
|
|
149
|
+
},
|
|
150
|
+
"totals": aggregate_to_dict(total, options.show_prompts)
|
|
151
|
+
| {"duplicates": result.duplicates},
|
|
152
|
+
"breakdowns": [aggregate_to_dict(row, options.show_prompts) for row in rows],
|
|
153
|
+
"model_mode": [
|
|
154
|
+
aggregate_to_dict(row, options.show_prompts)
|
|
155
|
+
for row in aggregate_model_mode(result, options, rate_card=card)
|
|
156
|
+
],
|
|
157
|
+
"pricing": {
|
|
158
|
+
"mode": options.pricing_mode,
|
|
159
|
+
"offline": options.offline,
|
|
160
|
+
"status": pricing_status(total),
|
|
161
|
+
"warnings": pricing_warnings(total),
|
|
162
|
+
"sources": [asdict(source) for source in PRICING_SOURCES],
|
|
163
|
+
},
|
|
164
|
+
"metadata": {
|
|
165
|
+
"session_root": str(options.session_root),
|
|
166
|
+
"state_db": str(options.state_db),
|
|
167
|
+
"tier_sources": result.tier_sources,
|
|
168
|
+
"plan_types": sorted(result.plan_types),
|
|
169
|
+
"warning_count": len(result.warnings),
|
|
170
|
+
},
|
|
171
|
+
"rate_limit_samples": [
|
|
172
|
+
rate_limit_sample_to_dict(sample)
|
|
173
|
+
for sample in _recent_samples(result.credit_samples, options.top_threads)
|
|
174
|
+
],
|
|
175
|
+
"warnings": result.warnings,
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def write_output(text: str, output: Path | None) -> None:
|
|
180
|
+
if output:
|
|
181
|
+
output.expanduser().write_text(text)
|
|
182
|
+
else:
|
|
183
|
+
sys.stdout.write(text)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def render_table(
|
|
187
|
+
result: LoadResult,
|
|
188
|
+
options: RuntimeOptions,
|
|
189
|
+
rows: list[Aggregate],
|
|
190
|
+
title: str,
|
|
191
|
+
rate_card: RateCard | None = None,
|
|
192
|
+
) -> str:
|
|
193
|
+
buffer = io.StringIO()
|
|
194
|
+
console = _make_console(buffer, options)
|
|
195
|
+
total = aggregate_total(result, options, rate_card=rate_card or _rate_card(options))
|
|
196
|
+
console.print(f"[bold]{title}[/bold]")
|
|
197
|
+
console.print(f"Window: {window_label(options.start, options.end, options.timezone)}")
|
|
198
|
+
console.print(f"Session root: {options.session_root}")
|
|
199
|
+
if result.warnings:
|
|
200
|
+
for warning in result.warnings:
|
|
201
|
+
console.print(f"[yellow]Warning:[/yellow] {warning}")
|
|
202
|
+
for warning in pricing_warnings(total):
|
|
203
|
+
console.print(
|
|
204
|
+
f"[yellow]Warning:[/yellow] {warning} Reported costs are {pricing_status(total)}."
|
|
205
|
+
)
|
|
206
|
+
console.print()
|
|
207
|
+
|
|
208
|
+
table = Table(show_lines=False, expand=not options.compact)
|
|
209
|
+
table.add_column("Group")
|
|
210
|
+
table.add_column("Models")
|
|
211
|
+
table.add_column("Input", justify="right")
|
|
212
|
+
table.add_column("Cached", justify="right")
|
|
213
|
+
table.add_column("Output", justify="right")
|
|
214
|
+
table.add_column("Total", justify="right")
|
|
215
|
+
table.add_column("Credits", justify="right")
|
|
216
|
+
table.add_column("API $", justify="right")
|
|
217
|
+
for row in rows:
|
|
218
|
+
table.add_row(
|
|
219
|
+
short_table_label(redact(row.label, options.show_prompts)),
|
|
220
|
+
"\n".join(sorted(row.models)) or "-",
|
|
221
|
+
_table_int(row.totals.input_tokens, options),
|
|
222
|
+
_table_int(row.totals.cached_input_tokens, options),
|
|
223
|
+
_table_int(row.totals.output_tokens, options),
|
|
224
|
+
_table_int(row.totals.total_tokens, options),
|
|
225
|
+
_table_float(row.costs.adjusted_credits, options),
|
|
226
|
+
_table_float(row.costs.api_dollars, options, prefix="$"),
|
|
227
|
+
)
|
|
228
|
+
table.add_section()
|
|
229
|
+
table.add_row(
|
|
230
|
+
"Total",
|
|
231
|
+
"\n".join(sorted(total.models)) or "-",
|
|
232
|
+
_table_int(total.totals.input_tokens, options),
|
|
233
|
+
_table_int(total.totals.cached_input_tokens, options),
|
|
234
|
+
_table_int(total.totals.output_tokens, options),
|
|
235
|
+
_table_int(total.totals.total_tokens, options),
|
|
236
|
+
_table_float(total.costs.adjusted_credits, options),
|
|
237
|
+
_table_float(total.costs.api_dollars, options, prefix="$"),
|
|
238
|
+
)
|
|
239
|
+
console.print(table)
|
|
240
|
+
console.print()
|
|
241
|
+
events_text = format_int(total.totals.events)
|
|
242
|
+
duplicates_text = format_int(result.duplicates)
|
|
243
|
+
console.print(f"Events: {events_text} | Duplicates skipped: {duplicates_text}")
|
|
244
|
+
if total.costs.adjusted_credits != total.costs.standard_credits:
|
|
245
|
+
console.print(f"Standard-mode baseline: {total.costs.standard_credits:,.2f} credits")
|
|
246
|
+
if total.cache_savings.adjusted_credits or total.cache_savings.api_dollars:
|
|
247
|
+
console.print(
|
|
248
|
+
"Cache savings: "
|
|
249
|
+
f"{total.cache_savings.adjusted_credits:,.2f} credits | "
|
|
250
|
+
f"${total.cache_savings.api_dollars:,.2f}"
|
|
251
|
+
)
|
|
252
|
+
if result.tier_sources:
|
|
253
|
+
sources = ", ".join(
|
|
254
|
+
f"{key}={format_int(value)}" for key, value in sorted(result.tier_sources.items())
|
|
255
|
+
)
|
|
256
|
+
console.print(f"Service-tier sources: {sources}")
|
|
257
|
+
if result.plan_types:
|
|
258
|
+
console.print(f"Plan types: {', '.join(sorted(result.plan_types))}")
|
|
259
|
+
return buffer.getvalue()
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _table_int(value: int, options: RuntimeOptions) -> str:
|
|
263
|
+
return compact_number(value) if options.compact else format_int(value)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _table_float(value: float, options: RuntimeOptions, prefix: str = "") -> str:
|
|
267
|
+
if options.compact:
|
|
268
|
+
return compact_number(value, prefix=prefix)
|
|
269
|
+
if prefix:
|
|
270
|
+
return f"{prefix}{value:,.2f}"
|
|
271
|
+
return f"{value:,.2f}"
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def render_json(
|
|
275
|
+
result: LoadResult,
|
|
276
|
+
options: RuntimeOptions,
|
|
277
|
+
rows: list[Aggregate],
|
|
278
|
+
command: str,
|
|
279
|
+
rate_card: RateCard | None = None,
|
|
280
|
+
) -> str:
|
|
281
|
+
return (
|
|
282
|
+
json.dumps(report_payload(result, options, rows, command, rate_card=rate_card), indent=2)
|
|
283
|
+
+ "\n"
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def render_csv(rows: list[Aggregate], show_prompts: bool) -> str:
|
|
288
|
+
output = io.StringIO()
|
|
289
|
+
writer = csv.DictWriter(
|
|
290
|
+
output,
|
|
291
|
+
fieldnames=[
|
|
292
|
+
"key",
|
|
293
|
+
"label",
|
|
294
|
+
"events",
|
|
295
|
+
"input_tokens",
|
|
296
|
+
"cached_input_tokens",
|
|
297
|
+
"uncached_input_tokens",
|
|
298
|
+
"output_tokens",
|
|
299
|
+
"reasoning_output_tokens",
|
|
300
|
+
"total_tokens",
|
|
301
|
+
"credits",
|
|
302
|
+
"standard_credits",
|
|
303
|
+
"api_dollars",
|
|
304
|
+
"pricing_status",
|
|
305
|
+
"unpriced_events",
|
|
306
|
+
"estimated_events",
|
|
307
|
+
"models",
|
|
308
|
+
"service_tiers",
|
|
309
|
+
],
|
|
310
|
+
)
|
|
311
|
+
writer.writeheader()
|
|
312
|
+
for row in rows:
|
|
313
|
+
item = aggregate_to_dict(row, show_prompts)
|
|
314
|
+
item["models"] = ",".join(item["models"])
|
|
315
|
+
item["service_tiers"] = ",".join(item["service_tiers"])
|
|
316
|
+
writer.writerow({key: item[key] for key in writer.fieldnames or []})
|
|
317
|
+
return output.getvalue()
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def render_markdown(rows: list[Aggregate], show_prompts: bool) -> str:
|
|
321
|
+
headers = [
|
|
322
|
+
"Group",
|
|
323
|
+
"Events",
|
|
324
|
+
"Input",
|
|
325
|
+
"Cached",
|
|
326
|
+
"Output",
|
|
327
|
+
"Total",
|
|
328
|
+
"Credits",
|
|
329
|
+
"API $",
|
|
330
|
+
"Pricing",
|
|
331
|
+
]
|
|
332
|
+
lines = [
|
|
333
|
+
"| " + " | ".join(headers) + " |",
|
|
334
|
+
"| " + " | ".join(["---"] * len(headers)) + " |",
|
|
335
|
+
]
|
|
336
|
+
totals = TokenTotals()
|
|
337
|
+
costs = CostTotals()
|
|
338
|
+
row_statuses: set[str] = set()
|
|
339
|
+
for row in rows:
|
|
340
|
+
row_statuses.add(pricing_status(row))
|
|
341
|
+
totals.events += row.totals.events
|
|
342
|
+
totals.input_tokens += row.totals.input_tokens
|
|
343
|
+
totals.cached_input_tokens += row.totals.cached_input_tokens
|
|
344
|
+
totals.output_tokens += row.totals.output_tokens
|
|
345
|
+
totals.total_tokens += row.totals.total_tokens
|
|
346
|
+
costs.add(row.costs)
|
|
347
|
+
lines.append(
|
|
348
|
+
"| "
|
|
349
|
+
+ " | ".join(
|
|
350
|
+
[
|
|
351
|
+
redact(row.label, show_prompts).replace("|", "\\|"),
|
|
352
|
+
str(row.totals.events),
|
|
353
|
+
str(row.totals.input_tokens),
|
|
354
|
+
str(row.totals.cached_input_tokens),
|
|
355
|
+
str(row.totals.output_tokens),
|
|
356
|
+
str(row.totals.total_tokens),
|
|
357
|
+
f"{row.costs.adjusted_credits:.2f}",
|
|
358
|
+
f"{row.costs.api_dollars:.2f}",
|
|
359
|
+
pricing_status(row),
|
|
360
|
+
]
|
|
361
|
+
)
|
|
362
|
+
+ " |"
|
|
363
|
+
)
|
|
364
|
+
if rows:
|
|
365
|
+
total_status = (
|
|
366
|
+
"partial"
|
|
367
|
+
if "partial" in row_statuses
|
|
368
|
+
else "estimated"
|
|
369
|
+
if "estimated" in row_statuses
|
|
370
|
+
else "exact"
|
|
371
|
+
)
|
|
372
|
+
lines.append(
|
|
373
|
+
"| "
|
|
374
|
+
+ " | ".join(
|
|
375
|
+
[
|
|
376
|
+
"**Total**",
|
|
377
|
+
str(totals.events),
|
|
378
|
+
str(totals.input_tokens),
|
|
379
|
+
str(totals.cached_input_tokens),
|
|
380
|
+
str(totals.output_tokens),
|
|
381
|
+
str(totals.total_tokens),
|
|
382
|
+
f"{costs.adjusted_credits:.2f}",
|
|
383
|
+
f"{costs.api_dollars:.2f}",
|
|
384
|
+
total_status,
|
|
385
|
+
]
|
|
386
|
+
)
|
|
387
|
+
+ " |"
|
|
388
|
+
)
|
|
389
|
+
return "\n".join(lines) + "\n"
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def render(
|
|
393
|
+
result: LoadResult,
|
|
394
|
+
options: RuntimeOptions,
|
|
395
|
+
rows: list[Aggregate],
|
|
396
|
+
command: str,
|
|
397
|
+
output_format: str,
|
|
398
|
+
output: Path | None,
|
|
399
|
+
) -> None:
|
|
400
|
+
card = _rate_card(options)
|
|
401
|
+
if output_format == "json":
|
|
402
|
+
text = render_json(result, options, rows, command, rate_card=card)
|
|
403
|
+
elif output_format == "csv":
|
|
404
|
+
text = render_csv(rows, options.show_prompts)
|
|
405
|
+
elif output_format == "markdown":
|
|
406
|
+
text = render_markdown(rows, options.show_prompts)
|
|
407
|
+
else:
|
|
408
|
+
text = render_table(
|
|
409
|
+
result, options, rows, f"Codex Meter - {command.title()}", rate_card=card
|
|
410
|
+
)
|
|
411
|
+
write_output(text, output)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
LIMITS_CSV_FIELDS = (
|
|
415
|
+
"timestamp",
|
|
416
|
+
"session_id",
|
|
417
|
+
"plan_type",
|
|
418
|
+
"credits",
|
|
419
|
+
"primary_used_percent",
|
|
420
|
+
"primary_window_minutes",
|
|
421
|
+
"primary_resets_at",
|
|
422
|
+
"secondary_used_percent",
|
|
423
|
+
"secondary_window_minutes",
|
|
424
|
+
"secondary_resets_at",
|
|
425
|
+
"rate_limit_reached_type",
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def render_limits_table(result: LoadResult, options: RuntimeOptions) -> str:
|
|
430
|
+
buffer = io.StringIO()
|
|
431
|
+
console = _make_console(buffer, options)
|
|
432
|
+
console.print("[bold]Codex Meter - Limits[/bold]")
|
|
433
|
+
console.print(f"Window: {window_label(options.start, options.end, options.timezone)}")
|
|
434
|
+
samples = _recent_samples(result.credit_samples, options.top_threads)
|
|
435
|
+
if not samples:
|
|
436
|
+
console.print("No rate-limit samples found.")
|
|
437
|
+
return buffer.getvalue()
|
|
438
|
+
if options.compact:
|
|
439
|
+
for sample in samples:
|
|
440
|
+
console.print(
|
|
441
|
+
f"- {iso_z(sample.timestamp)} | credits={sample.credits} | "
|
|
442
|
+
f"primary={sample.primary_used_percent}%/"
|
|
443
|
+
f"{sample.primary_window_minutes}m reset={sample.primary_resets_at} | "
|
|
444
|
+
f"secondary={sample.secondary_used_percent}%/"
|
|
445
|
+
f"{sample.secondary_window_minutes}m reset={sample.secondary_resets_at}"
|
|
446
|
+
)
|
|
447
|
+
return buffer.getvalue()
|
|
448
|
+
table = Table(show_lines=False, expand=not options.compact)
|
|
449
|
+
table.add_column("Time")
|
|
450
|
+
table.add_column("Plan")
|
|
451
|
+
table.add_column("Credits", justify="right")
|
|
452
|
+
table.add_column("Primary %", justify="right")
|
|
453
|
+
table.add_column("Reset In", justify="right")
|
|
454
|
+
table.add_column("Secondary %", justify="right")
|
|
455
|
+
table.add_column("Reset In", justify="right")
|
|
456
|
+
for sample in samples:
|
|
457
|
+
table.add_row(
|
|
458
|
+
iso_z(sample.timestamp),
|
|
459
|
+
str(sample.plan_type or "-"),
|
|
460
|
+
str(sample.credits if sample.credits is not None else "-"),
|
|
461
|
+
_percent(sample.primary_used_percent),
|
|
462
|
+
_reset_epoch(sample.primary_resets_at),
|
|
463
|
+
_percent(sample.secondary_used_percent),
|
|
464
|
+
_reset_epoch(sample.secondary_resets_at),
|
|
465
|
+
)
|
|
466
|
+
console.print(table)
|
|
467
|
+
return buffer.getvalue()
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _percent(value: object) -> str:
|
|
471
|
+
return "-" if value is None else f"{float(value):.1f}%"
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _reset_epoch(value: object) -> str:
|
|
475
|
+
if value in {None, ""}:
|
|
476
|
+
return "-"
|
|
477
|
+
try:
|
|
478
|
+
reset_at = dt.datetime.fromtimestamp(float(value), tz=dt.UTC)
|
|
479
|
+
except (TypeError, ValueError, OSError):
|
|
480
|
+
return str(value)
|
|
481
|
+
remaining = reset_at - dt.datetime.now(tz=dt.UTC)
|
|
482
|
+
seconds = int(remaining.total_seconds())
|
|
483
|
+
if seconds <= 0:
|
|
484
|
+
return "now"
|
|
485
|
+
minutes, leftover = divmod(seconds, 60)
|
|
486
|
+
hours, minutes = divmod(minutes, 60)
|
|
487
|
+
if hours:
|
|
488
|
+
return f"{hours}h {minutes}m"
|
|
489
|
+
return f"{minutes}m {leftover}s"
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _recent_samples(samples: list, limit: int) -> list:
|
|
493
|
+
return sorted(samples, key=lambda sample: sample.timestamp)[-limit:]
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def render_limits_csv(result: LoadResult, options: RuntimeOptions) -> str:
|
|
497
|
+
out = io.StringIO()
|
|
498
|
+
writer = csv.DictWriter(out, fieldnames=list(LIMITS_CSV_FIELDS))
|
|
499
|
+
writer.writeheader()
|
|
500
|
+
for sample in _recent_samples(result.credit_samples, options.top_threads):
|
|
501
|
+
row = rate_limit_sample_to_dict(sample)
|
|
502
|
+
writer.writerow({key: row.get(key, "") for key in LIMITS_CSV_FIELDS})
|
|
503
|
+
return out.getvalue()
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def render_limits_markdown(result: LoadResult, options: RuntimeOptions) -> str:
|
|
507
|
+
headers = ["Timestamp", "Plan", "Credits", "Primary %", "Secondary %"]
|
|
508
|
+
lines = [
|
|
509
|
+
"| " + " | ".join(headers) + " |",
|
|
510
|
+
"| " + " | ".join(["---"] * len(headers)) + " |",
|
|
511
|
+
]
|
|
512
|
+
for sample in _recent_samples(result.credit_samples, options.top_threads):
|
|
513
|
+
primary = sample.primary_used_percent
|
|
514
|
+
secondary = sample.secondary_used_percent
|
|
515
|
+
lines.append(
|
|
516
|
+
"| "
|
|
517
|
+
+ " | ".join(
|
|
518
|
+
[
|
|
519
|
+
iso_z(sample.timestamp),
|
|
520
|
+
str(sample.plan_type or ""),
|
|
521
|
+
str(sample.credits if sample.credits is not None else ""),
|
|
522
|
+
str(primary if primary is not None else ""),
|
|
523
|
+
str(secondary if secondary is not None else ""),
|
|
524
|
+
]
|
|
525
|
+
)
|
|
526
|
+
+ " |"
|
|
527
|
+
)
|
|
528
|
+
return "\n".join(lines) + "\n"
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def render_limits(
|
|
532
|
+
result: LoadResult,
|
|
533
|
+
options: RuntimeOptions,
|
|
534
|
+
output_format: str,
|
|
535
|
+
output: Path | None,
|
|
536
|
+
) -> None:
|
|
537
|
+
if output_format == "json":
|
|
538
|
+
text = render_json(result, options, [], "limits", rate_card=_rate_card(options))
|
|
539
|
+
elif output_format == "csv":
|
|
540
|
+
text = render_limits_csv(result, options)
|
|
541
|
+
elif output_format == "markdown":
|
|
542
|
+
text = render_limits_markdown(result, options)
|
|
543
|
+
else:
|
|
544
|
+
text = render_limits_table(result, options)
|
|
545
|
+
write_output(text, output)
|
codex_meter/timeutil.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import datetime as dt
|
|
4
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def local_timezone() -> dt.tzinfo:
|
|
8
|
+
return dt.datetime.now().astimezone().tzinfo or dt.UTC
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def load_timezone(name: str | None) -> dt.tzinfo:
|
|
12
|
+
if not name or name == "local":
|
|
13
|
+
return local_timezone()
|
|
14
|
+
try:
|
|
15
|
+
return ZoneInfo(name)
|
|
16
|
+
except ZoneInfoNotFoundError as exc:
|
|
17
|
+
raise ValueError(f"Unknown IANA timezone: {name}") from exc
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse_datetime(value: str | None, default: dt.datetime | None = None) -> dt.datetime:
|
|
21
|
+
if not value:
|
|
22
|
+
if default is None:
|
|
23
|
+
raise ValueError("missing datetime")
|
|
24
|
+
return default
|
|
25
|
+
|
|
26
|
+
raw = value.strip()
|
|
27
|
+
if raw.endswith("Z"):
|
|
28
|
+
raw = raw[:-1] + "+00:00"
|
|
29
|
+
if len(raw) == 8 and raw.isdigit():
|
|
30
|
+
raw = f"{raw[:4]}-{raw[4:6]}-{raw[6:8]} 00:00:00"
|
|
31
|
+
elif len(raw) == 10 and raw[4] == "-" and raw[7] == "-":
|
|
32
|
+
raw = raw + " 00:00:00"
|
|
33
|
+
|
|
34
|
+
parsed = dt.datetime.fromisoformat(raw)
|
|
35
|
+
if parsed.tzinfo is None:
|
|
36
|
+
parsed = parsed.replace(tzinfo=local_timezone())
|
|
37
|
+
return parsed
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def parse_event_timestamp(value: str | None) -> dt.datetime | None:
|
|
41
|
+
if not value:
|
|
42
|
+
return None
|
|
43
|
+
raw = value[:-1] + "+00:00" if value.endswith("Z") else value
|
|
44
|
+
try:
|
|
45
|
+
parsed = dt.datetime.fromisoformat(raw)
|
|
46
|
+
except ValueError:
|
|
47
|
+
return None
|
|
48
|
+
if parsed.tzinfo is None:
|
|
49
|
+
parsed = parsed.replace(tzinfo=dt.UTC)
|
|
50
|
+
return parsed
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def iso_z(value: dt.datetime) -> str:
|
|
54
|
+
return value.astimezone(dt.UTC).isoformat().replace("+00:00", "Z")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def day_key(value: dt.datetime, tz: dt.tzinfo) -> str:
|
|
58
|
+
return value.astimezone(tz).strftime("%Y-%m-%d")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def month_key(value: dt.datetime, tz: dt.tzinfo) -> str:
|
|
62
|
+
return value.astimezone(tz).strftime("%Y-%m")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def week_key(value: dt.datetime, tz: dt.tzinfo) -> str:
|
|
66
|
+
local = value.astimezone(tz)
|
|
67
|
+
year, week, _ = local.isocalendar()
|
|
68
|
+
return f"{year}-W{week:02d}"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def window_label(start: dt.datetime, end: dt.datetime, tzname: str) -> str:
|
|
72
|
+
tz = load_timezone(tzname)
|
|
73
|
+
start_label = start.astimezone(tz).strftime("%Y-%m-%d %H:%M:%S %Z")
|
|
74
|
+
end_label = end.astimezone(tz).strftime("%Y-%m-%d %H:%M:%S %Z")
|
|
75
|
+
return f"{start_label} to {end_label}"
|