claude-usage 0.0.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.
- claude_usage-0.0.1/PKG-INFO +11 -0
- claude_usage-0.0.1/README.md +33 -0
- claude_usage-0.0.1/pyproject.toml +14 -0
- claude_usage-0.0.1/setup.cfg +4 -0
- claude_usage-0.0.1/src/OAuth_credentials.py +73 -0
- claude_usage-0.0.1/src/claude_usage.egg-info/PKG-INFO +11 -0
- claude_usage-0.0.1/src/claude_usage.egg-info/SOURCES.txt +14 -0
- claude_usage-0.0.1/src/claude_usage.egg-info/dependency_links.txt +1 -0
- claude_usage-0.0.1/src/claude_usage.egg-info/requires.txt +6 -0
- claude_usage-0.0.1/src/claude_usage.egg-info/top_level.txt +7 -0
- claude_usage-0.0.1/src/config.py +5 -0
- claude_usage-0.0.1/src/context_menus.py +0 -0
- claude_usage-0.0.1/src/draw_icon.py +99 -0
- claude_usage-0.0.1/src/format_util.py +15 -0
- claude_usage-0.0.1/src/logging.py +12 -0
- claude_usage-0.0.1/src/usage_fetch.py +56 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: claude-usage
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: macOS menu bar app for Anthropic API usage and cost tracking
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: rumps>=0.4.0
|
|
7
|
+
Requires-Dist: requests>=2.32.3
|
|
8
|
+
Requires-Dist: keyring>=25.6.0
|
|
9
|
+
Requires-Dist: python-dotenv>=1.0.1
|
|
10
|
+
Requires-Dist: pyobjc-framework-quartz>=12.1
|
|
11
|
+
Requires-Dist: pyobjc-framework-coretext>=12.1
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Claude Usage Mac Menu
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/pypi/claude_usage/)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
<img src="https://raw.githubusercontent.com/ailabexperiments/claude-usage/main/assets/menu_item_example.png" alt="doctordoc logo" width="150" >
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
A macOS menu bar app that shows your Anthropic API spend and token usage in real time - like a battery indicator for your API budget.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Get started
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
$ pip install claude_usage
|
|
17
|
+
$ claude-usage
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Pre-requsite: Having authenticated with Claude Code:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
$ claude-code login
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## How it works
|
|
29
|
+
|
|
30
|
+
The data is retrieved from Anthropic's Usage API with the OAuth token saved by Claude Code in your macOS Keychain.
|
|
31
|
+
|
|
32
|
+
which provides up-to-date information on your organization's API usage and costs. The app polls the API every 60 seconds and updates the menu bar title and dropdown with your current token usage and session / weekly reset window.
|
|
33
|
+
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "claude-usage"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "macOS menu bar app for Anthropic API usage and cost tracking"
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
|
|
7
|
+
dependencies = [
|
|
8
|
+
"rumps>=0.4.0",
|
|
9
|
+
"requests>=2.32.3",
|
|
10
|
+
"keyring>=25.6.0",
|
|
11
|
+
"python-dotenv>=1.0.1",
|
|
12
|
+
"pyobjc-framework-quartz>=12.1",
|
|
13
|
+
"pyobjc-framework-coretext>=12.1",
|
|
14
|
+
]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import logging
|
|
3
|
+
import subprocess
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
from .config import PLATFORM_URL, OAUTH_CLIENT_ID
|
|
9
|
+
|
|
10
|
+
def read_claude_code_creds() -> dict:
|
|
11
|
+
result = subprocess.run(
|
|
12
|
+
["security", "find-generic-password", "-s", "Claude Code-credentials", "-w"],
|
|
13
|
+
capture_output=True, text=True,
|
|
14
|
+
)
|
|
15
|
+
if result.returncode != 0:
|
|
16
|
+
raise RuntimeError("Claude Code-credentials not found in keychain. Is Claude Code installed and logged in?")
|
|
17
|
+
return json.loads(result.stdout.strip())
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def write_claude_code_creds(creds: dict) -> None:
|
|
21
|
+
blob = json.dumps(creds)
|
|
22
|
+
subprocess.run(["security", "delete-generic-password", "-s", "Claude Code-credentials"],
|
|
23
|
+
capture_output=True)
|
|
24
|
+
subprocess.run(
|
|
25
|
+
["security", "add-generic-password", "-s", "Claude Code-credentials",
|
|
26
|
+
"-a", os.environ.get("USER", ""), "-w", blob],
|
|
27
|
+
capture_output=True, check=True,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_valid_token() -> str:
|
|
32
|
+
"""Return a valid access token, refreshing via OAuth if expired."""
|
|
33
|
+
creds = read_claude_code_creds()
|
|
34
|
+
oauth = creds["claudeAiOauth"]
|
|
35
|
+
|
|
36
|
+
expires_at_ms = oauth.get("expiresAt", 0)
|
|
37
|
+
now_ms = time.time() * 1000
|
|
38
|
+
|
|
39
|
+
if now_ms < expires_at_ms - 60_000:
|
|
40
|
+
return oauth["accessToken"]
|
|
41
|
+
|
|
42
|
+
logging.info("OAuth token expired - refreshing")
|
|
43
|
+
r = requests.post(
|
|
44
|
+
f"{PLATFORM_URL}/v1/oauth/token",
|
|
45
|
+
json={
|
|
46
|
+
"grant_type": "refresh_token",
|
|
47
|
+
"refresh_token": oauth["refreshToken"],
|
|
48
|
+
"client_id": OAUTH_CLIENT_ID,
|
|
49
|
+
},
|
|
50
|
+
timeout=15,
|
|
51
|
+
)
|
|
52
|
+
if not r.ok:
|
|
53
|
+
raise RuntimeError(f"Token refresh failed: HTTP {r.status_code} {r.text[:200]}")
|
|
54
|
+
|
|
55
|
+
new_token = r.json()
|
|
56
|
+
oauth["accessToken"] = new_token.get("access_token", oauth["accessToken"])
|
|
57
|
+
if "refresh_token" in new_token:
|
|
58
|
+
oauth["refreshToken"] = new_token["refresh_token"]
|
|
59
|
+
if "expires_in" in new_token:
|
|
60
|
+
oauth["expiresAt"] = int((time.time() + new_token["expires_in"]) * 1000)
|
|
61
|
+
creds["claudeAiOauth"] = oauth
|
|
62
|
+
write_claude_code_creds(creds)
|
|
63
|
+
logging.info("Keychain updated with refreshed token")
|
|
64
|
+
return oauth["accessToken"]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def is_logged_in() -> bool:
|
|
69
|
+
try:
|
|
70
|
+
read_claude_code_creds()
|
|
71
|
+
return True
|
|
72
|
+
except Exception:
|
|
73
|
+
return False
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: claude-usage
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: macOS menu bar app for Anthropic API usage and cost tracking
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: rumps>=0.4.0
|
|
7
|
+
Requires-Dist: requests>=2.32.3
|
|
8
|
+
Requires-Dist: keyring>=25.6.0
|
|
9
|
+
Requires-Dist: python-dotenv>=1.0.1
|
|
10
|
+
Requires-Dist: pyobjc-framework-quartz>=12.1
|
|
11
|
+
Requires-Dist: pyobjc-framework-coretext>=12.1
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/OAuth_credentials.py
|
|
4
|
+
src/config.py
|
|
5
|
+
src/context_menus.py
|
|
6
|
+
src/draw_icon.py
|
|
7
|
+
src/format_util.py
|
|
8
|
+
src/logging.py
|
|
9
|
+
src/usage_fetch.py
|
|
10
|
+
src/claude_usage.egg-info/PKG-INFO
|
|
11
|
+
src/claude_usage.egg-info/SOURCES.txt
|
|
12
|
+
src/claude_usage.egg-info/dependency_links.txt
|
|
13
|
+
src/claude_usage.egg-info/requires.txt
|
|
14
|
+
src/claude_usage.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
File without changes
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from AppKit import (
|
|
2
|
+
NSImage, NSColor, NSBezierPath, NSFont,
|
|
3
|
+
NSMutableAttributedString, NSAttributedString,
|
|
4
|
+
NSTextAttachment, NSFontAttributeName,
|
|
5
|
+
NSForegroundColorAttributeName,
|
|
6
|
+
NSGraphicsContext,
|
|
7
|
+
)
|
|
8
|
+
from Foundation import NSMakeRect, NSOperationQueue
|
|
9
|
+
from CoreText import (
|
|
10
|
+
CTLineCreateWithAttributedString,
|
|
11
|
+
CTLineDraw,
|
|
12
|
+
CTLineGetTypographicBounds,
|
|
13
|
+
)
|
|
14
|
+
from Quartz.CoreGraphics import (
|
|
15
|
+
CGContextSetTextMatrix,
|
|
16
|
+
CGContextSetTextPosition,
|
|
17
|
+
CGAffineTransformIdentity,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
_BAT_W = 62
|
|
21
|
+
_BAT_H = 18
|
|
22
|
+
_BAT_BASELINE = -3 # vertical nudge to align battery with menu bar text baseline
|
|
23
|
+
|
|
24
|
+
def battery_image(fraction: float | None, label: str) -> NSImage:
|
|
25
|
+
"""
|
|
26
|
+
fraction - 0.0-1.0 fill level (amount used); None = unknown (grey outline only)
|
|
27
|
+
label - time-remaining string drawn centred inside the battery body
|
|
28
|
+
"""
|
|
29
|
+
image = NSImage.alloc().initWithSize_((_BAT_W, _BAT_H))
|
|
30
|
+
image.lockFocus()
|
|
31
|
+
|
|
32
|
+
pad = 1
|
|
33
|
+
bump_w = 4
|
|
34
|
+
bump_h = int(_BAT_H * 0.4)
|
|
35
|
+
body_w = _BAT_W - bump_w - pad * 2
|
|
36
|
+
body_h = _BAT_H - pad * 2
|
|
37
|
+
bx, by = float(pad), float(pad)
|
|
38
|
+
r = 3.0
|
|
39
|
+
inner_pad = 2
|
|
40
|
+
|
|
41
|
+
NSColor.clearColor().set()
|
|
42
|
+
NSBezierPath.fillRect_(NSMakeRect(0, 0, _BAT_W, _BAT_H))
|
|
43
|
+
|
|
44
|
+
fill_frac = fraction if fraction is not None else 0.0
|
|
45
|
+
fill_w = max(0.0, (body_w - inner_pad * 2) * min(fill_frac, 1.0))
|
|
46
|
+
if fill_w > 0:
|
|
47
|
+
NSColor.whiteColor().colorWithAlphaComponent_(0.6).setFill()
|
|
48
|
+
NSBezierPath.bezierPathWithRoundedRect_xRadius_yRadius_(
|
|
49
|
+
NSMakeRect(bx + inner_pad, by + inner_pad, fill_w, body_h - inner_pad * 2),
|
|
50
|
+
max(1.0, r - 1), max(1.0, r - 1),
|
|
51
|
+
).fill()
|
|
52
|
+
|
|
53
|
+
outline_color = NSColor.systemOrangeColor()
|
|
54
|
+
|
|
55
|
+
outline_path = NSBezierPath.bezierPathWithRoundedRect_xRadius_yRadius_(
|
|
56
|
+
NSMakeRect(bx, by, float(body_w), float(body_h)), r, r
|
|
57
|
+
)
|
|
58
|
+
outline_path.setLineWidth_(1.5)
|
|
59
|
+
outline_color.setStroke()
|
|
60
|
+
outline_path.stroke()
|
|
61
|
+
|
|
62
|
+
outline_color.setFill()
|
|
63
|
+
NSBezierPath.fillRect_(NSMakeRect(
|
|
64
|
+
bx + body_w, by + (body_h - bump_h) / 2, float(bump_w), float(bump_h),
|
|
65
|
+
))
|
|
66
|
+
|
|
67
|
+
if label:
|
|
68
|
+
font_size = 8.5
|
|
69
|
+
font = NSFont.systemFontOfSize_(font_size)
|
|
70
|
+
text_color = NSColor.systemOrangeColor()
|
|
71
|
+
attrs = {NSFontAttributeName: font, NSForegroundColorAttributeName: text_color}
|
|
72
|
+
attr_str = NSAttributedString.alloc().initWithString_attributes_(label, attrs)
|
|
73
|
+
|
|
74
|
+
ct_line = CTLineCreateWithAttributedString(attr_str)
|
|
75
|
+
adv_w = CTLineGetTypographicBounds(ct_line, None, None, None)
|
|
76
|
+
if not isinstance(adv_w, (int, float)):
|
|
77
|
+
adv_w = adv_w[0]
|
|
78
|
+
|
|
79
|
+
cg_ctx = NSGraphicsContext.currentContext().CGContext()
|
|
80
|
+
CGContextSetTextMatrix(cg_ctx, CGAffineTransformIdentity)
|
|
81
|
+
|
|
82
|
+
ascender = font.ascender()
|
|
83
|
+
descender = font.descender()
|
|
84
|
+
cap_h = ascender - descender
|
|
85
|
+
text_x = bx + inner_pad + (body_w - inner_pad * 2 - adv_w) / 2
|
|
86
|
+
text_y = by + (body_h - cap_h) / 2 - descender
|
|
87
|
+
CGContextSetTextPosition(cg_ctx, text_x, text_y)
|
|
88
|
+
CTLineDraw(ct_line, cg_ctx)
|
|
89
|
+
|
|
90
|
+
image.unlockFocus()
|
|
91
|
+
image.setSize_((_BAT_W, _BAT_H))
|
|
92
|
+
return image
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def battery_attachment(fraction: float | None, label: str) -> NSAttributedString:
|
|
96
|
+
attachment = NSTextAttachment.alloc().init()
|
|
97
|
+
attachment.setImage_(battery_image(fraction, label))
|
|
98
|
+
attachment.setBounds_(NSMakeRect(0, _BAT_BASELINE, _BAT_W, _BAT_H))
|
|
99
|
+
return NSAttributedString.attributedStringWithAttachment_(attachment)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
|
|
3
|
+
def format_reset_window(reset_at: datetime | None) -> str:
|
|
4
|
+
if reset_at is None:
|
|
5
|
+
return "?"
|
|
6
|
+
delta = reset_at - datetime.now(timezone.utc)
|
|
7
|
+
total = int(delta.total_seconds())
|
|
8
|
+
if total <= 0:
|
|
9
|
+
return "now"
|
|
10
|
+
days = total // 86400
|
|
11
|
+
hours = (total % 86400) // 3600
|
|
12
|
+
mins = (total % 3600) // 60
|
|
13
|
+
if days >= 1:
|
|
14
|
+
return f"{days}d"
|
|
15
|
+
return f"{hours}h{mins:02d}m"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
LOG_FILE = Path.home() / "Library" / "Logs" / "claude_usage.log"
|
|
5
|
+
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
6
|
+
logging.basicConfig(
|
|
7
|
+
level=logging.DEBUG,
|
|
8
|
+
format="%(asctime)s %(levelname)s %(message)s",
|
|
9
|
+
handlers=[
|
|
10
|
+
logging.FileHandler(LOG_FILE),
|
|
11
|
+
],
|
|
12
|
+
)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
import logging
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
|
|
5
|
+
from .config import MESSAGES_URL, OAUTH_CLIENT_ID, PLATFORM_URL
|
|
6
|
+
from .OAuth_credentials import get_valid_token, read_claude_code_creds, write_claude_code_creds
|
|
7
|
+
|
|
8
|
+
def fetch_utilization() -> dict:
|
|
9
|
+
"""
|
|
10
|
+
Send a minimal 1-token message to api.anthropic.com and read the rate-limit
|
|
11
|
+
utilization headers that claude.ai subscribers receive.
|
|
12
|
+
|
|
13
|
+
Returns:
|
|
14
|
+
{
|
|
15
|
+
"five_hour": {"utilization": 0.19, "reset_at": <datetime>},
|
|
16
|
+
"seven_day": {"utilization": 0.94, "reset_at": <datetime>},
|
|
17
|
+
}
|
|
18
|
+
"""
|
|
19
|
+
token = get_valid_token()
|
|
20
|
+
r = requests.post(
|
|
21
|
+
MESSAGES_URL,
|
|
22
|
+
headers={
|
|
23
|
+
"x-api-key": token,
|
|
24
|
+
"anthropic-version": "2023-06-01",
|
|
25
|
+
"content-type": "application/json",
|
|
26
|
+
},
|
|
27
|
+
json={
|
|
28
|
+
"model": "claude-haiku-4-5-20251001",
|
|
29
|
+
"max_tokens": 1,
|
|
30
|
+
"messages": [{"role": "user", "content": "hi"}],
|
|
31
|
+
},
|
|
32
|
+
timeout=20,
|
|
33
|
+
)
|
|
34
|
+
logging.debug("Messages API %s headers=%s", r.status_code, dict(r.headers))
|
|
35
|
+
if not r.ok:
|
|
36
|
+
raise RuntimeError(f"Messages API HTTP {r.status_code}: {r.text[:200]}")
|
|
37
|
+
|
|
38
|
+
def _reset_dt(unix_str: str | None) -> datetime | None:
|
|
39
|
+
if not unix_str:
|
|
40
|
+
return None
|
|
41
|
+
try:
|
|
42
|
+
return datetime.fromtimestamp(int(unix_str), tz=timezone.utc)
|
|
43
|
+
except Exception:
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
h = r.headers
|
|
47
|
+
return {
|
|
48
|
+
"five_hour": {
|
|
49
|
+
"utilization": float(h.get("anthropic-ratelimit-unified-5h-utilization", 0)),
|
|
50
|
+
"reset_at": _reset_dt(h.get("anthropic-ratelimit-unified-5h-reset")),
|
|
51
|
+
},
|
|
52
|
+
"seven_day": {
|
|
53
|
+
"utilization": float(h.get("anthropic-ratelimit-unified-7d-utilization", 0)),
|
|
54
|
+
"reset_at": _reset_dt(h.get("anthropic-ratelimit-unified-7d-reset")),
|
|
55
|
+
},
|
|
56
|
+
}
|