codex-usage-tracking 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.
Files changed (50) hide show
  1. codex_usage_tracker/__init__.py +7 -0
  2. codex_usage_tracker/__main__.py +6 -0
  3. codex_usage_tracker/allowance.py +759 -0
  4. codex_usage_tracker/api_payloads.py +90 -0
  5. codex_usage_tracker/cli.py +1326 -0
  6. codex_usage_tracker/context.py +410 -0
  7. codex_usage_tracker/costing.py +176 -0
  8. codex_usage_tracker/dashboard.py +389 -0
  9. codex_usage_tracker/diagnostics.py +624 -0
  10. codex_usage_tracker/formatting.py +225 -0
  11. codex_usage_tracker/json_contracts.py +350 -0
  12. codex_usage_tracker/mcp_server.py +371 -0
  13. codex_usage_tracker/models.py +92 -0
  14. codex_usage_tracker/parser.py +491 -0
  15. codex_usage_tracker/paths.py +18 -0
  16. codex_usage_tracker/plugin_data/__init__.py +1 -0
  17. codex_usage_tracker/plugin_data/assets/icon.svg +8 -0
  18. codex_usage_tracker/plugin_data/dashboard/dashboard.css +954 -0
  19. codex_usage_tracker/plugin_data/dashboard/dashboard.js +1833 -0
  20. codex_usage_tracker/plugin_data/dashboard/dashboard_data.js +155 -0
  21. codex_usage_tracker/plugin_data/dashboard/dashboard_format.js +132 -0
  22. codex_usage_tracker/plugin_data/dashboard/dashboard_state.js +157 -0
  23. codex_usage_tracker/plugin_data/dashboard/dashboard_template.html +141 -0
  24. codex_usage_tracker/plugin_data/docs/assets/dashboard-calls.png +0 -0
  25. codex_usage_tracker/plugin_data/docs/assets/dashboard-details.png +0 -0
  26. codex_usage_tracker/plugin_data/docs/assets/dashboard-insights.png +0 -0
  27. codex_usage_tracker/plugin_data/docs/assets/dashboard-threads.png +0 -0
  28. codex_usage_tracker/plugin_data/docs/dashboard-guide.html +136 -0
  29. codex_usage_tracker/plugin_data/rate_cards/codex-credit-rates.json +69 -0
  30. codex_usage_tracker/plugin_data/skills/codex-usage-api/SKILL.md +62 -0
  31. codex_usage_tracker/plugin_data/skills/codex-usage-tracker/SKILL.md +47 -0
  32. codex_usage_tracker/plugin_installer.py +312 -0
  33. codex_usage_tracker/pricing.py +57 -0
  34. codex_usage_tracker/pricing_config.py +223 -0
  35. codex_usage_tracker/pricing_estimates.py +44 -0
  36. codex_usage_tracker/pricing_openai.py +253 -0
  37. codex_usage_tracker/projects.py +347 -0
  38. codex_usage_tracker/recommendations.py +270 -0
  39. codex_usage_tracker/reports.py +637 -0
  40. codex_usage_tracker/schema.py +71 -0
  41. codex_usage_tracker/server.py +400 -0
  42. codex_usage_tracker/store.py +666 -0
  43. codex_usage_tracker/support.py +147 -0
  44. codex_usage_tracker/threads.py +183 -0
  45. codex_usage_tracking-0.3.0.dist-info/METADATA +278 -0
  46. codex_usage_tracking-0.3.0.dist-info/RECORD +50 -0
  47. codex_usage_tracking-0.3.0.dist-info/WHEEL +5 -0
  48. codex_usage_tracking-0.3.0.dist-info/entry_points.txt +2 -0
  49. codex_usage_tracking-0.3.0.dist-info/licenses/LICENSE +21 -0
  50. codex_usage_tracking-0.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,759 @@
1
+ """Codex usage allowance and credit estimation helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import shutil
8
+ from dataclasses import asdict, dataclass
9
+ from datetime import datetime, timezone
10
+ from importlib import resources
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from codex_usage_tracker.paths import DEFAULT_ALLOWANCE_PATH, DEFAULT_RATE_CARD_PATH
15
+
16
+ ALLOWANCE_SCHEMA = "codex-usage-tracker-allowance-v1"
17
+ RATE_CARD_SCHEMA = "codex-usage-tracker-codex-rate-card-v1"
18
+ CODEX_RATE_CARD_URL = "https://help.openai.com/en/articles/20001106-codex-rate-card"
19
+ CODEX_PRICING_URL = "https://developers.openai.com/codex/pricing"
20
+ DEFAULT_SOURCE = {
21
+ "name": "OpenAI Codex rate card",
22
+ "url": CODEX_RATE_CARD_URL,
23
+ "pricing_url": CODEX_PRICING_URL,
24
+ "fetched_at": "2026-06-03",
25
+ "basis": "credits per 1M input, cached input, and output tokens",
26
+ "tier": "standard",
27
+ }
28
+
29
+ ALLOWANCE_TEMPLATE = {
30
+ "schema": ALLOWANCE_SCHEMA,
31
+ "_comment": (
32
+ "Optional. Copy remaining usage values from Codex Settings > Usage or "
33
+ "from /status. Percent values can be 0-100 or 0-1. Add total_credits "
34
+ "only when your plan or workspace exposes an exact credit allowance. "
35
+ "Use credit_rates and aliases only for local rate-card overrides; "
36
+ "bundled/default rates live in the separate rate-card snapshot."
37
+ ),
38
+ "windows": [
39
+ {
40
+ "key": "five_hour",
41
+ "label": "5h",
42
+ "remaining_percent": None,
43
+ "reset_at": None,
44
+ "captured_at": None,
45
+ "total_credits": None,
46
+ "remaining_credits": None,
47
+ },
48
+ {
49
+ "key": "weekly",
50
+ "label": "Weekly",
51
+ "remaining_percent": None,
52
+ "reset_at": None,
53
+ "captured_at": None,
54
+ "total_credits": None,
55
+ "remaining_credits": None,
56
+ },
57
+ ],
58
+ "credit_rates": {},
59
+ "aliases": {},
60
+ }
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class AllowanceWindow:
65
+ """One configured usage-limit window from the user's local allowance file."""
66
+
67
+ key: str
68
+ label: str
69
+ total_credits: float | None = None
70
+ remaining_credits: float | None = None
71
+ remaining_percent: float | None = None
72
+ reset_at: str | None = None
73
+ captured_at: str | None = None
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class UsageAllowanceConfig:
78
+ """Local usage allowance config plus bundled Codex credit rates."""
79
+
80
+ path: Path
81
+ rate_card_path: Path
82
+ credit_rates: dict[str, dict[str, float]]
83
+ aliases: dict[str, dict[str, str]]
84
+ rate_metadata: dict[str, dict[str, Any]]
85
+ alias_metadata: dict[str, dict[str, Any]]
86
+ windows: list[AllowanceWindow]
87
+ loaded: bool
88
+ rate_card_loaded: bool
89
+ source: dict[str, Any]
90
+ error: str | None = None
91
+ rate_card_error: str | None = None
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class RateCardUpdateResult:
96
+ """Result from writing a local Codex credit rate-card snapshot."""
97
+
98
+ path: Path
99
+ source_url: str | None
100
+ fetched_at: str | None
101
+ model_count: int
102
+ alias_count: int
103
+ backup_path: Path | None = None
104
+
105
+
106
+ def load_allowance_config(
107
+ path: Path = DEFAULT_ALLOWANCE_PATH,
108
+ *,
109
+ rate_card_path: Path = DEFAULT_RATE_CARD_PATH,
110
+ ) -> UsageAllowanceConfig:
111
+ """Load optional allowance settings while always keeping bundled rate-card data."""
112
+
113
+ base_card = load_bundled_rate_card()
114
+ rate_card_loaded = False
115
+ rate_card_error = None
116
+ if rate_card_path.expanduser().exists():
117
+ try:
118
+ base_card = _load_json_file(rate_card_path)
119
+ rate_card_loaded = True
120
+ except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc:
121
+ rate_card_error = str(exc)
122
+
123
+ source = parse_rate_card_source(base_card)
124
+ credit_rates = parse_credit_rates(base_card.get("credit_rates", {}))
125
+ aliases = parse_aliases(base_card.get("aliases", {}))
126
+ rate_metadata = parse_credit_rate_metadata(
127
+ base_card.get("credit_rates", {}), source=source, default_confidence="exact"
128
+ )
129
+ alias_metadata = parse_alias_metadata(base_card.get("aliases", {}), source=source)
130
+ windows: list[AllowanceWindow] = []
131
+ if not path.exists():
132
+ return UsageAllowanceConfig(
133
+ path=path,
134
+ rate_card_path=rate_card_path,
135
+ credit_rates=credit_rates,
136
+ aliases=aliases,
137
+ rate_metadata=rate_metadata,
138
+ alias_metadata=alias_metadata,
139
+ windows=windows,
140
+ loaded=False,
141
+ rate_card_loaded=rate_card_loaded,
142
+ source=source,
143
+ rate_card_error=rate_card_error,
144
+ )
145
+
146
+ try:
147
+ raw = _load_json_file(path)
148
+ local_rates = parse_credit_rates(raw.get("credit_rates", {}))
149
+ credit_rates.update(local_rates)
150
+ rate_metadata.update(
151
+ parse_credit_rate_metadata(
152
+ raw.get("credit_rates", {}),
153
+ source={
154
+ "name": "Local allowance override",
155
+ "url": str(path.expanduser()),
156
+ "fetched_at": None,
157
+ },
158
+ default_confidence="user_override",
159
+ )
160
+ )
161
+ local_aliases = parse_aliases(raw.get("aliases", {}))
162
+ aliases.update(local_aliases)
163
+ alias_metadata.update(
164
+ parse_alias_metadata(
165
+ raw.get("aliases", {}),
166
+ source={
167
+ "name": "Local allowance override",
168
+ "url": str(path.expanduser()),
169
+ "fetched_at": None,
170
+ },
171
+ )
172
+ )
173
+ windows = parse_windows(raw.get("windows", []))
174
+ except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc:
175
+ return UsageAllowanceConfig(
176
+ path=path,
177
+ rate_card_path=rate_card_path,
178
+ credit_rates=credit_rates,
179
+ aliases=aliases,
180
+ rate_metadata=rate_metadata,
181
+ alias_metadata=alias_metadata,
182
+ windows=[],
183
+ loaded=False,
184
+ rate_card_loaded=rate_card_loaded,
185
+ source=source,
186
+ error=str(exc),
187
+ rate_card_error=rate_card_error,
188
+ )
189
+
190
+ return UsageAllowanceConfig(
191
+ path=path,
192
+ rate_card_path=rate_card_path,
193
+ credit_rates=credit_rates,
194
+ aliases=aliases,
195
+ rate_metadata=rate_metadata,
196
+ alias_metadata=alias_metadata,
197
+ windows=windows,
198
+ loaded=True,
199
+ rate_card_loaded=rate_card_loaded,
200
+ source=source,
201
+ rate_card_error=rate_card_error,
202
+ )
203
+
204
+
205
+ def write_allowance_template(
206
+ path: Path = DEFAULT_ALLOWANCE_PATH, force: bool = False
207
+ ) -> Path:
208
+ """Write a local template for optional allowance-window settings."""
209
+
210
+ if path.exists() and not force:
211
+ raise FileExistsError(f"Allowance config already exists: {path}")
212
+ path.parent.mkdir(parents=True, exist_ok=True)
213
+ path.write_text(json.dumps(ALLOWANCE_TEMPLATE, indent=2) + "\n", encoding="utf-8")
214
+ return path
215
+
216
+
217
+ def load_bundled_rate_card() -> dict[str, Any]:
218
+ """Load the package-bundled Codex credit rate-card snapshot."""
219
+
220
+ rate_card = (
221
+ resources.files("codex_usage_tracker.plugin_data")
222
+ .joinpath("rate_cards")
223
+ .joinpath("codex-credit-rates.json")
224
+ )
225
+ with rate_card.open("r", encoding="utf-8") as handle:
226
+ raw = json.load(handle)
227
+ if not isinstance(raw, dict):
228
+ raise ValueError("bundled Codex rate card must be a JSON object")
229
+ return raw
230
+
231
+
232
+ def update_rate_card(
233
+ path: Path = DEFAULT_RATE_CARD_PATH,
234
+ *,
235
+ source_file: Path | None = None,
236
+ ) -> RateCardUpdateResult:
237
+ """Write a validated Codex credit rate-card snapshot to the local config directory."""
238
+
239
+ raw = _load_json_file(source_file) if source_file is not None else load_bundled_rate_card()
240
+ schema = raw.get("schema") or raw.get("_schema")
241
+ if schema and schema != RATE_CARD_SCHEMA:
242
+ raise ValueError(f"unsupported Codex rate-card schema: {schema}")
243
+ source = parse_rate_card_source(raw)
244
+ credit_rates = parse_credit_rates(raw.get("credit_rates", {}))
245
+ aliases = parse_aliases(raw.get("aliases", {}))
246
+ if not credit_rates:
247
+ raise ValueError("rate card must contain at least one credit rate")
248
+ parse_credit_rate_metadata(raw.get("credit_rates", {}), source=source)
249
+ parse_alias_metadata(raw.get("aliases", {}), source=source)
250
+
251
+ path = path.expanduser()
252
+ path.parent.mkdir(parents=True, exist_ok=True)
253
+ backup_path = _backup_existing_rate_card(path)
254
+ tmp_path = path.with_name(f"{path.name}.tmp")
255
+ tmp_path.write_text(json.dumps(raw, indent=2, sort_keys=True) + "\n", encoding="utf-8")
256
+ tmp_path.replace(path)
257
+ return RateCardUpdateResult(
258
+ path=path,
259
+ source_url=_optional_str(source.get("url")),
260
+ fetched_at=_optional_str(source.get("fetched_at")),
261
+ model_count=len(credit_rates),
262
+ alias_count=len(aliases),
263
+ backup_path=backup_path,
264
+ )
265
+
266
+
267
+ def parse_allowance_text(
268
+ text: str,
269
+ *,
270
+ captured_at: str | None = None,
271
+ ) -> list[AllowanceWindow]:
272
+ """Parse pasted Codex usage text into allowance windows."""
273
+
274
+ captured = captured_at or _utc_now()
275
+ windows: list[AllowanceWindow] = []
276
+ for key, label, percent, reset_at in _allowance_line_matches(text):
277
+ windows.append(
278
+ AllowanceWindow(
279
+ key=key,
280
+ label=label,
281
+ remaining_percent=_optional_percent(percent),
282
+ reset_at=reset_at,
283
+ captured_at=captured,
284
+ )
285
+ )
286
+ return windows
287
+
288
+
289
+ def write_allowance_from_text(
290
+ text: str,
291
+ *,
292
+ path: Path = DEFAULT_ALLOWANCE_PATH,
293
+ force: bool = False,
294
+ captured_at: str | None = None,
295
+ ) -> Path:
296
+ """Update the local allowance-window file from pasted usage text."""
297
+
298
+ windows = parse_allowance_text(text, captured_at=captured_at)
299
+ if not windows:
300
+ raise ValueError("could not find 5h or weekly allowance percentages in pasted text")
301
+
302
+ path = path.expanduser()
303
+ if path.exists():
304
+ try:
305
+ payload = _load_json_file(path)
306
+ except (OSError, TypeError, json.JSONDecodeError, ValueError):
307
+ if not force:
308
+ raise
309
+ payload = json.loads(json.dumps(ALLOWANCE_TEMPLATE))
310
+ else:
311
+ payload = json.loads(json.dumps(ALLOWANCE_TEMPLATE))
312
+ payload["schema"] = ALLOWANCE_SCHEMA
313
+ payload["windows"] = [asdict(window) for window in windows]
314
+ payload["_source"] = {
315
+ "name": "Pasted Codex usage text",
316
+ "captured_at": windows[0].captured_at,
317
+ "exact_allowance_source": False,
318
+ "note": "Remaining percentages are user-copied from Codex UI or /status text.",
319
+ }
320
+ path.parent.mkdir(parents=True, exist_ok=True)
321
+ path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
322
+ return path
323
+
324
+
325
+ def annotate_rows_with_allowance(
326
+ rows: list[dict[str, Any]],
327
+ config: UsageAllowanceConfig | None = None,
328
+ *,
329
+ model_field: str = "model",
330
+ allowance_path: Path = DEFAULT_ALLOWANCE_PATH,
331
+ ) -> list[dict[str, Any]]:
332
+ """Return copied rows with Codex credit usage annotations."""
333
+
334
+ resolved = config or load_allowance_config(allowance_path)
335
+ annotated: list[dict[str, Any]] = []
336
+ for row in rows:
337
+ copy = dict(row)
338
+ model = copy.get(model_field)
339
+ match = resolve_credit_rate(model, resolved)
340
+ if match is None:
341
+ copy.update(
342
+ {
343
+ "usage_credits": None,
344
+ "usage_credit_model": None,
345
+ "usage_credit_confidence": "unpriced",
346
+ "usage_credit_source": "No Codex credit rate",
347
+ "usage_credit_source_url": None,
348
+ "usage_credit_fetched_at": None,
349
+ "usage_credit_tier": None,
350
+ "usage_credit_note": "No bundled or configured credit rate matched this model.",
351
+ }
352
+ )
353
+ else:
354
+ rated_model, rates, confidence, note, metadata = match
355
+ copy.update(
356
+ {
357
+ "usage_credits": estimate_usage_credits(copy, rates),
358
+ "usage_credit_model": rated_model,
359
+ "usage_credit_confidence": confidence,
360
+ "usage_credit_source": metadata.get("source_name")
361
+ or resolved.source.get("name", "Codex credit rates"),
362
+ "usage_credit_source_url": metadata.get("source_url"),
363
+ "usage_credit_fetched_at": metadata.get("fetched_at"),
364
+ "usage_credit_tier": metadata.get("tier"),
365
+ "usage_credit_note": note,
366
+ }
367
+ )
368
+ annotated.append(copy)
369
+ return annotated
370
+
371
+
372
+ def summarize_allowance_usage(
373
+ rows: list[dict[str, Any]], config: UsageAllowanceConfig | None = None
374
+ ) -> dict[str, Any]:
375
+ """Summarize Codex credit usage and configured allowance windows."""
376
+
377
+ resolved = config or load_allowance_config()
378
+ total_tokens = sum(_number(row.get("total_tokens")) for row in rows)
379
+ rated_tokens = sum(
380
+ _number(row.get("total_tokens"))
381
+ for row in rows
382
+ if row.get("usage_credits") is not None
383
+ )
384
+ usage_credits = sum(
385
+ _number(row.get("usage_credits"))
386
+ for row in rows
387
+ if row.get("usage_credits") is not None
388
+ )
389
+ estimated_credits = sum(
390
+ _number(row.get("usage_credits"))
391
+ for row in rows
392
+ if row.get("usage_credit_confidence") == "estimated"
393
+ )
394
+ override_credits = sum(
395
+ _number(row.get("usage_credits"))
396
+ for row in rows
397
+ if row.get("usage_credit_confidence") == "user_override"
398
+ )
399
+ exact_credits = sum(
400
+ _number(row.get("usage_credits"))
401
+ for row in rows
402
+ if row.get("usage_credit_confidence") == "exact"
403
+ )
404
+ return {
405
+ "usage_credits": usage_credits,
406
+ "exact_usage_credits": exact_credits,
407
+ "estimated_usage_credits": estimated_credits,
408
+ "user_override_usage_credits": override_credits,
409
+ "rated_tokens": rated_tokens,
410
+ "unrated_tokens": max(total_tokens - rated_tokens, 0.0),
411
+ "credit_token_ratio": rated_tokens / total_tokens if total_tokens else 0.0,
412
+ "windows": [asdict(window) for window in resolved.windows],
413
+ "source": resolved.source,
414
+ "configured": resolved.loaded,
415
+ "error": resolved.error,
416
+ "rate_card_loaded": resolved.rate_card_loaded,
417
+ "rate_card_error": resolved.rate_card_error,
418
+ }
419
+
420
+
421
+ def resolve_credit_rate(
422
+ model: object, config: UsageAllowanceConfig
423
+ ) -> tuple[str, dict[str, float], str, str, dict[str, Any]] | None:
424
+ """Resolve a model label into a credit rate, confidence, and note."""
425
+
426
+ normalized = _normalize_model(model)
427
+ if not normalized:
428
+ return None
429
+ direct = config.credit_rates.get(normalized)
430
+ if direct is not None:
431
+ metadata = config.rate_metadata.get(normalized, {})
432
+ confidence = _optional_str(metadata.get("confidence")) or "exact"
433
+ note = _optional_str(metadata.get("note")) or (
434
+ "Direct match to Codex credit rates."
435
+ if confidence != "user_override"
436
+ else "Direct match to local user-provided Codex credit rate."
437
+ )
438
+ return normalized, direct, confidence, note, metadata
439
+
440
+ alias = config.aliases.get(normalized)
441
+ if not alias:
442
+ return None
443
+ target = _normalize_model(alias.get("model"))
444
+ if not target:
445
+ return None
446
+ rates = config.credit_rates.get(target)
447
+ if rates is None:
448
+ return None
449
+ metadata = {**config.rate_metadata.get(target, {}), **config.alias_metadata.get(normalized, {})}
450
+ confidence = alias.get("confidence") or _optional_str(metadata.get("confidence")) or "estimated"
451
+ note = alias.get("note") or _optional_str(metadata.get("note")) or (
452
+ f"Mapped from {normalized} to {target} by local alias."
453
+ )
454
+ return target, rates, confidence, note, metadata
455
+
456
+
457
+ def estimate_usage_credits(row: dict[str, Any], rates: dict[str, float]) -> float:
458
+ """Estimate Codex credits from aggregate token counters."""
459
+
460
+ input_rate = rates["input_per_million"]
461
+ cached_rate = rates["cached_input_per_million"]
462
+ output_rate = rates["output_per_million"]
463
+ cached_input = _number(row.get("cached_input_tokens"))
464
+ uncached_input = _number(row.get("uncached_input_tokens"))
465
+ if uncached_input <= 0:
466
+ uncached_input = max(_number(row.get("input_tokens")) - cached_input, 0.0)
467
+ output_tokens = _number(row.get("output_tokens"))
468
+ return (
469
+ (uncached_input * input_rate)
470
+ + (cached_input * cached_rate)
471
+ + (output_tokens * output_rate)
472
+ ) / 1_000_000
473
+
474
+
475
+ def parse_credit_rates(raw: object) -> dict[str, dict[str, float]]:
476
+ if not isinstance(raw, dict):
477
+ return {}
478
+ parsed: dict[str, dict[str, float]] = {}
479
+ for model, rates in raw.items():
480
+ normalized = _normalize_model(model)
481
+ if not normalized or not isinstance(rates, dict):
482
+ continue
483
+ parsed[normalized] = {
484
+ "input_per_million": _required_rate(rates, "input_per_million", normalized),
485
+ "cached_input_per_million": _required_rate(
486
+ rates, "cached_input_per_million", normalized
487
+ ),
488
+ "output_per_million": _required_rate(rates, "output_per_million", normalized),
489
+ }
490
+ return parsed
491
+
492
+
493
+ def parse_aliases(raw: object) -> dict[str, dict[str, str]]:
494
+ if not isinstance(raw, dict):
495
+ return {}
496
+ parsed: dict[str, dict[str, str]] = {}
497
+ for source, target in raw.items():
498
+ source_model = _normalize_model(source)
499
+ if not source_model:
500
+ continue
501
+ if isinstance(target, str):
502
+ parsed[source_model] = {
503
+ "model": _normalize_model(target) or target,
504
+ "confidence": "estimated",
505
+ "note": f"Mapped from {source_model} by local allowance config.",
506
+ }
507
+ elif isinstance(target, dict):
508
+ target_model = _normalize_model(target.get("model"))
509
+ if not target_model:
510
+ continue
511
+ parsed[source_model] = {
512
+ "model": target_model,
513
+ "confidence": _optional_str(target.get("confidence")) or "estimated",
514
+ "note": _optional_str(target.get("note"))
515
+ or f"Mapped from {source_model} by local allowance config.",
516
+ }
517
+ return parsed
518
+
519
+
520
+ def parse_rate_card_source(raw: object) -> dict[str, Any]:
521
+ if not isinstance(raw, dict):
522
+ return dict(DEFAULT_SOURCE)
523
+ source = raw.get("source") or raw.get("_source")
524
+ if not isinstance(source, dict):
525
+ return dict(DEFAULT_SOURCE)
526
+ return {**DEFAULT_SOURCE, **source}
527
+
528
+
529
+ def parse_credit_rate_metadata(
530
+ raw: object,
531
+ *,
532
+ source: dict[str, Any],
533
+ default_confidence: str = "exact",
534
+ ) -> dict[str, dict[str, Any]]:
535
+ if not isinstance(raw, dict):
536
+ return {}
537
+ parsed: dict[str, dict[str, Any]] = {}
538
+ for model, rates in raw.items():
539
+ normalized = _normalize_model(model)
540
+ if not normalized or not isinstance(rates, dict):
541
+ continue
542
+ parsed[normalized] = {
543
+ "confidence": _optional_str(rates.get("confidence")) or default_confidence,
544
+ "source_name": _optional_str(rates.get("source_name"))
545
+ or _optional_str(source.get("name"))
546
+ or "Codex credit rates",
547
+ "source_url": _optional_str(rates.get("source_url"))
548
+ or _optional_str(source.get("url")),
549
+ "fetched_at": _optional_str(rates.get("fetched_at"))
550
+ or _optional_str(source.get("fetched_at")),
551
+ "tier": _optional_str(rates.get("tier")) or _optional_str(source.get("tier")),
552
+ "note": _optional_str(rates.get("note")),
553
+ }
554
+ return parsed
555
+
556
+
557
+ def parse_alias_metadata(raw: object, *, source: dict[str, Any]) -> dict[str, dict[str, Any]]:
558
+ if not isinstance(raw, dict):
559
+ return {}
560
+ parsed: dict[str, dict[str, Any]] = {}
561
+ for alias, target in raw.items():
562
+ normalized = _normalize_model(alias)
563
+ if not normalized:
564
+ continue
565
+ if isinstance(target, str):
566
+ parsed[normalized] = {
567
+ "confidence": "estimated",
568
+ "source_name": _optional_str(source.get("name")) or "Codex credit rates",
569
+ "source_url": _optional_str(source.get("url")),
570
+ "fetched_at": _optional_str(source.get("fetched_at")),
571
+ "tier": _optional_str(source.get("tier")),
572
+ "note": f"Mapped from {normalized} by local allowance config.",
573
+ }
574
+ elif isinstance(target, dict):
575
+ parsed[normalized] = {
576
+ "confidence": _optional_str(target.get("confidence")) or "estimated",
577
+ "source_name": _optional_str(target.get("source_name"))
578
+ or _optional_str(source.get("name"))
579
+ or "Codex credit rates",
580
+ "source_url": _optional_str(target.get("source_url"))
581
+ or _optional_str(source.get("url")),
582
+ "fetched_at": _optional_str(target.get("fetched_at"))
583
+ or _optional_str(source.get("fetched_at")),
584
+ "tier": _optional_str(target.get("tier")) or _optional_str(source.get("tier")),
585
+ "note": _optional_str(target.get("note")),
586
+ "alias_reason": _optional_str(target.get("alias_reason")),
587
+ }
588
+ return parsed
589
+
590
+
591
+ def parse_windows(raw: object) -> list[AllowanceWindow]:
592
+ if isinstance(raw, dict):
593
+ rows = [{**value, "key": key} for key, value in raw.items() if isinstance(value, dict)]
594
+ elif isinstance(raw, list):
595
+ rows = [value for value in raw if isinstance(value, dict)]
596
+ else:
597
+ rows = []
598
+
599
+ windows: list[AllowanceWindow] = []
600
+ for row in rows:
601
+ key = _optional_str(row.get("key"))
602
+ if not key:
603
+ continue
604
+ label = _optional_str(row.get("label")) or key.replace("_", " ").title()
605
+ windows.append(
606
+ AllowanceWindow(
607
+ key=key,
608
+ label=label,
609
+ total_credits=_optional_positive_number(row.get("total_credits")),
610
+ remaining_credits=_optional_positive_number(row.get("remaining_credits")),
611
+ remaining_percent=_optional_percent(row.get("remaining_percent")),
612
+ reset_at=_optional_str(row.get("reset_at")),
613
+ captured_at=_optional_str(row.get("captured_at")),
614
+ )
615
+ )
616
+ return windows
617
+
618
+
619
+ def _load_json_file(path: Path) -> dict[str, Any]:
620
+ raw = json.loads(path.expanduser().read_text(encoding="utf-8"))
621
+ if not isinstance(raw, dict):
622
+ raise ValueError(f"JSON config must be an object: {path}")
623
+ return raw
624
+
625
+
626
+ def _backup_existing_rate_card(path: Path) -> Path | None:
627
+ if not path.exists():
628
+ return None
629
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
630
+ backup_path = path.with_name(f"{path.name}.{stamp}.bak")
631
+ shutil.copy2(path, backup_path)
632
+ return backup_path
633
+
634
+
635
+ def _allowance_line_matches(text: str) -> list[tuple[str, str, str, str | None]]:
636
+ lines = [line.strip() for line in text.replace("\u00a0", " ").splitlines() if line.strip()]
637
+ matches: list[tuple[str, str, str, str | None]] = []
638
+ for line in lines:
639
+ match = _ALLOWANCE_LINE_RE.match(line)
640
+ if not match:
641
+ continue
642
+ key = _allowance_window_key(match.group("label"))
643
+ if key is None:
644
+ continue
645
+ reset_at = match.group("reset")
646
+ if reset_at and _ALLOWANCE_LABEL_RE.search(reset_at):
647
+ continue
648
+ matches.append(
649
+ (
650
+ key,
651
+ "5h" if key == "five_hour" else "Weekly",
652
+ match.group("percent"),
653
+ reset_at.strip() if reset_at and reset_at.strip() else None,
654
+ )
655
+ )
656
+ if matches:
657
+ return _dedupe_allowance_matches(matches)
658
+
659
+ flat = " ".join(text.replace("\u00a0", " ").split())
660
+ label_matches = list(_ALLOWANCE_LABEL_RE.finditer(flat))
661
+ for index, match in enumerate(label_matches):
662
+ key = _allowance_window_key(match.group(0))
663
+ if key is None:
664
+ continue
665
+ next_start = label_matches[index + 1].start() if index + 1 < len(label_matches) else len(flat)
666
+ segment = flat[match.end() : next_start].strip()
667
+ percent_match = _ALLOWANCE_PERCENT_RE.search(segment)
668
+ if percent_match is None:
669
+ continue
670
+ reset_at = segment[percent_match.end() :].strip()
671
+ matches.append(
672
+ (
673
+ key,
674
+ "5h" if key == "five_hour" else "Weekly",
675
+ percent_match.group("percent"),
676
+ reset_at or None,
677
+ )
678
+ )
679
+ return _dedupe_allowance_matches(matches)
680
+
681
+
682
+ def _dedupe_allowance_matches(
683
+ matches: list[tuple[str, str, str, str | None]],
684
+ ) -> list[tuple[str, str, str, str | None]]:
685
+ seen: set[str] = set()
686
+ deduped: list[tuple[str, str, str, str | None]] = []
687
+ for match in matches:
688
+ key = match[0]
689
+ if key in seen:
690
+ continue
691
+ seen.add(key)
692
+ deduped.append(match)
693
+ return deduped
694
+
695
+
696
+ def _allowance_window_key(label: str) -> str | None:
697
+ normalized = label.lower().replace("-", "_").replace(" ", "_")
698
+ if normalized in {"5h", "5_hour", "five_hour"}:
699
+ return "five_hour"
700
+ if normalized in {"weekly", "week"}:
701
+ return "weekly"
702
+ return None
703
+
704
+
705
+ def _normalize_model(value: object) -> str | None:
706
+ if not isinstance(value, str) or not value.strip():
707
+ return None
708
+ return value.strip().lower().replace("_", "-")
709
+
710
+
711
+ def _required_rate(raw: dict[str, Any], key: str, model: str) -> float:
712
+ parsed = _optional_positive_number(raw.get(key))
713
+ if parsed is None:
714
+ raise ValueError(f"missing {key} for Codex credit model {model}")
715
+ return parsed
716
+
717
+
718
+ def _optional_positive_number(value: object) -> float | None:
719
+ if value is None or value == "":
720
+ return None
721
+ number = _number(value)
722
+ if number < 0:
723
+ raise ValueError("allowance values cannot be negative")
724
+ return number
725
+
726
+
727
+ def _optional_percent(value: object) -> float | None:
728
+ parsed = _optional_positive_number(value)
729
+ if parsed is None:
730
+ return None
731
+ return parsed / 100 if parsed > 1 else parsed
732
+
733
+
734
+ def _optional_str(value: object) -> str | None:
735
+ return value if isinstance(value, str) and value.strip() else None
736
+
737
+
738
+ def _number(value: object) -> float:
739
+ if isinstance(value, bool):
740
+ return float(int(value))
741
+ if isinstance(value, int | float):
742
+ return float(value)
743
+ if isinstance(value, str) and value.strip():
744
+ return float(value)
745
+ return 0.0
746
+
747
+
748
+ def _utc_now() -> str:
749
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
750
+
751
+
752
+ _ALLOWANCE_LINE_RE = re.compile(
753
+ r"^(?P<label>5h|5-hour|five-hour|weekly|week)\s+"
754
+ r"(?P<percent>\d+(?:\.\d+)?)\s*%"
755
+ r"(?:\s+(?P<reset>.+?))?\s*$",
756
+ re.IGNORECASE,
757
+ )
758
+ _ALLOWANCE_LABEL_RE = re.compile(r"\b(?:5h|5-hour|five-hour|weekly|week)\b", re.IGNORECASE)
759
+ _ALLOWANCE_PERCENT_RE = re.compile(r"(?P<percent>\d+(?:\.\d+)?)\s*%")