tokenmaxxing 0.1.6__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.6
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.6"
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.6"
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:
File without changes
File without changes
File without changes