codex-usage-epd 2026.10.1__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_usage_epd/__init__.py +3 -0
- codex_usage_epd/__main__.py +4 -0
- codex_usage_epd/api.py +258 -0
- codex_usage_epd/ble.py +345 -0
- codex_usage_epd/cli.py +270 -0
- codex_usage_epd/codex_usage_epd.yaml.example +30 -0
- codex_usage_epd/config.py +127 -0
- codex_usage_epd/model.py +73 -0
- codex_usage_epd/render.py +321 -0
- codex_usage_epd/rle.py +91 -0
- codex_usage_epd/token_usage.py +438 -0
- codex_usage_epd-2026.10.1.dist-info/METADATA +179 -0
- codex_usage_epd-2026.10.1.dist-info/RECORD +16 -0
- codex_usage_epd-2026.10.1.dist-info/WHEEL +4 -0
- codex_usage_epd-2026.10.1.dist-info/entry_points.txt +2 -0
- codex_usage_epd-2026.10.1.dist-info/licenses/LICENSE +661 -0
codex_usage_epd/cli.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""codex-usage-epd: fetch OpenAI Codex usage and push it to an e-paper display.
|
|
3
|
+
|
|
4
|
+
Primary data source: OAuth `wham/usage` endpoint (see CodexBar).
|
|
5
|
+
Display protocol is compatible with the EPD-nRF5 web client (RLE + bitplanes).
|
|
6
|
+
|
|
7
|
+
Commands:
|
|
8
|
+
--selftest render sample data + verify RLE round-trip (no network/BLE)
|
|
9
|
+
--dry-run fetch + render preview.png, print summary (no BLE)
|
|
10
|
+
--once fetch + render + push to display once
|
|
11
|
+
--loop push every N minutes forever
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import asyncio
|
|
18
|
+
import json
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from .api import (
|
|
24
|
+
UsageFetchError,
|
|
25
|
+
build_usage_url,
|
|
26
|
+
fetch_usage,
|
|
27
|
+
parse_usage,
|
|
28
|
+
read_auth,
|
|
29
|
+
sample_balance,
|
|
30
|
+
)
|
|
31
|
+
from .ble import BlePushError, probe, push_display, test_screen
|
|
32
|
+
from .config import expand_user, load_config
|
|
33
|
+
from .render import image_to_planes, planes_to_rgb, render_dashboard, resolve_font_path
|
|
34
|
+
from .token_usage import read_token_usage
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_args(argv: list[str]) -> argparse.Namespace:
|
|
38
|
+
p = argparse.ArgumentParser(description="Codex usage -> e-paper display")
|
|
39
|
+
p.add_argument("--config", default=None, help="path to YAML config (default: repo config/ or bundled)")
|
|
40
|
+
p.add_argument("--debug", action="store_true", help="dump raw wham/usage JSON to tmp/usage_dump.json")
|
|
41
|
+
p.add_argument("--font", default=None, help="override render.font")
|
|
42
|
+
p.add_argument("--show-history", action=argparse.BooleanOptionalAction, default=None,
|
|
43
|
+
help="show the Last 7 days token chart (overrides render.show_history)")
|
|
44
|
+
p.add_argument("--sample", action="store_true", help="use synthetic sample data (no network)")
|
|
45
|
+
p.add_argument("--force", action="store_true", help="with --init: overwrite an existing config")
|
|
46
|
+
g = p.add_mutually_exclusive_group()
|
|
47
|
+
g.add_argument("--init", action="store_true", help="generate a config from the template (no network/device)")
|
|
48
|
+
g.add_argument("--selftest", action="store_true", help="render sample data, no network/device")
|
|
49
|
+
g.add_argument("--dry-run", action="store_true", help="fetch + render preview.png, no BLE")
|
|
50
|
+
g.add_argument("--probe", action="store_true", help="connect + INIT, print device config/mtu, no image")
|
|
51
|
+
g.add_argument("--test-screen", action="store_true", help="INIT + CLEAR full refresh, check firmware path")
|
|
52
|
+
g.add_argument("--once", action="store_true", help="fetch + render + push once")
|
|
53
|
+
g.add_argument("--loop", action="store_true", help="push every N minutes forever")
|
|
54
|
+
return p.parse_args(argv)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def load_balance(args: argparse.Namespace, cfg: dict) -> tuple:
|
|
58
|
+
"""Return (balance, raw_dict_or_None)."""
|
|
59
|
+
codex = cfg["codex"]
|
|
60
|
+
auth_file = expand_user(codex["auth_file"])
|
|
61
|
+
access_token, account_id = read_auth(Path(auth_file))
|
|
62
|
+
url = build_usage_url(Path(auth_file).parent)
|
|
63
|
+
raw = fetch_usage(access_token, account_id, url, timeout=codex.get("timeout", 15.0))
|
|
64
|
+
if args.debug:
|
|
65
|
+
dump_dir = Path(__file__).resolve().parent.parent / "tmp"
|
|
66
|
+
dump_dir.mkdir(exist_ok=True)
|
|
67
|
+
dump_path = dump_dir / "usage_dump.json"
|
|
68
|
+
dump_path.write_text(json.dumps(raw, indent=2))
|
|
69
|
+
print(f"[debug] raw wham/usage JSON -> {dump_path}")
|
|
70
|
+
balance = parse_usage(raw)
|
|
71
|
+
balance.models, balance.daily_usage = read_token_usage(
|
|
72
|
+
Path(auth_file).parent, now=balance.fetched_at, limit=None,
|
|
73
|
+
history_days=7 if cfg["render"].get("show_history", False) else 0,
|
|
74
|
+
)
|
|
75
|
+
return balance, raw
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def render(balance, cfg: dict, font_path: str) -> tuple[bytes, bytes]:
|
|
79
|
+
render_cfg = cfg["render"]
|
|
80
|
+
img = render_dashboard(
|
|
81
|
+
balance,
|
|
82
|
+
font_path=font_path,
|
|
83
|
+
warn_threshold=render_cfg.get("warn_threshold", 20.0),
|
|
84
|
+
show_history=render_cfg.get("show_history", False),
|
|
85
|
+
width=cfg["display"]["width"],
|
|
86
|
+
height=cfg["display"]["height"],
|
|
87
|
+
)
|
|
88
|
+
preview = render_cfg.get("preview", "preview.png")
|
|
89
|
+
img.save(preview)
|
|
90
|
+
print(f"[render] saved {preview} ({img.size[0]}x{img.size[1]})")
|
|
91
|
+
return image_to_planes(img)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def run_selftest(cfg: dict, font_path: str) -> int:
|
|
95
|
+
balance = sample_balance()
|
|
96
|
+
render_cfg = cfg["render"]
|
|
97
|
+
img = render_dashboard(
|
|
98
|
+
balance,
|
|
99
|
+
font_path=font_path,
|
|
100
|
+
warn_threshold=render_cfg.get("warn_threshold", 20.0),
|
|
101
|
+
show_history=render_cfg.get("show_history", False),
|
|
102
|
+
width=cfg["display"]["width"],
|
|
103
|
+
height=cfg["display"]["height"],
|
|
104
|
+
)
|
|
105
|
+
preview = render_cfg.get("preview", "preview.png")
|
|
106
|
+
img.save(preview)
|
|
107
|
+
bw, red = image_to_planes(img)
|
|
108
|
+
print(f"[selftest] rendered {img.size[0]}x{img.size[1]} -> {preview}")
|
|
109
|
+
print(f"[selftest] planes: bw={len(bw)}B red={len(red)}B")
|
|
110
|
+
|
|
111
|
+
# RLE round-trip: compress -> decompress == original, chunks self-contained
|
|
112
|
+
from .rle import _rle_decompressed, rle_chunks
|
|
113
|
+
|
|
114
|
+
chunk_size = 242 # MTU 247 - 5
|
|
115
|
+
for name, plane in (("bw", bw), ("red", red)):
|
|
116
|
+
chunks = rle_chunks(plane, chunk_size)
|
|
117
|
+
for i, ch in enumerate(chunks):
|
|
118
|
+
if len(ch) > chunk_size:
|
|
119
|
+
print(f"[selftest] FAIL chunk {name}[{i}] {len(ch)} > {chunk_size}")
|
|
120
|
+
return 1
|
|
121
|
+
joined = b"".join(chunks)
|
|
122
|
+
decoded = _rle_decompressed(joined, len(plane))
|
|
123
|
+
if decoded != plane:
|
|
124
|
+
print(f"[selftest] FAIL {name} RLE round-trip ({len(decoded)} != {len(plane)})")
|
|
125
|
+
return 1
|
|
126
|
+
print(f"[selftest] ok {name}: {len(plane)}B -> {len(joined)}B RLE in {len(chunks)} chunks")
|
|
127
|
+
|
|
128
|
+
# plane round-trip: every pure black/white/red pixel must decode back
|
|
129
|
+
back = planes_to_rgb(bw, red, img.size[0], img.size[1])
|
|
130
|
+
pa, pb = img.load(), back.load()
|
|
131
|
+
assert pa is not None and pb is not None
|
|
132
|
+
bad = 0
|
|
133
|
+
checked = 0
|
|
134
|
+
for yy in range(img.size[1]):
|
|
135
|
+
for xx in range(img.size[0]):
|
|
136
|
+
a = pa[xx, yy]
|
|
137
|
+
if a in ((0, 0, 0), (255, 255, 255), (255, 0, 0)):
|
|
138
|
+
checked += 1
|
|
139
|
+
if pb[xx, yy] != a:
|
|
140
|
+
bad += 1
|
|
141
|
+
print(f"[selftest] pure-colour round-trip: {checked} px checked, {bad} wrong")
|
|
142
|
+
if bad == 0:
|
|
143
|
+
print("[selftest] PASS")
|
|
144
|
+
return 0
|
|
145
|
+
print("[selftest] FAIL: bitplane encode/decode mismatch")
|
|
146
|
+
return 1
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def run_push(planes, cfg: dict) -> None:
|
|
150
|
+
ble = cfg["ble"]
|
|
151
|
+
display = cfg["display"]
|
|
152
|
+
await push_display(
|
|
153
|
+
planes,
|
|
154
|
+
model_id=int(display["model_id"]),
|
|
155
|
+
device=ble["device"],
|
|
156
|
+
mtu=int(ble.get("mtu", 247)),
|
|
157
|
+
scan_timeout=float(ble.get("scan_timeout", 10.0)),
|
|
158
|
+
scan_retries=int(ble.get("scan_retries", 3)),
|
|
159
|
+
interleave=int(ble.get("interleave", 50)),
|
|
160
|
+
sleep_after_push=bool(display.get("sleep_after_push", False)),
|
|
161
|
+
patch_wakeup_pin=bool(ble.get("patch_wakeup_pin", True)),
|
|
162
|
+
pacing_ms=float(ble.get("pacing_ms", 0.0)),
|
|
163
|
+
hold_after_refresh=float(ble.get("hold_after_refresh", 15.0)),
|
|
164
|
+
slot=ble.get("slot", 0),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
async def run_probe(cfg: dict) -> None:
|
|
169
|
+
ble = cfg["ble"]
|
|
170
|
+
display = cfg["display"]
|
|
171
|
+
await probe(
|
|
172
|
+
device=ble["device"],
|
|
173
|
+
model_id=int(display["model_id"]),
|
|
174
|
+
mtu=int(ble.get("mtu", 247)),
|
|
175
|
+
scan_timeout=float(ble.get("scan_timeout", 10.0)),
|
|
176
|
+
scan_retries=int(ble.get("scan_retries", 3)),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
async def run_test_screen(cfg: dict) -> None:
|
|
181
|
+
ble = cfg["ble"]
|
|
182
|
+
display = cfg["display"]
|
|
183
|
+
await test_screen(
|
|
184
|
+
device=ble["device"],
|
|
185
|
+
model_id=int(display["model_id"]),
|
|
186
|
+
mtu=int(ble.get("mtu", 247)),
|
|
187
|
+
scan_timeout=float(ble.get("scan_timeout", 10.0)),
|
|
188
|
+
scan_retries=int(ble.get("scan_retries", 3)),
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def main(argv: list[str] | None = None) -> int:
|
|
193
|
+
args = parse_args(argv if argv is not None else sys.argv[1:])
|
|
194
|
+
|
|
195
|
+
if args.init:
|
|
196
|
+
try:
|
|
197
|
+
from .config import init_config
|
|
198
|
+
|
|
199
|
+
dest = init_config(args.config, force=args.force)
|
|
200
|
+
except FileExistsError as e:
|
|
201
|
+
print(f"[error] {e}", file=sys.stderr)
|
|
202
|
+
return 1
|
|
203
|
+
print(f"[init] wrote {dest}")
|
|
204
|
+
return 0
|
|
205
|
+
|
|
206
|
+
cfg = load_config(args.config)
|
|
207
|
+
if args.show_history is not None:
|
|
208
|
+
cfg["render"]["show_history"] = args.show_history
|
|
209
|
+
print(f"[cfg] using {cfg['_path']}")
|
|
210
|
+
|
|
211
|
+
if args.selftest:
|
|
212
|
+
font_path = resolve_font_path(args.font or cfg["render"].get("font", ""))
|
|
213
|
+
return run_selftest(cfg, font_path)
|
|
214
|
+
|
|
215
|
+
if args.probe:
|
|
216
|
+
try:
|
|
217
|
+
asyncio.run(run_probe(cfg))
|
|
218
|
+
return 0
|
|
219
|
+
except BlePushError as e:
|
|
220
|
+
print(f"[error] {e}", file=sys.stderr)
|
|
221
|
+
return 1
|
|
222
|
+
|
|
223
|
+
if args.test_screen:
|
|
224
|
+
try:
|
|
225
|
+
asyncio.run(run_test_screen(cfg))
|
|
226
|
+
return 0
|
|
227
|
+
except BlePushError as e:
|
|
228
|
+
print(f"[error] {e}", file=sys.stderr)
|
|
229
|
+
return 1
|
|
230
|
+
|
|
231
|
+
def do_once() -> int:
|
|
232
|
+
if args.sample:
|
|
233
|
+
balance = sample_balance()
|
|
234
|
+
print("[fetch] using synthetic sample snapshot")
|
|
235
|
+
else:
|
|
236
|
+
balance, _raw = load_balance(args, cfg)
|
|
237
|
+
print("[fetch] usage snapshot:")
|
|
238
|
+
print(balance.summary())
|
|
239
|
+
font_path = resolve_font_path(args.font or cfg["render"].get("font", ""))
|
|
240
|
+
planes = render(balance, cfg, font_path)
|
|
241
|
+
if args.dry_run:
|
|
242
|
+
print("[dry-run] skipping BLE push")
|
|
243
|
+
return 0
|
|
244
|
+
asyncio.run(run_push(planes, cfg))
|
|
245
|
+
return 0
|
|
246
|
+
|
|
247
|
+
if args.dry_run or args.once:
|
|
248
|
+
try:
|
|
249
|
+
return do_once()
|
|
250
|
+
except (UsageFetchError, BlePushError) as e:
|
|
251
|
+
print(f"[error] {e}", file=sys.stderr)
|
|
252
|
+
return 1
|
|
253
|
+
|
|
254
|
+
if args.loop:
|
|
255
|
+
interval = int(cfg["schedule"].get("interval_minutes", 5)) * 60
|
|
256
|
+
print(f"[loop] pushing every {interval // 60} min (Ctrl-C to stop)")
|
|
257
|
+
while True:
|
|
258
|
+
try:
|
|
259
|
+
do_once()
|
|
260
|
+
except (UsageFetchError, BlePushError) as e:
|
|
261
|
+
print(f"[error] {e}", file=sys.stderr)
|
|
262
|
+
time.sleep(interval)
|
|
263
|
+
return 0
|
|
264
|
+
|
|
265
|
+
print("no action given (use --selftest / --probe / --dry-run / --once / --loop)", file=sys.stderr)
|
|
266
|
+
return 2
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
if __name__ == "__main__":
|
|
270
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
display:
|
|
2
|
+
width: 400
|
|
3
|
+
height: 300
|
|
4
|
+
model_id: 0x02 # SSD1683/SSD1619 400x300 B/W/R
|
|
5
|
+
sleep_after_push: false # web client never sends SLEEP; early sleep aborts refresh
|
|
6
|
+
|
|
7
|
+
ble:
|
|
8
|
+
device: "auto" # "auto" (scan NRF_EPD) | MAC address | name substring
|
|
9
|
+
mtu: 247
|
|
10
|
+
scan_timeout: 10 # seconds per scan attempt
|
|
11
|
+
scan_retries: 3 # retry the scan before giving up (missed adv packets)
|
|
12
|
+
interleave: 50 # match web client: 50 writes w/o response, then 1 with
|
|
13
|
+
patch_wakeup_pin: true
|
|
14
|
+
pacing_ms: 0.0 # no pacing (web client sends back-to-back)
|
|
15
|
+
hold_after_refresh: 20.0 # stay connected until panel busy refresh completes
|
|
16
|
+
slot: 0 # epdiy.cn slot firmware: image slot to write (0..N-1);
|
|
17
|
+
# "auto" = first free slot; -1 / "none" = no SET_SLOT cmd
|
|
18
|
+
|
|
19
|
+
codex:
|
|
20
|
+
auth_file: "~/.codex/auth.json"
|
|
21
|
+
timeout: 15
|
|
22
|
+
|
|
23
|
+
render:
|
|
24
|
+
font: "" # empty = auto-detect; or path to a .ttf
|
|
25
|
+
warn_threshold: 20 # remaining % below this renders the bar red
|
|
26
|
+
preview: "preview.png"
|
|
27
|
+
show_history: false # opt in to the Last 7 days chart beside today's usage
|
|
28
|
+
|
|
29
|
+
schedule:
|
|
30
|
+
interval_minutes: 60 # used by --loop; launchd uses its plist StartInterval
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Config + CLI helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
DEFAULT_CONFIG_NAME = "codex_usage_epd.yaml"
|
|
8
|
+
TEMPLATE_NAME = "codex_usage_epd.yaml.example"
|
|
9
|
+
|
|
10
|
+
USER_CONFIG_DIR = "~/.config/codex-usage-epd"
|
|
11
|
+
|
|
12
|
+
_DEFAULTS = {
|
|
13
|
+
"display": {
|
|
14
|
+
"width": 400,
|
|
15
|
+
"height": 300,
|
|
16
|
+
"model_id": 0x02,
|
|
17
|
+
"sleep_after_push": False,
|
|
18
|
+
},
|
|
19
|
+
"ble": {
|
|
20
|
+
"device": "auto",
|
|
21
|
+
"mtu": 247,
|
|
22
|
+
"scan_timeout": 10.0,
|
|
23
|
+
"scan_retries": 3,
|
|
24
|
+
"interleave": 50,
|
|
25
|
+
"patch_wakeup_pin": True,
|
|
26
|
+
"pacing_ms": 0.0,
|
|
27
|
+
"hold_after_refresh": 20.0,
|
|
28
|
+
"slot": 0,
|
|
29
|
+
},
|
|
30
|
+
"codex": {
|
|
31
|
+
"auth_file": "~/.codex/auth.json",
|
|
32
|
+
"timeout": 15.0,
|
|
33
|
+
},
|
|
34
|
+
"render": {
|
|
35
|
+
"font": "",
|
|
36
|
+
"warn_threshold": 20.0,
|
|
37
|
+
"preview": "preview.png",
|
|
38
|
+
"show_history": False,
|
|
39
|
+
},
|
|
40
|
+
"schedule": {
|
|
41
|
+
"interval_minutes": 5,
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _bundled_template_text() -> str:
|
|
47
|
+
repo = Path(__file__).resolve().parent.parent / "config" / TEMPLATE_NAME
|
|
48
|
+
if repo.exists():
|
|
49
|
+
return repo.read_text(encoding="utf-8")
|
|
50
|
+
import importlib.resources
|
|
51
|
+
|
|
52
|
+
res = importlib.resources.files("codex_usage_epd").joinpath(TEMPLATE_NAME)
|
|
53
|
+
return res.read_text(encoding="utf-8")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def init_config(path: str | None = None, force: bool = False) -> str:
|
|
57
|
+
"""Generate a real config file from the bundled template.
|
|
58
|
+
|
|
59
|
+
Defaults to <repo root>/config/codex_usage_epd.yaml when the repo checkout
|
|
60
|
+
is present, otherwise to ~/.config/codex-usage-epd/codex_usage_epd.yaml.
|
|
61
|
+
Refuses to overwrite an existing file unless force=True.
|
|
62
|
+
Returns the path written.
|
|
63
|
+
"""
|
|
64
|
+
dest = _default_config_path(path)
|
|
65
|
+
if dest.exists() and not force:
|
|
66
|
+
raise FileExistsError(f"config already exists: {dest} (use --force to overwrite)")
|
|
67
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
dest.write_text(_bundled_template_text())
|
|
69
|
+
return str(dest)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _default_config_path(path: str | None) -> Path:
|
|
73
|
+
if path:
|
|
74
|
+
return Path(path)
|
|
75
|
+
repo = Path(__file__).resolve().parent.parent / "config" / DEFAULT_CONFIG_NAME
|
|
76
|
+
if repo.parent.exists():
|
|
77
|
+
return repo
|
|
78
|
+
return Path(USER_CONFIG_DIR).expanduser() / DEFAULT_CONFIG_NAME
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def load_config(path: str | None) -> dict:
|
|
82
|
+
import yaml
|
|
83
|
+
|
|
84
|
+
if path:
|
|
85
|
+
cfg_path = Path(path)
|
|
86
|
+
if not cfg_path.exists():
|
|
87
|
+
raise FileNotFoundError(f"config not found: {cfg_path}")
|
|
88
|
+
else:
|
|
89
|
+
# 1) repo checkout layout: <repo root>/config/codex_usage_epd.yaml
|
|
90
|
+
repo_cfg = Path(__file__).resolve().parent.parent / "config" / DEFAULT_CONFIG_NAME
|
|
91
|
+
# 2) installed package: ~/.config/codex-usage-epd/codex_usage_epd.yaml
|
|
92
|
+
user_cfg = Path(USER_CONFIG_DIR).expanduser() / DEFAULT_CONFIG_NAME
|
|
93
|
+
if repo_cfg.exists():
|
|
94
|
+
cfg_path = repo_cfg
|
|
95
|
+
elif user_cfg.exists():
|
|
96
|
+
cfg_path = user_cfg
|
|
97
|
+
else:
|
|
98
|
+
# 3) bundled template as the fallback default
|
|
99
|
+
return _merge(yaml.safe_load(_bundled_template_text()) or {}, f"<bundled>:{TEMPLATE_NAME}")
|
|
100
|
+
with cfg_path.open() as f:
|
|
101
|
+
user_cfg = yaml.safe_load(f) or {}
|
|
102
|
+
return _merge(user_cfg, str(cfg_path))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _merge(user_cfg: dict, src: str) -> dict:
|
|
106
|
+
merged = {}
|
|
107
|
+
for section, defaults in _DEFAULTS.items():
|
|
108
|
+
merged[section] = dict(defaults)
|
|
109
|
+
if isinstance(user_cfg.get(section), dict):
|
|
110
|
+
merged[section].update(user_cfg[section])
|
|
111
|
+
|
|
112
|
+
# top-level flat keys are accepted too
|
|
113
|
+
for key, value in user_cfg.items():
|
|
114
|
+
if key not in _DEFAULTS:
|
|
115
|
+
merged[key] = value
|
|
116
|
+
|
|
117
|
+
merged["_path"] = src
|
|
118
|
+
return merged
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def expand_user(text: str) -> str:
|
|
122
|
+
return str(Path(text).expanduser())
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def resolve_codex_home() -> Path:
|
|
126
|
+
override = __import__("os").environ.get("CODEX_HOME")
|
|
127
|
+
return Path(override) if override else Path.home() / ".codex"
|
codex_usage_epd/model.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Data model for Codex usage quota snapshots.
|
|
2
|
+
|
|
3
|
+
Quota field names mirror the `wham/usage` payload decoded by CodexBar's
|
|
4
|
+
CodexOAuthUsageFetcher. Per-model token totals come from today's local Codex
|
|
5
|
+
session logs, like CodexBar's local token-history view.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from datetime import date, datetime
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Window:
|
|
16
|
+
name: str # window identity e.g. "five_hour", "weekly"
|
|
17
|
+
label: str # short display label e.g. "5H", "WK"
|
|
18
|
+
used_percent: float # 0..100 used
|
|
19
|
+
resets_at: int | None # unix epoch seconds
|
|
20
|
+
limit_seconds: int | None
|
|
21
|
+
reset_after_seconds: int | None = None
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def remaining_percent(self) -> float:
|
|
25
|
+
return max(0.0, min(100.0, 100.0 - self.used_percent))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class ModelTokenUsage:
|
|
30
|
+
id: str # e.g. "gpt-5.6-sol"
|
|
31
|
+
tokens: int
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class DailyTokenUsage:
|
|
36
|
+
day: date
|
|
37
|
+
tokens: int
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Balance:
|
|
42
|
+
plan_type: str | None
|
|
43
|
+
windows: list[Window] = field(default_factory=list) # global windows
|
|
44
|
+
models: list[ModelTokenUsage] = field(default_factory=list) # all today's models, ranked
|
|
45
|
+
credits_balance: float | None = None
|
|
46
|
+
credits_unlimited: bool = False
|
|
47
|
+
has_credits: bool = False
|
|
48
|
+
fetched_at: datetime = field(default_factory=datetime.now)
|
|
49
|
+
source: str = "oauth" # "oauth" | "sample"
|
|
50
|
+
daily_usage: list[DailyTokenUsage] = field(default_factory=list)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def total_tokens_today(self) -> int:
|
|
54
|
+
return sum(model.tokens for model in self.models)
|
|
55
|
+
|
|
56
|
+
def window(self, name: str) -> Window | None:
|
|
57
|
+
for w in self.windows:
|
|
58
|
+
if w.name == name:
|
|
59
|
+
return w
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
def summary(self) -> str:
|
|
63
|
+
lines = [f"plan: {self.plan_type}"]
|
|
64
|
+
for w in self.windows:
|
|
65
|
+
lines.append(f" {w.name}: used {w.used_percent:.1f}% remaining {w.remaining_percent:.1f}%")
|
|
66
|
+
lines.append(f" today Total: {self.total_tokens_today} tokens")
|
|
67
|
+
for m in self.models:
|
|
68
|
+
lines.append(f" today {m.id}: {m.tokens} tokens")
|
|
69
|
+
for day in self.daily_usage:
|
|
70
|
+
lines.append(f" {day.day.isoformat()}: {day.tokens} tokens")
|
|
71
|
+
if self.has_credits:
|
|
72
|
+
lines.append(f" credits: balance={self.credits_balance} unlimited={self.credits_unlimited}")
|
|
73
|
+
return "\n".join(lines)
|