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.
- {tokenmaxxing-0.1.5 → tokenmaxxing-0.2.0}/PKG-INFO +1 -1
- {tokenmaxxing-0.1.5 → tokenmaxxing-0.2.0}/pyproject.toml +1 -1
- {tokenmaxxing-0.1.5 → tokenmaxxing-0.2.0}/src/tokenmaxxing/__init__.py +1 -1
- {tokenmaxxing-0.1.5 → tokenmaxxing-0.2.0}/src/tokenmaxxing/app.py +102 -12
- {tokenmaxxing-0.1.5 → tokenmaxxing-0.2.0}/.gitignore +0 -0
- {tokenmaxxing-0.1.5 → tokenmaxxing-0.2.0}/LICENSE +0 -0
- {tokenmaxxing-0.1.5 → tokenmaxxing-0.2.0}/README.md +0 -0
- {tokenmaxxing-0.1.5 → tokenmaxxing-0.2.0}/src/tokenmaxxing/__main__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tokenmaxxing
|
|
3
|
-
Version: 0.
|
|
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.
|
|
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"
|
|
@@ -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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1007
|
-
|
|
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
|
|
File without changes
|