init
This commit is contained in:
311
bin/codex-waybar
Executable file
311
bin/codex-waybar
Executable file
@@ -0,0 +1,311 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Waybar custom module for Codex account rate limits."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import selectors
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from string import Formatter
|
||||
from typing import Any
|
||||
|
||||
FIVE_HOUR_MINS = 300
|
||||
WEEKLY_MINS = 10_080
|
||||
DEFAULT_FORMAT = "5H {five_hour_used}% · W {weekly_used}% · {five_hour_reset_in}"
|
||||
DEFAULT_TOOLTIP = (
|
||||
"Codex ({plan})\n"
|
||||
"5-hour: {five_hour_used}% used, resets {five_hour_reset_at} ({five_hour_reset_in})\n"
|
||||
"Weekly: {weekly_used}% used, resets {weekly_reset_at} ({weekly_reset_in})"
|
||||
)
|
||||
ALLOWED_FIELDS = {
|
||||
"five_hour_used",
|
||||
"weekly_used",
|
||||
"five_hour_reset_in",
|
||||
"weekly_reset_in",
|
||||
"five_hour_reset_at",
|
||||
"weekly_reset_at",
|
||||
"plan",
|
||||
}
|
||||
|
||||
|
||||
class UsageError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def cache_path() -> Path:
|
||||
root = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
|
||||
return root / "codex-waybar" / "rate-limits.json"
|
||||
|
||||
|
||||
def parse_json_lines(output: str, response_id: int = 2) -> dict[str, Any]:
|
||||
for line in output.splitlines():
|
||||
try:
|
||||
message = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if message.get("id") == response_id:
|
||||
if "error" in message:
|
||||
raise UsageError(str(message["error"]))
|
||||
result = message.get("result")
|
||||
if isinstance(result, dict):
|
||||
return result
|
||||
raise UsageError("Codex returned an invalid rate-limit result")
|
||||
raise UsageError("Codex did not return rate-limit data")
|
||||
|
||||
|
||||
def fetch_rate_limits(timeout: float, codex_command: str = "codex") -> dict[str, Any]:
|
||||
requests = [
|
||||
{
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"clientInfo": {"name": "codex-waybar", "version": "1.0.0"},
|
||||
},
|
||||
},
|
||||
{"method": "initialized"},
|
||||
{"id": 2, "method": "account/rateLimits/read"},
|
||||
]
|
||||
payload = "\n".join(json.dumps(item, separators=(",", ":")) for item in requests) + "\n"
|
||||
process = None
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
[codex_command, "app-server"],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
assert process.stdin is not None
|
||||
assert process.stdout is not None
|
||||
process.stdin.write(payload)
|
||||
process.stdin.flush()
|
||||
|
||||
deadline = time.monotonic() + timeout
|
||||
selector = selectors.DefaultSelector()
|
||||
selector.register(process.stdout, selectors.EVENT_READ)
|
||||
while time.monotonic() < deadline:
|
||||
events = selector.select(deadline - time.monotonic())
|
||||
if not events:
|
||||
break
|
||||
line = process.stdout.readline()
|
||||
if not line:
|
||||
break
|
||||
try:
|
||||
message = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if message.get("id") == 2:
|
||||
return parse_json_lines(line)
|
||||
raise UsageError("Codex app-server timed out or returned no rate-limit data")
|
||||
except FileNotFoundError as exc:
|
||||
raise UsageError(f"{codex_command!r} was not found") from exc
|
||||
finally:
|
||||
if process is not None:
|
||||
if process.stdin:
|
||||
try:
|
||||
process.stdin.close()
|
||||
except OSError:
|
||||
pass
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait()
|
||||
|
||||
|
||||
def select_snapshot(result: dict[str, Any]) -> dict[str, Any]:
|
||||
by_id = result.get("rateLimitsByLimitId")
|
||||
if isinstance(by_id, dict):
|
||||
codex = by_id.get("codex")
|
||||
if isinstance(codex, dict):
|
||||
return codex
|
||||
for value in by_id.values():
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
snapshot = result.get("rateLimits")
|
||||
if isinstance(snapshot, dict):
|
||||
return snapshot
|
||||
raise UsageError("No Codex rate-limit snapshot is available")
|
||||
|
||||
|
||||
def select_windows(snapshot: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
windows = [
|
||||
window
|
||||
for key in ("primary", "secondary")
|
||||
if isinstance((window := snapshot.get(key)), dict)
|
||||
]
|
||||
by_duration = {window.get("windowDurationMins"): window for window in windows}
|
||||
five_hour = by_duration.get(FIVE_HOUR_MINS)
|
||||
weekly = by_duration.get(WEEKLY_MINS)
|
||||
if not isinstance(five_hour, dict) or not isinstance(weekly, dict):
|
||||
raise UsageError("The 5-hour or weekly Codex limit window is unavailable")
|
||||
return five_hour, weekly
|
||||
|
||||
|
||||
def load_cache(path: Path) -> dict[str, Any] | None:
|
||||
try:
|
||||
value = json.loads(path.read_text())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
|
||||
def save_cache(path: Path, result: dict[str, Any]) -> None:
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_suffix(".tmp")
|
||||
temporary.write_text(json.dumps({"saved_at": int(time.time()), "result": result}))
|
||||
temporary.replace(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def human_countdown(resets_at: Any, now: int) -> str:
|
||||
if not isinstance(resets_at, int):
|
||||
return "unknown"
|
||||
remaining = max(0, resets_at - now)
|
||||
days, remainder = divmod(remaining, 86_400)
|
||||
hours, remainder = divmod(remainder, 3_600)
|
||||
minutes = (remainder + 59) // 60
|
||||
if minutes == 60:
|
||||
hours += 1
|
||||
minutes = 0
|
||||
if hours == 24:
|
||||
days += 1
|
||||
hours = 0
|
||||
if days:
|
||||
return f"{days}d {hours}h"
|
||||
if hours:
|
||||
return f"{hours}h {minutes:02d}m"
|
||||
return f"{minutes}m"
|
||||
|
||||
|
||||
def reset_time(resets_at: Any) -> str:
|
||||
if not isinstance(resets_at, int):
|
||||
return "unknown"
|
||||
return datetime.fromtimestamp(resets_at).astimezone().strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
|
||||
def validate_template(template: str) -> None:
|
||||
try:
|
||||
fields = {field for _, field, _, _ in Formatter().parse(template) if field}
|
||||
except ValueError as exc:
|
||||
raise UsageError(f"Invalid format template: {exc}") from exc
|
||||
unknown = fields - ALLOWED_FIELDS
|
||||
if unknown:
|
||||
raise UsageError(f"Unknown format field(s): {', '.join(sorted(unknown))}")
|
||||
|
||||
|
||||
def format_values(snapshot: dict[str, Any], now: int) -> tuple[dict[str, Any], int]:
|
||||
five_hour, weekly = select_windows(snapshot)
|
||||
five_used = int(five_hour.get("usedPercent", 0))
|
||||
weekly_used = int(weekly.get("usedPercent", 0))
|
||||
values = {
|
||||
"five_hour_used": five_used,
|
||||
"weekly_used": weekly_used,
|
||||
"five_hour_reset_in": human_countdown(five_hour.get("resetsAt"), now),
|
||||
"weekly_reset_in": human_countdown(weekly.get("resetsAt"), now),
|
||||
"five_hour_reset_at": reset_time(five_hour.get("resetsAt")),
|
||||
"weekly_reset_at": reset_time(weekly.get("resetsAt")),
|
||||
"plan": snapshot.get("planType") or "unknown",
|
||||
}
|
||||
return values, max(five_used, weekly_used)
|
||||
|
||||
|
||||
def output_json(
|
||||
result: dict[str, Any],
|
||||
text_format: str,
|
||||
tooltip_format: str,
|
||||
warning: int,
|
||||
critical: int,
|
||||
stale: bool = False,
|
||||
stale_reason: str | None = None,
|
||||
now: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
snapshot = select_snapshot(result)
|
||||
values, percentage = format_values(snapshot, now or int(time.time()))
|
||||
classes = ["critical" if percentage >= critical else "warning" if percentage >= warning else "normal"]
|
||||
if stale:
|
||||
classes.append("stale")
|
||||
tooltip = tooltip_format.format_map(values)
|
||||
if stale:
|
||||
tooltip += f"\n\nCached data: {stale_reason or 'refresh failed'}"
|
||||
return {
|
||||
"text": html.escape(text_format.format_map(values)),
|
||||
"tooltip": html.escape(tooltip),
|
||||
"class": classes,
|
||||
"percentage": percentage,
|
||||
}
|
||||
|
||||
|
||||
def unavailable_json(reason: str) -> dict[str, Any]:
|
||||
return {
|
||||
"text": "Codex ?",
|
||||
"tooltip": html.escape(f"Codex usage unavailable\n{reason}"),
|
||||
"class": ["unavailable"],
|
||||
"percentage": 0,
|
||||
}
|
||||
|
||||
|
||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--format", default=DEFAULT_FORMAT, help="Waybar text template")
|
||||
parser.add_argument("--tooltip-format", default=DEFAULT_TOOLTIP, help="Waybar tooltip template")
|
||||
parser.add_argument("--warning", type=int, default=70, help="Warning threshold percentage")
|
||||
parser.add_argument("--critical", type=int, default=90, help="Critical threshold percentage")
|
||||
parser.add_argument("--timeout", type=float, default=10, help="Codex app-server timeout in seconds")
|
||||
parser.add_argument("--codex-command", default="codex", help=argparse.SUPPRESS)
|
||||
parser.add_argument("--no-cache", action="store_true", help="Disable cached fallback data")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parse_args(argv)
|
||||
try:
|
||||
validate_template(args.format)
|
||||
validate_template(args.tooltip_format)
|
||||
if not 0 <= args.warning <= args.critical <= 100:
|
||||
raise UsageError("thresholds must satisfy 0 <= warning <= critical <= 100")
|
||||
except UsageError as exc:
|
||||
print(json.dumps(unavailable_json(str(exc)), separators=(",", ":")))
|
||||
return 2
|
||||
|
||||
path = cache_path()
|
||||
try:
|
||||
result = fetch_rate_limits(args.timeout, args.codex_command)
|
||||
save_cache(path, result)
|
||||
output = output_json(result, args.format, args.tooltip_format, args.warning, args.critical)
|
||||
except UsageError as exc:
|
||||
cached = None if args.no_cache else load_cache(path)
|
||||
cached_result = cached.get("result") if isinstance(cached, dict) else None
|
||||
try:
|
||||
if not isinstance(cached_result, dict):
|
||||
raise UsageError(str(exc))
|
||||
output = output_json(
|
||||
cached_result,
|
||||
args.format,
|
||||
args.tooltip_format,
|
||||
args.warning,
|
||||
args.critical,
|
||||
stale=True,
|
||||
stale_reason=str(exc),
|
||||
)
|
||||
except UsageError:
|
||||
output = unavailable_json(str(exc))
|
||||
|
||||
print(json.dumps(output, separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user