tokenmaxxing 0.2.0__tar.gz → 0.2.1__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.1
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.1"
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.1"
4
4
 
5
5
  from tokenmaxxing.app import main
6
6
 
@@ -74,7 +74,12 @@ 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.
81
+ SUSTAINED_429_THRESHOLD = 3 # after this many consecutive 429s, fall back to /v1/messages
82
+ # headers (which cost ~10 tokens/poll) until /usage recovers
78
83
  STALE_AFTER_SECONDS = 600 # 10 min: only mark cached data "(stale)" past this age — one
79
84
  # missed poll cycle (~5 min) shouldn't trigger the warning
80
85
  REFRESH_MAX_BACKOFF = 1800 # Max backoff: 30 minutes
@@ -480,22 +485,70 @@ def _epoch_to_iso(epoch: float) -> str:
480
485
  return datetime.fromtimestamp(epoch, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
481
486
 
482
487
 
483
- def fetch_usage(oauth_data: Optional[dict] = None):
488
+ def fetch_usage(oauth_data: Optional[dict] = None, allow_messages_fallback: bool = False):
484
489
  """Returns (payload_dict, error_str, is_rate_limited, retry_after_seconds).
485
490
 
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.
491
+ Primary path: /api/oauth/usage token-free, same endpoint the official
492
+ Claude Code client uses (see claude-code submodule services/api/usage.ts).
493
+ Honors the server's Retry-After on 429 so we never poll inside the
494
+ cooldown window.
495
+
496
+ Fallback: if the caller passes allow_messages_fallback=True (the worker
497
+ flips this on after SUSTAINED_429_THRESHOLD consecutive 429s), we make
498
+ one 1-token /v1/messages probe and parse the anthropic-ratelimit-unified-*
499
+ response headers — same data, but costs ~10 tokens per call. Used only to
500
+ rescue the menu from a stuck cooldown; the primary path resumes as soon
501
+ as /usage stops 429ing.
493
502
  """
494
503
  token = _lift_claude_env_token()
495
504
  if not token and oauth_data:
496
505
  token = oauth_data.get("accessToken")
497
506
  if not token:
498
507
  return None, "no Claude Code token (start a claude session)", False, None
508
+ payload, err, is_rate_limited, retry_after = _fetch_usage_oauth(token)
509
+ if payload is not None:
510
+ return payload, None, False, None
511
+ if is_rate_limited and allow_messages_fallback:
512
+ msg_payload, msg_err, msg_is_rate, msg_retry = _fetch_usage_messages(token)
513
+ if msg_payload is not None:
514
+ return msg_payload, None, False, None
515
+ # Fallback also failed — surface the /usage 429 since that's the path
516
+ # we want to recover, not the messages probe failure.
517
+ return None, err, is_rate_limited, retry_after
518
+
519
+
520
+ def _fetch_usage_oauth(token: str):
521
+ """Hit /api/oauth/usage. Zero token cost. Throttled if polled too often."""
522
+ req = urllib.request.Request(
523
+ USAGE_URL,
524
+ headers={
525
+ "Authorization": f"Bearer {token}",
526
+ "anthropic-beta": OAUTH_API_VERSION,
527
+ "User-Agent": "claude-limit-app/1.0",
528
+ },
529
+ )
530
+ try:
531
+ with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as resp:
532
+ return json.loads(resp.read().decode("utf-8")), None, False, None
533
+ except urllib.error.HTTPError as e:
534
+ if e.code == HTTP_STATUS_RATE_LIMITED:
535
+ retry_after = None
536
+ try:
537
+ retry_after = int(e.headers.get("Retry-After") or 0) or None
538
+ except (TypeError, ValueError):
539
+ retry_after = None
540
+ return None, "rate limited — data is stale, retrying soon", True, retry_after
541
+ if e.code == HTTP_STATUS_UNAUTHORIZED:
542
+ return None, "auth expired (start a claude session)", False, None
543
+ return None, f"HTTP {e.code}", False, None
544
+ except urllib.error.URLError as e:
545
+ return None, f"net: {e.reason}", False, None
546
+ except Exception as e: # noqa: BLE001
547
+ return None, str(e), False, None
548
+
549
+
550
+ def _fetch_usage_messages(token: str):
551
+ """Paid fallback: 1-token /v1/messages probe; read rate-limit headers."""
499
552
  body = json.dumps({
500
553
  "model": PROBE_MODEL,
501
554
  "max_tokens": 1,
@@ -517,25 +570,12 @@ def fetch_usage(oauth_data: Optional[dict] = None):
517
570
  with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as resp:
518
571
  return _payload_from_ratelimit_headers(resp.headers), None, False, None
519
572
  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
573
  payload = _payload_from_ratelimit_headers(e.headers)
536
574
  if payload:
537
575
  return payload, None, False, None
538
- return None, f"HTTP {e.code}", False, None
576
+ if e.code == HTTP_STATUS_UNAUTHORIZED:
577
+ return None, "auth expired (start a claude session)", False, None
578
+ return None, f"HTTP {e.code} on /v1/messages fallback", False, None
539
579
  except urllib.error.URLError as e:
540
580
  return None, f"net: {e.reason}", False, None
541
581
  except Exception as e: # noqa: BLE001
@@ -760,7 +800,14 @@ class ClaudeMonitorApp(rumps.App):
760
800
  continue
761
801
 
762
802
  oauth_data = _get_oauth_data()
763
- payload, err, is_rate_limited, retry_after = fetch_usage(oauth_data)
803
+ # After SUSTAINED_429_THRESHOLD consecutive /api/oauth/usage 429s,
804
+ # unstick the menu by falling back to the paid /v1/messages
805
+ # header path. We don't enable the fallback by default because it
806
+ # costs ~10 tokens/poll — we only pay that when the free endpoint
807
+ # is genuinely stuck in a multi-hour cooldown.
808
+ allow_fallback = consecutive_failures >= SUSTAINED_429_THRESHOLD
809
+ payload, err, is_rate_limited, retry_after = fetch_usage(
810
+ oauth_data, allow_messages_fallback=allow_fallback)
764
811
  # Drop any Refresh-now clicks that arrived during the poll — the
765
812
  # in-flight poll already satisfies them, and re-polling immediately
766
813
  # can trip the OAuth endpoint's rate limit.
File without changes
File without changes
File without changes