Skip to content

Commit 91fbd52

Browse files
add feature for weekly and monthly quotas
1 parent 183ccbb commit 91fbd52

6 files changed

Lines changed: 314 additions & 49 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
__pycache__/
2+
*.pyc
3+
*.pyo

README.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
[![Tests](https://github.com/thedavidwhiteside/claude-code-tokenbudget/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/thedavidwhiteside/claude-code-tokenbudget/actions/workflows/test.yml)
22

3-
# Claude Code Daily Token Quota Plugin
3+
# Claude Code Token Quota Plugin
44

5-
Claude Code has no built-in spending guardrails. This plugin tracks your daily token usage and hard stops new prompts once you hit your limit. It works with **any backend**: Bedrock, Vertex, direct API, or subscription.
5+
Claude Code has no built-in spending guardrails. This plugin tracks your token usage and hard stops new prompts once you hit your limit. Supports daily, weekly (rolling 7-day), and monthly (calendar month) quotas. It works with **any backend**: Bedrock, Vertex, direct API, or subscription.
66

77
## How it works
88

99
| Hook | Event | Action |
1010
|------|-------|--------|
11-
| `enforce_quota.py` | `UserPromptSubmit` | Blocks the prompt if today's usage ≥ limit |
11+
| `enforce_quota.py` | `UserPromptSubmit` | Blocks the prompt if any quota (daily/weekly/monthly) is exceeded |
1212
| `track_tokens.py` | `Stop` | Records token usage after each turn |
1313

14-
Usage is stored in `~/.claude-token-quota/YYYY-MM-DD.json` and resets automatically each day.
14+
Usage is stored in `~/.claude-token-quota/YYYY-MM-DD.json` per day. Daily quotas reset at midnight; the weekly quota uses a rolling 7-day window; the monthly quota resets on the 1st.
1515

1616
---
1717

@@ -50,6 +50,8 @@ Override any of these in your `~/.claude/settings.json`:
5050
{
5151
"env": {
5252
"TOKEN_QUOTA_DAILY": "1000000",
53+
"TOKEN_QUOTA_WEEKLY": "5000000",
54+
"TOKEN_QUOTA_MONTHLY": "15000000",
5355
"TOKEN_QUOTA_DIR": "~/.claude-token-quota",
5456
"TOKEN_QUOTA_RETAIN_DAYS": "30"
5557
}
@@ -59,9 +61,13 @@ Override any of these in your `~/.claude/settings.json`:
5961
| Variable | Default | Description |
6062
|---|---|---|
6163
| `TOKEN_QUOTA_DAILY` | `1000000` | Daily token limit |
64+
| `TOKEN_QUOTA_WEEKLY` | _(unset)_ | Rolling 7-day token limit (optional) |
65+
| `TOKEN_QUOTA_MONTHLY` | _(unset)_ | Calendar-month token limit (optional) |
6266
| `TOKEN_QUOTA_DIR` | `~/.claude-token-quota` | Where ledger files are stored |
6367
| `TOKEN_QUOTA_RETAIN_DAYS` | `30` | How many days of usage history to keep |
6468

69+
Weekly and monthly limits are opt-in — omit them to enforce only the daily limit. When multiple limits are set, any one being exceeded blocks new prompts.
70+
6571
**Rough token budgets by spend goal — AWS Bedrock example (Claude Sonnet 4.6):**
6672

6773
> **Note:** Prices below are AWS Bedrock examples only and will change. For current rates check the [AWS Bedrock pricing page](https://aws.amazon.com/bedrock/pricing/). Direct API users: see the [Anthropic pricing page](https://www.anthropic.com/pricing) for your model's rates, then apply the same blended-cost formula below.

hooks/enforce_quota.py

Lines changed: 72 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,33 @@
11
#!/usr/bin/env python3
22
"""
33
enforce_quota.py — UserPromptSubmit hook
4-
Reads today's token ledger and blocks the prompt if the daily limit is exceeded.
4+
Reads token ledgers and blocks the prompt if any quota is exceeded.
55
6-
Set your daily limit via environment variable:
7-
TOKEN_QUOTA_DAILY=500000 (default: 500,000 tokens)
6+
Set limits via environment variables:
7+
TOKEN_QUOTA_DAILY=1000000 (default: 1,000,000 tokens)
8+
TOKEN_QUOTA_WEEKLY=5000000 (optional; rolling 7-day window)
9+
TOKEN_QUOTA_MONTHLY=15000000 (optional; current calendar month)
810
TOKEN_QUOTA_DIR=~/.claude-token-quota (default ledger location)
911
"""
1012

1113
import json
1214
import sys
1315
import os
14-
from datetime import date
16+
from datetime import date, timedelta
1517
from pathlib import Path
1618

1719
LEDGER_DIR = Path(os.environ.get("TOKEN_QUOTA_DIR", Path.home() / ".claude-token-quota"))
1820
DAILY_LIMIT = int(os.environ.get("TOKEN_QUOTA_DAILY", 1_000_000))
1921

20-
def today_ledger() -> Path:
21-
return LEDGER_DIR / f"{date.today().isoformat()}.json"
22+
_weekly_raw = os.environ.get("TOKEN_QUOTA_WEEKLY")
23+
WEEKLY_LIMIT = int(_weekly_raw) if _weekly_raw else None
24+
25+
_monthly_raw = os.environ.get("TOKEN_QUOTA_MONTHLY")
26+
MONTHLY_LIMIT = int(_monthly_raw) if _monthly_raw else None
27+
2228

2329
def get_today_total() -> int:
24-
p = today_ledger()
30+
p = LEDGER_DIR / f"{date.today().isoformat()}.json"
2531
if not p.exists():
2632
return 0
2733
try:
@@ -30,39 +36,86 @@ def get_today_total() -> int:
3036
except Exception:
3137
return 0
3238

39+
40+
def get_period_total(start: date, end: date) -> int:
41+
total = 0
42+
current = start
43+
while current <= end:
44+
p = LEDGER_DIR / f"{current.isoformat()}.json"
45+
if p.exists():
46+
try:
47+
data = json.loads(p.read_text())
48+
total += data.get("total_tokens", 0)
49+
except Exception:
50+
pass
51+
current += timedelta(days=1)
52+
return total
53+
54+
3355
def main():
34-
# Read the hook input (we don't need it, but consume stdin cleanly)
3556
try:
3657
sys.stdin.read()
3758
except Exception:
3859
pass
3960

40-
used = get_today_total()
41-
remaining = DAILY_LIMIT - used
42-
pct = (used / DAILY_LIMIT) * 100 if DAILY_LIMIT > 0 else 0
61+
today = date.today()
62+
used_daily = get_today_total()
63+
pct_daily = (used_daily / DAILY_LIMIT) * 100 if DAILY_LIMIT > 0 else 0
4364

44-
if used >= DAILY_LIMIT:
45-
# Block the prompt by returning a decision=block JSON
65+
if used_daily >= DAILY_LIMIT:
4666
result = {
4767
"decision": "block",
4868
"reason": (
4969
f"Daily token quota exceeded.\n"
50-
f"Used: {used:,} / {DAILY_LIMIT:,} tokens ({pct:.1f}%)\n"
70+
f"Used: {used_daily:,} / {DAILY_LIMIT:,} tokens ({pct_daily:.1f}%)\n"
5171
f"Quota resets at midnight. Edit TOKEN_QUOTA_DAILY to change the limit."
5272
)
5373
}
5474
print(json.dumps(result))
5575
sys.exit(0)
5676

57-
# Warn at 80% and 95%
58-
if pct >= 95:
59-
warning = f"Token quota at {pct:.1f}% ({used:,} / {DAILY_LIMIT:,}). Nearly exhausted."
77+
if WEEKLY_LIMIT is not None:
78+
week_start = today - timedelta(days=6)
79+
used_weekly = get_period_total(week_start, today)
80+
if used_weekly >= WEEKLY_LIMIT:
81+
pct = (used_weekly / WEEKLY_LIMIT) * 100
82+
result = {
83+
"decision": "block",
84+
"reason": (
85+
f"Weekly token quota exceeded (rolling 7-day window).\n"
86+
f"Used: {used_weekly:,} / {WEEKLY_LIMIT:,} tokens ({pct:.1f}%)\n"
87+
f"Edit TOKEN_QUOTA_WEEKLY to change the limit."
88+
)
89+
}
90+
print(json.dumps(result))
91+
sys.exit(0)
92+
93+
if MONTHLY_LIMIT is not None:
94+
month_start = today.replace(day=1)
95+
used_monthly = get_period_total(month_start, today)
96+
if used_monthly >= MONTHLY_LIMIT:
97+
pct = (used_monthly / MONTHLY_LIMIT) * 100
98+
result = {
99+
"decision": "block",
100+
"reason": (
101+
f"Monthly token quota exceeded.\n"
102+
f"Used: {used_monthly:,} / {MONTHLY_LIMIT:,} tokens ({pct:.1f}%)\n"
103+
f"Quota resets on the 1st. Edit TOKEN_QUOTA_MONTHLY to change the limit."
104+
)
105+
}
106+
print(json.dumps(result))
107+
sys.exit(0)
108+
109+
remaining = DAILY_LIMIT - used_daily
110+
if pct_daily >= 95:
111+
warning = f"Token quota at {pct_daily:.1f}% ({used_daily:,} / {DAILY_LIMIT:,}). Nearly exhausted."
60112
result = {"decision": "allow", "reason": warning}
61113
print(json.dumps(result))
62-
elif pct >= 85:
63-
print(f"[token-quota] {pct:.1f}% of daily quota used ({remaining:,} tokens remaining)", file=sys.stderr)
114+
elif pct_daily >= 85:
115+
print(f"[token-quota] {pct_daily:.1f}% of daily quota used ({remaining:,} tokens remaining)", file=sys.stderr)
64116

65117
sys.exit(0)
66118

119+
67120
if __name__ == "__main__":
68121
main()

hooks/quota_status.py

Lines changed: 80 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,49 +2,103 @@
22
"""
33
quota_status.py — run manually to check today's token usage
44
Usage: python3 quota_status.py
5-
TOKEN_QUOTA_DAILY=1000000 python3 quota_status.py
5+
TOKEN_QUOTA_DAILY=1000000 TOKEN_QUOTA_WEEKLY=5000000 python3 quota_status.py
66
"""
77

88
import json
99
import os
10-
from datetime import date
10+
from datetime import date, timedelta
1111
from pathlib import Path
1212

1313
LEDGER_DIR = Path(os.environ.get("TOKEN_QUOTA_DIR", Path.home() / ".claude-token-quota"))
1414
DAILY_LIMIT = int(os.environ.get("TOKEN_QUOTA_DAILY", 1_000_000))
1515

16-
def main():
17-
ledger_file = LEDGER_DIR / f"{date.today().isoformat()}.json"
16+
_weekly_raw = os.environ.get("TOKEN_QUOTA_WEEKLY")
17+
WEEKLY_LIMIT = int(_weekly_raw) if _weekly_raw else None
1818

19-
if not ledger_file.exists():
20-
print(f"No usage recorded today ({date.today().isoformat()}).")
21-
print(f"Daily limit: {DAILY_LIMIT:,} tokens")
22-
return
19+
_monthly_raw = os.environ.get("TOKEN_QUOTA_MONTHLY")
20+
MONTHLY_LIMIT = int(_monthly_raw) if _monthly_raw else None
2321

24-
data = json.loads(ledger_file.read_text())
25-
used = data.get("total_tokens", 0)
26-
remaining = max(0, DAILY_LIMIT - used)
27-
pct = (used / DAILY_LIMIT) * 100 if DAILY_LIMIT > 0 else 0
28-
sessions = data.get("sessions", [])
2922

30-
bar_width = 30
31-
filled = int(bar_width * pct / 100)
32-
bar = "█" * filled + "░" * (bar_width - filled)
23+
def get_period_total(start: date, end: date) -> int:
24+
total = 0
25+
current = start
26+
while current <= end:
27+
p = LEDGER_DIR / f"{current.isoformat()}.json"
28+
if p.exists():
29+
try:
30+
data = json.loads(p.read_text())
31+
total += data.get("total_tokens", 0)
32+
except Exception:
33+
pass
34+
current += timedelta(days=1)
35+
return total
3336

37+
38+
def _render_bar(used: int, limit: int) -> tuple[str, float, str]:
39+
pct = (used / limit) * 100 if limit > 0 else 0
40+
filled = min(30, int(30 * pct / 100))
41+
bar = "█" * filled + "░" * (30 - filled)
3442
status = "✅ OK" if pct < 80 else ("⚠️ WARNING" if pct < 100 else "🚫 EXCEEDED")
43+
return bar, pct, status
44+
45+
46+
def main():
47+
today = date.today()
3548

3649
print(f"\n{'─'*50}")
37-
print(f" Claude Code Token Quota — {date.today().isoformat()}")
50+
print(f" Claude Code Token Quota — {today.isoformat()}")
3851
print(f"{'─'*50}")
39-
print(f" [{bar}] {pct:.1f}%")
40-
print(f" Used: {used:>12,} tokens")
41-
print(f" Remaining: {remaining:>12,} tokens")
42-
print(f" Limit: {DAILY_LIMIT:>12,} tokens")
43-
print(f" Status: {status}")
44-
print(f" Turns: {len(sessions)}")
45-
if sessions:
46-
print(f" Last turn: {sessions[-1]['timestamp']}")
47-
print(f"{'─'*50}\n")
52+
53+
ledger_file = LEDGER_DIR / f"{today.isoformat()}.json"
54+
if not ledger_file.exists():
55+
print(f" No usage recorded today ({today.isoformat()}).")
56+
print(f" Daily limit: {DAILY_LIMIT:,} tokens")
57+
sessions = []
58+
else:
59+
data = json.loads(ledger_file.read_text())
60+
used = data.get("total_tokens", 0)
61+
remaining = max(0, DAILY_LIMIT - used)
62+
sessions = data.get("sessions", [])
63+
bar, pct, status = _render_bar(used, DAILY_LIMIT)
64+
65+
print(f" [{bar}] {pct:.1f}%")
66+
print(f" Used: {used:>12,} tokens")
67+
print(f" Remaining: {remaining:>12,} tokens")
68+
print(f" Limit: {DAILY_LIMIT:>12,} tokens")
69+
print(f" Status: {status}")
70+
print(f" Turns: {len(sessions)}")
71+
if sessions:
72+
print(f" Last turn: {sessions[-1]['timestamp']}")
73+
74+
if WEEKLY_LIMIT is not None:
75+
week_start = today - timedelta(days=6)
76+
used_weekly = get_period_total(week_start, today)
77+
remaining_weekly = max(0, WEEKLY_LIMIT - used_weekly)
78+
bar, pct, status = _render_bar(used_weekly, WEEKLY_LIMIT)
79+
80+
print(f"\n Weekly (rolling 7-day: {week_start.isoformat()}{today.isoformat()})")
81+
print(f" [{bar}] {pct:.1f}%")
82+
print(f" Used: {used_weekly:>12,} tokens")
83+
print(f" Remaining: {remaining_weekly:>12,} tokens")
84+
print(f" Limit: {WEEKLY_LIMIT:>12,} tokens")
85+
print(f" Status: {status}")
86+
87+
if MONTHLY_LIMIT is not None:
88+
month_start = today.replace(day=1)
89+
used_monthly = get_period_total(month_start, today)
90+
remaining_monthly = max(0, MONTHLY_LIMIT - used_monthly)
91+
bar, pct, status = _render_bar(used_monthly, MONTHLY_LIMIT)
92+
93+
print(f"\n Monthly ({today.strftime('%B %Y')})")
94+
print(f" [{bar}] {pct:.1f}%")
95+
print(f" Used: {used_monthly:>12,} tokens")
96+
print(f" Remaining: {remaining_monthly:>12,} tokens")
97+
print(f" Limit: {MONTHLY_LIMIT:>12,} tokens")
98+
print(f" Status: {status}")
99+
100+
print(f"\n{'─'*50}\n")
101+
48102

49103
if __name__ == "__main__":
50104
main()
-20.5 KB
Binary file not shown.

0 commit comments

Comments
 (0)