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.
- {tokenmaxxing-0.2.0 → tokenmaxxing-0.2.2}/PKG-INFO +1 -1
- {tokenmaxxing-0.2.0 → tokenmaxxing-0.2.2}/pyproject.toml +1 -1
- {tokenmaxxing-0.2.0 → tokenmaxxing-0.2.2}/src/tokenmaxxing/__init__.py +1 -1
- {tokenmaxxing-0.2.0 → tokenmaxxing-0.2.2}/src/tokenmaxxing/app.py +88 -26
- {tokenmaxxing-0.2.0 → tokenmaxxing-0.2.2}/.gitignore +0 -0
- {tokenmaxxing-0.2.0 → tokenmaxxing-0.2.2}/LICENSE +0 -0
- {tokenmaxxing-0.2.0 → tokenmaxxing-0.2.2}/README.md +0 -0
- {tokenmaxxing-0.2.0 → tokenmaxxing-0.2.2}/src/tokenmaxxing/__main__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tokenmaxxing
|
|
3
|
-
Version: 0.2.
|
|
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.
|
|
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"
|
|
@@ -74,7 +74,10 @@ APP_NAME = "Claude"
|
|
|
74
74
|
LOADING_TEXT = "loading…"
|
|
75
75
|
TIMER_INTERVAL = 1
|
|
76
76
|
|
|
77
|
-
REFRESH_SECONDS =
|
|
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
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|