tokenmaxxing 0.2.0__tar.gz → 0.2.2__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.2.0
3
+ Version: 0.2.2
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.2.0"
7
+ version = "0.2.2"
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.2.0"
3
+ __version__ = "0.2.2"
4
4
 
5
5
  from tokenmaxxing.app import main
6
6
 
@@ -74,7 +74,10 @@ APP_NAME = "Claude"
74
74
  LOADING_TEXT = "loading…"
75
75
  TIMER_INTERVAL = 1
76
76
 
77
- REFRESH_SECONDS = 300 # 5 min: OAuth usage endpoint has aggressive undocumented rate limits
77
+ REFRESH_SECONDS = 1800 # 30 min: /api/oauth/usage tolerates ~hourly polling but trips
78
+ # at ~5 min cadence with a sticky multi-hour cooldown. 30 min keeps
79
+ # us well below the throttle threshold; on-demand menu opens still
80
+ # refresh instantly.
78
81
  STALE_AFTER_SECONDS = 600 # 10 min: only mark cached data "(stale)" past this age — one
79
82
  # missed poll cycle (~5 min) shouldn't trigger the warning
80
83
  REFRESH_MAX_BACKOFF = 1800 # Max backoff: 30 minutes
@@ -480,22 +483,70 @@ def _epoch_to_iso(epoch: float) -> str:
480
483
  return datetime.fromtimestamp(epoch, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
481
484
 
482
485
 
483
- def fetch_usage(oauth_data: Optional[dict] = None):
486
+ def fetch_usage(oauth_data: Optional[dict] = None, allow_messages_fallback: bool = False):
484
487
  """Returns (payload_dict, error_str, is_rate_limited, retry_after_seconds).
485
488
 
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.
489
+ Primary path: /api/oauth/usage token-free, same endpoint the official
490
+ Claude Code client uses (see claude-code submodule services/api/usage.ts).
491
+ Honors the server's Retry-After on 429 so we never poll inside the
492
+ cooldown window.
493
+
494
+ Fallback: if the caller passes allow_messages_fallback=True (the worker
495
+ flips this on after SUSTAINED_429_THRESHOLD consecutive 429s), we make
496
+ one 1-token /v1/messages probe and parse the anthropic-ratelimit-unified-*
497
+ response headers — same data, but costs ~10 tokens per call. Used only to
498
+ rescue the menu from a stuck cooldown; the primary path resumes as soon
499
+ as /usage stops 429ing.
493
500
  """
494
501
  token = _lift_claude_env_token()
495
502
  if not token and oauth_data:
496
503
  token = oauth_data.get("accessToken")
497
504
  if not token:
498
505
  return None, "no Claude Code token (start a claude session)", False, None
506
+ payload, err, is_rate_limited, retry_after = _fetch_usage_oauth(token)
507
+ if payload is not None:
508
+ return payload, None, False, None
509
+ if is_rate_limited and allow_messages_fallback:
510
+ msg_payload, msg_err, msg_is_rate, msg_retry = _fetch_usage_messages(token)
511
+ if msg_payload is not None:
512
+ return msg_payload, None, False, None
513
+ # Fallback also failed — surface the /usage 429 since that's the path
514
+ # we want to recover, not the messages probe failure.
515
+ return None, err, is_rate_limited, retry_after
516
+
517
+
518
+ def _fetch_usage_oauth(token: str):
519
+ """Hit /api/oauth/usage. Zero token cost. Throttled if polled too often."""
520
+ req = urllib.request.Request(
521
+ USAGE_URL,
522
+ headers={
523
+ "Authorization": f"Bearer {token}",
524
+ "anthropic-beta": OAUTH_API_VERSION,
525
+ "User-Agent": "claude-limit-app/1.0",
526
+ },
527
+ )
528
+ try:
529
+ with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as resp:
530
+ return json.loads(resp.read().decode("utf-8")), None, False, None
531
+ except urllib.error.HTTPError as e:
532
+ if e.code == HTTP_STATUS_RATE_LIMITED:
533
+ retry_after = None
534
+ try:
535
+ retry_after = int(e.headers.get("Retry-After") or 0) or None
536
+ except (TypeError, ValueError):
537
+ retry_after = None
538
+ return None, "rate limited — data is stale, retrying soon", True, retry_after
539
+ if e.code == HTTP_STATUS_UNAUTHORIZED:
540
+ return None, "auth expired (start a claude session)", False, None
541
+ return None, f"HTTP {e.code}", False, None
542
+ except urllib.error.URLError as e:
543
+ return None, f"net: {e.reason}", False, None
544
+ except Exception as e: # noqa: BLE001
545
+ return None, str(e), False, None
546
+
547
+
548
+ def _fetch_usage_messages(token: str):
549
+ """Paid fallback: 1-token /v1/messages probe; read rate-limit headers."""
499
550
  body = json.dumps({
500
551
  "model": PROBE_MODEL,
501
552
  "max_tokens": 1,
@@ -517,25 +568,12 @@ def fetch_usage(oauth_data: Optional[dict] = None):
517
568
  with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as resp:
518
569
  return _payload_from_ratelimit_headers(resp.headers), None, False, None
519
570
  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.
523
- if e.code == HTTP_STATUS_RATE_LIMITED:
524
- payload = _payload_from_ratelimit_headers(e.headers)
525
- retry_after = None
526
- try:
527
- retry_after = int(e.headers.get("Retry-After") or 0) or None
528
- except (TypeError, ValueError):
529
- retry_after = None
530
- if payload:
531
- return payload, None, False, None
532
- return None, "rate limited — data is stale, retrying soon", True, retry_after
533
- if e.code == HTTP_STATUS_UNAUTHORIZED:
534
- return None, "auth expired (start a claude session)", False, None
535
571
  payload = _payload_from_ratelimit_headers(e.headers)
536
572
  if payload:
537
573
  return payload, None, False, None
538
- return None, f"HTTP {e.code}", False, None
574
+ if e.code == HTTP_STATUS_UNAUTHORIZED:
575
+ return None, "auth expired (start a claude session)", False, None
576
+ return None, f"HTTP {e.code} on /v1/messages fallback", False, None
539
577
  except urllib.error.URLError as e:
540
578
  return None, f"net: {e.reason}", False, None
541
579
  except Exception as e: # noqa: BLE001
@@ -684,6 +722,7 @@ class ClaudeMonitorApp(rumps.App):
684
722
  self._pending_lock = threading.Lock()
685
723
  self._wake = threading.Event()
686
724
  self._backoff_until = 0 # unix timestamp; worker won't poll until after this
725
+ self._force_fallback_next = False # set by "Force fresh" menu; consumed by next poll
687
726
  self._history_lock = threading.Lock()
688
727
  self._history = _load_history()
689
728
 
@@ -733,6 +772,8 @@ class ClaudeMonitorApp(rumps.App):
733
772
  self.stats_item = rumps.MenuItem(HISTORY_COLLECTING)
734
773
  self._dashboard_item = rumps.MenuItem("Open Claude dashboard…", callback=self._open_dashboard)
735
774
  self._refresh_item = rumps.MenuItem("Refresh now", callback=self._manual_refresh)
775
+ self._refresh_paid_item = rumps.MenuItem(
776
+ "Force fresh (uses ~10 tokens)", callback=self._manual_refresh_paid)
736
777
 
737
778
  self.menu = self._build_menu_items()
738
779
 
@@ -760,7 +801,16 @@ class ClaudeMonitorApp(rumps.App):
760
801
  continue
761
802
 
762
803
  oauth_data = _get_oauth_data()
763
- payload, err, is_rate_limited, retry_after = fetch_usage(oauth_data)
804
+ # /v1/messages fallback is opt-in only — the user explicitly
805
+ # triggers it via the "Force fresh (uses ~10 tokens)" menu item,
806
+ # which sets _force_fallback_next for exactly one poll. The
807
+ # default path stays zero-token even when /api/oauth/usage is
808
+ # stuck — we'd rather show stale data than silently bill the
809
+ # user's quota.
810
+ allow_fallback = self._force_fallback_next
811
+ self._force_fallback_next = False
812
+ payload, err, is_rate_limited, retry_after = fetch_usage(
813
+ oauth_data, allow_messages_fallback=allow_fallback)
764
814
  # Drop any Refresh-now clicks that arrived during the poll — the
765
815
  # in-flight poll already satisfies them, and re-polling immediately
766
816
  # can trip the OAuth endpoint's rate limit.
@@ -835,6 +885,17 @@ class ClaudeMonitorApp(rumps.App):
835
885
  # Without this, when the worker is in backoff (e.g. after sustained
836
886
  # 429s), the wake event fires but the loop top-checks _backoff_until
837
887
  # and goes right back to waiting — the click is swallowed.
888
+ # Zero token cost: hits /api/oauth/usage only.
889
+ self._backoff_until = 0
890
+ self._wake.set()
891
+
892
+ def _manual_refresh_paid(self, _sender):
893
+ # Opt-in escape hatch when /api/oauth/usage is stuck in a long
894
+ # cooldown. Costs ~10 tokens for one /v1/messages probe whose
895
+ # response headers carry the current rate-limit values. Consumed
896
+ # exactly once by the next poll; subsequent polls revert to the
897
+ # free path.
898
+ self._force_fallback_next = True
838
899
  self._backoff_until = 0
839
900
  self._wake.set()
840
901
 
@@ -915,6 +976,7 @@ class ClaudeMonitorApp(rumps.App):
915
976
  None,
916
977
  self.last_update_item,
917
978
  self._refresh_item,
979
+ self._refresh_paid_item,
918
980
  ])
919
981
  return items
920
982
 
File without changes
File without changes
File without changes