tokenmaxxing 0.1.5__tar.gz → 0.2.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tokenmaxxing
3
- Version: 0.1.5
3
+ Version: 0.2.0
4
4
  Summary: Menu bar app showing your live Claude Code session and weekly usage as a colored progress bar.
5
5
  Project-URL: Homepage, https://github.com/alvations/tokenmaxxing
6
6
  Project-URL: Repository, https://github.com/alvations/tokenmaxxing
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "tokenmaxxing"
7
- version = "0.1.5"
7
+ version = "0.2.0"
8
8
  description = "Menu bar app showing your live Claude Code session and weekly usage as a colored progress bar."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -1,6 +1,6 @@
1
1
  """tokenmaxxing — menu bar app for Claude Code session and weekly usage."""
2
2
 
3
- __version__ = "0.1.5"
3
+ __version__ = "0.2.0"
4
4
 
5
5
  from tokenmaxxing.app import main
6
6
 
@@ -50,6 +50,20 @@ except ImportError: # pragma: no cover
50
50
 
51
51
  USAGE_URL = "https://api.anthropic.com/api/oauth/usage"
52
52
  OAUTH_API_VERSION = "oauth-2025-04-20"
53
+
54
+ # The /api/oauth/usage endpoint is aggressively rate-limited (per-IP cooldown of
55
+ # ~1h that resets on every probe). It's also not how the official Claude Code
56
+ # client reads its rate limits — it reads them from the
57
+ # anthropic-ratelimit-unified-* response headers on regular inference calls.
58
+ # Make a 1-token /v1/messages call and parse those headers — same data, no
59
+ # /usage throttle.
60
+ MESSAGES_URL = "https://api.anthropic.com/v1/messages"
61
+ ANTHROPIC_VERSION = "2023-06-01"
62
+ PROBE_MODEL = "claude-haiku-4-5-20251001" # cheapest current model
63
+ HEADER_5H_UTIL = "anthropic-ratelimit-unified-5h-utilization"
64
+ HEADER_5H_RESET = "anthropic-ratelimit-unified-5h-reset"
65
+ HEADER_7D_UTIL = "anthropic-ratelimit-unified-7d-utilization"
66
+ HEADER_7D_RESET = "anthropic-ratelimit-unified-7d-reset"
53
67
  KEYCHAIN_SERVICE = "Claude Code-credentials"
54
68
  CREDENTIALS_FILE = Path.home() / ".claude" / ".credentials.json"
55
69
  DASHBOARD_URL = "https://claude.ai/settings/usage"
@@ -460,44 +474,67 @@ def get_access_token() -> Optional[str]:
460
474
  return oauth_data.get("accessToken") if oauth_data else None
461
475
 
462
476
 
477
+ def _epoch_to_iso(epoch: float) -> str:
478
+ """Convert unix-epoch seconds to ISO-8601 with Z suffix (the format the
479
+ /api/oauth/usage payload uses)."""
480
+ return datetime.fromtimestamp(epoch, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
481
+
482
+
463
483
  def fetch_usage(oauth_data: Optional[dict] = None):
464
484
  """Returns (payload_dict, error_str, is_rate_limited, retry_after_seconds).
465
485
 
466
- `retry_after_seconds` is the int parsed from the server's Retry-After
467
- header on 429s (or None if absent / non-429). Lets the worker schedule
468
- the next poll exactly when the rate-limit window expires instead of
469
- guessing via exponential backoff.
486
+ Strategy: a 1-token /v1/messages probe whose response headers carry
487
+ anthropic-ratelimit-unified-* the same data the official Claude Code
488
+ client reads. This avoids the /api/oauth/usage IP-throttle entirely.
489
+
490
+ Pro/Max subscribers don't pay per-token, so the probe is effectively free.
491
+ The retry_after_seconds field stays for compatibility but should be None
492
+ in practice — /v1/messages doesn't 429 the way /api/oauth/usage does.
470
493
  """
471
- # Prefer a fresh token lifted from a running `claude` process — the
472
- # keychain token can't refresh in-place for Pro/Max users and will 401
473
- # once it expires. Fall back to the keychain only if no claude session
474
- # is alive.
475
494
  token = _lift_claude_env_token()
476
495
  if not token and oauth_data:
477
496
  token = oauth_data.get("accessToken")
478
497
  if not token:
479
498
  return None, "no Claude Code token (start a claude session)", False, None
499
+ body = json.dumps({
500
+ "model": PROBE_MODEL,
501
+ "max_tokens": 1,
502
+ "messages": [{"role": "user", "content": "."}],
503
+ }).encode("utf-8")
480
504
  req = urllib.request.Request(
481
- USAGE_URL,
505
+ MESSAGES_URL,
506
+ data=body,
507
+ method="POST",
482
508
  headers={
483
509
  "Authorization": f"Bearer {token}",
510
+ "anthropic-version": ANTHROPIC_VERSION,
484
511
  "anthropic-beta": OAUTH_API_VERSION,
512
+ "content-type": "application/json",
485
513
  "User-Agent": "claude-limit-app/1.0",
486
514
  },
487
515
  )
488
516
  try:
489
517
  with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as resp:
490
- return json.loads(resp.read().decode("utf-8")), None, False, None
518
+ return _payload_from_ratelimit_headers(resp.headers), None, False, None
491
519
  except urllib.error.HTTPError as e:
520
+ # Even error responses (e.g. 529 overload) carry the ratelimit headers
521
+ # — extract them when we can so a brief upstream blip still updates
522
+ # the menu.
492
523
  if e.code == HTTP_STATUS_RATE_LIMITED:
524
+ payload = _payload_from_ratelimit_headers(e.headers)
493
525
  retry_after = None
494
526
  try:
495
527
  retry_after = int(e.headers.get("Retry-After") or 0) or None
496
528
  except (TypeError, ValueError):
497
529
  retry_after = None
530
+ if payload:
531
+ return payload, None, False, None
498
532
  return None, "rate limited — data is stale, retrying soon", True, retry_after
499
533
  if e.code == HTTP_STATUS_UNAUTHORIZED:
500
534
  return None, "auth expired (start a claude session)", False, None
535
+ payload = _payload_from_ratelimit_headers(e.headers)
536
+ if payload:
537
+ return payload, None, False, None
501
538
  return None, f"HTTP {e.code}", False, None
502
539
  except urllib.error.URLError as e:
503
540
  return None, f"net: {e.reason}", False, None
@@ -505,6 +542,37 @@ def fetch_usage(oauth_data: Optional[dict] = None):
505
542
  return None, str(e), False, None
506
543
 
507
544
 
545
+ def _payload_from_ratelimit_headers(headers) -> Optional[dict]:
546
+ """Parse anthropic-ratelimit-unified-* response headers into the
547
+ /api/oauth/usage payload shape the rest of the app expects.
548
+
549
+ Returns None if neither five-hour nor seven-day data is present (e.g. on
550
+ a non-OAuth request or a free-tier user without subscription limits)."""
551
+ def _f(name):
552
+ v = headers.get(name)
553
+ try:
554
+ return float(v) if v is not None else None
555
+ except (TypeError, ValueError):
556
+ return None
557
+
558
+ out = {}
559
+ util_5h, reset_5h = _f(HEADER_5H_UTIL), _f(HEADER_5H_RESET)
560
+ if util_5h is not None:
561
+ view = {"utilization": util_5h * 100.0} # headers are 0..1; payload is 0..100
562
+ if reset_5h is not None:
563
+ view["resets_at"] = _epoch_to_iso(reset_5h)
564
+ out["five_hour"] = view
565
+ util_7d, reset_7d = _f(HEADER_7D_UTIL), _f(HEADER_7D_RESET)
566
+ if util_7d is not None:
567
+ view = {"utilization": util_7d * 100.0}
568
+ if reset_7d is not None:
569
+ view["resets_at"] = _epoch_to_iso(reset_7d)
570
+ out["seven_day"] = view
571
+ # seven_day_sonnet has no equivalent header — leave absent so the menu
572
+ # row renders as "—" rather than misleading stale data.
573
+ return out or None
574
+
575
+
508
576
  def bar(pct: Optional[float], width: int = BAR_WIDTH) -> str:
509
577
  """Solid Unicode progress bar."""
510
578
  if pct is None:
@@ -619,6 +687,23 @@ class ClaudeMonitorApp(rumps.App):
619
687
  self._history_lock = threading.Lock()
620
688
  self._history = _load_history()
621
689
 
690
+ # Hydrate _latest from the last snapshot so the menu shows last-known
691
+ # values immediately after restart instead of going blank while the
692
+ # first poll is in flight (or during a rate-limit window where no fresh
693
+ # poll will land for many minutes). resets_at is unknown for a hydrated
694
+ # payload — the render path tolerates that and shows "—" for the time.
695
+ if self._history:
696
+ last = self._history[-1]
697
+ self._latest = {
698
+ "five_hour": {"utilization": last.get("session_pct")},
699
+ "seven_day": {"utilization": last.get("weekly_pct")},
700
+ "seven_day_sonnet": {"utilization": last.get("weekly_sonnet_pct")},
701
+ }
702
+ try:
703
+ self._latest_at = datetime.fromtimestamp(last["ts"], tz=timezone.utc)
704
+ except (KeyError, TypeError, ValueError, OverflowError):
705
+ self._latest_at = None
706
+
622
707
  # Detail rows: one per known view (clickable to switch)
623
708
  self._detail_items = {}
624
709
  for label, key in VIEWS:
@@ -1003,8 +1088,13 @@ class ClaudeMonitorApp(rumps.App):
1003
1088
  # Either way, render markers/emoji on detail items so the user can see which view
1004
1089
  # is being tracked even before the first poll lands.
1005
1090
  if payload is None:
1006
- if err is not None:
1007
- self._set_title(MENU_ICON, "", err, None)
1091
+ # Prefer the formatted status suffix when we have one — it carries
1092
+ # the rate-limit countdown ("[rate limited 18m]") and is more useful
1093
+ # than the raw error string. Falls back to err when no suffix
1094
+ # applies (e.g. transient network blip with no cached data).
1095
+ tail = status_suffix.lstrip() if status_suffix else err
1096
+ if tail:
1097
+ self._set_title(MENU_ICON, "", f" {tail}", None)
1008
1098
  for key, item in self._detail_items.items():
1009
1099
  marker = MARKER_SELECTED if key == self.current_view else MARKER_UNSELECTED
1010
1100
  item.title = f"{marker}{status_emoji(None)} {VIEW_LABEL[key]}: {UNAVAILABLE}"
File without changes
File without changes
File without changes