switch daemon to /api/oauth/usage endpoint

This commit is contained in:
Brian Pugh
2026-05-24 15:26:43 -04:00
parent efc5641840
commit 525ebe8921
4 changed files with 100 additions and 70 deletions
+50 -36
View File
@@ -1,9 +1,10 @@
#!/usr/bin/env python3
"""Claude Usage Tracker Daemon (BLE) — macOS port of claude-usage-daemon.sh.
Polls Claude API rate-limit headers and writes a JSON payload to the
ESP32 "Claude Controller" peripheral over a custom GATT service. Uses
bleak (CoreBluetooth backend on macOS).
Polls the Claude OAuth usage endpoint (the same one Claude Code's `/usage`
command uses) and writes a JSON payload to the ESP32 "Claude Controller"
peripheral over a custom GATT service. Uses bleak (CoreBluetooth backend
on macOS).
"""
import asyncio
@@ -15,6 +16,7 @@ import signal
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path
import httpx
@@ -36,18 +38,12 @@ KEYCHAIN_SERVICE = "Claude Code-credentials"
CREDENTIALS_PATH = Path.home() / ".claude" / ".credentials.json"
SAVED_ADDR_FILE = Path.home() / ".config" / "claude-usage-monitor" / "ble-address"
API_URL = "https://api.anthropic.com/v1/messages"
API_URL = "https://api.anthropic.com/api/oauth/usage"
API_HEADERS_TEMPLATE = {
"anthropic-version": "2023-06-01",
"anthropic-beta": "oauth-2025-04-20",
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": "claude-code/2.1.5",
}
API_BODY = {
"model": "claude-haiku-4-5-20251001",
"max_tokens": 1,
"messages": [{"role": "user", "content": "hi"}],
}
def log(msg: str) -> None:
@@ -157,12 +153,40 @@ async def scan_for_device() -> str | None:
return None
def _parse_iso8601(s: object) -> float | None:
if not isinstance(s, str) or not s:
return None
try:
return datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp()
except ValueError:
return None
def _window_pct(window: object) -> int:
if not isinstance(window, dict):
return 0
util = window.get("utilization")
if not isinstance(util, (int, float)):
return 0
return int(round(float(util)))
def _window_reset_mins(window: object, now: float) -> int:
if not isinstance(window, dict):
return -1
ts = _parse_iso8601(window.get("resets_at"))
if ts is None:
return -1
mins = (ts - now) / 60.0
return int(round(mins)) if mins > 0 else 0
async def poll_api(token: str) -> dict | None:
headers = dict(API_HEADERS_TEMPLATE)
headers["Authorization"] = f"Bearer {token}"
try:
async with httpx.AsyncClient(timeout=20.0) as http:
resp = await http.post(API_URL, headers=headers, json=API_BODY)
resp = await http.get(API_URL, headers=headers)
except httpx.HTTPError as e:
log(f"API call failed: {e}")
return None
@@ -170,34 +194,24 @@ async def poll_api(token: str) -> dict | None:
log(f"API HTTP {resp.status_code}: {resp.text[:200]}")
return None
def hdr(name: str, default: str = "0") -> str:
return resp.headers.get(name, default)
try:
body = resp.json()
except json.JSONDecodeError as e:
log(f"API returned non-JSON: {e}")
return None
now = time.time()
def reset_minutes(reset_ts: str) -> int:
try:
r = float(reset_ts)
except ValueError:
return 0
mins = (r - now) / 60.0
return int(round(mins)) if mins > 0 else 0
def pct(util: str) -> int:
try:
return int(round(float(util) * 100))
except ValueError:
return 0
payload = {
"s": pct(hdr("anthropic-ratelimit-unified-5h-utilization")),
"sr": reset_minutes(hdr("anthropic-ratelimit-unified-5h-reset")),
"w": pct(hdr("anthropic-ratelimit-unified-7d-utilization")),
"wr": reset_minutes(hdr("anthropic-ratelimit-unified-7d-reset")),
"st": hdr("anthropic-ratelimit-unified-5h-status", "unknown"),
five_hour = body.get("five_hour")
seven_day = body.get("seven_day")
s_pct = _window_pct(five_hour)
return {
"s": s_pct,
"sr": _window_reset_mins(five_hour, now),
"w": _window_pct(seven_day),
"wr": _window_reset_mins(seven_day, now),
"st": "limited" if s_pct >= 100 else "allowed",
"ok": True,
}
return payload
class Session: