Last active
April 16, 2026 14:46
-
-
Save m0wer/06181c2f445285f9f03ae74312b8c64e to your computer and use it in GitHub Desktop.
Github Copilot subscription quota tracker
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # copilot-quota.sh — Shell function to check GitHub Copilot premium request quota | |
| # | |
| # Installation: | |
| # Copy this file somewhere, e.g. ~/.config/opencode/copilot-quota.sh | |
| # Then add to your ~/.bashrc or ~/.zshrc: | |
| # source ~/.config/opencode/copilot-quota.sh | |
| # | |
| # Usage: | |
| # copilot-quota | |
| copilot-quota() { | |
| local AUTH_FILE="$HOME/.local/share/opencode/auth.json" | |
| local COPILOT_VERSION="0.35.0" | |
| local EDITOR_VERSION="vscode/1.107.0" | |
| # ── 1. Read token from opencode auth.json ────────────────────────────────── | |
| if [[ ! -f "$AUTH_FILE" ]]; then | |
| echo "Error: opencode auth file not found at $AUTH_FILE" >&2 | |
| return 1 | |
| fi | |
| local OAUTH_TOKEN | |
| OAUTH_TOKEN=$(python3 -c " | |
| import json, sys | |
| try: | |
| data = json.load(open('$AUTH_FILE')) | |
| gh = data.get('github-copilot', {}) | |
| token = gh.get('refresh') or gh.get('access') or '' | |
| print(token) | |
| except Exception as e: | |
| print('', end='') | |
| " 2>/dev/null) | |
| if [[ -z "$OAUTH_TOKEN" ]]; then | |
| echo "Error: No GitHub Copilot token found in auth.json" >&2 | |
| return 1 | |
| fi | |
| # ── 2. Try token exchange (for new gho_ OAuth tokens) ────────────────────── | |
| local COPILOT_TOKEN | |
| local EXCHANGE_RESP | |
| EXCHANGE_RESP=$(curl -s -w "\n%{http_code}" \ | |
| -H "Accept: application/json" \ | |
| -H "Authorization: Bearer $OAUTH_TOKEN" \ | |
| -H "User-Agent: GitHubCopilotChat/$COPILOT_VERSION" \ | |
| -H "Editor-Version: $EDITOR_VERSION" \ | |
| -H "Editor-Plugin-Version: copilot-chat/$COPILOT_VERSION" \ | |
| -H "Copilot-Integration-Id: vscode-chat" \ | |
| "https://api.github.com/copilot_internal/v2/token" 2>/dev/null) | |
| local EXCHANGE_BODY EXCHANGE_STATUS | |
| EXCHANGE_BODY=$(echo "$EXCHANGE_RESP" | head -n -1) | |
| EXCHANGE_STATUS=$(echo "$EXCHANGE_RESP" | tail -n 1) | |
| if [[ "$EXCHANGE_STATUS" == "200" ]]; then | |
| COPILOT_TOKEN=$(python3 -c " | |
| import json, sys | |
| try: | |
| data = json.loads('''$EXCHANGE_BODY''') | |
| print(data.get('token', '')) | |
| except: | |
| print('') | |
| " 2>/dev/null) | |
| fi | |
| # ── 3. Call the Copilot quota API ────────────────────────────────────────── | |
| local API_RESP API_BODY API_STATUS | |
| local AUTH_HEADER | |
| if [[ -n "$COPILOT_TOKEN" ]]; then | |
| AUTH_HEADER="Bearer $COPILOT_TOKEN" | |
| else | |
| AUTH_HEADER="token $OAUTH_TOKEN" | |
| fi | |
| API_RESP=$(curl -s -w "\n%{http_code}" \ | |
| -H "Content-Type: application/json" \ | |
| -H "Accept: application/json" \ | |
| -H "Authorization: $AUTH_HEADER" \ | |
| -H "User-Agent: GitHubCopilotChat/$COPILOT_VERSION" \ | |
| -H "Editor-Version: $EDITOR_VERSION" \ | |
| -H "Editor-Plugin-Version: copilot-chat/$COPILOT_VERSION" \ | |
| -H "Copilot-Integration-Id: vscode-chat" \ | |
| "https://api.github.com/copilot_internal/user" 2>/dev/null) | |
| API_BODY=$(echo "$API_RESP" | head -n -1) | |
| API_STATUS=$(echo "$API_RESP" | tail -n 1) | |
| if [[ "$API_STATUS" != "200" && -n "$COPILOT_TOKEN" ]]; then | |
| API_RESP=$(curl -s -w "\n%{http_code}" \ | |
| -H "Content-Type: application/json" \ | |
| -H "Accept: application/json" \ | |
| -H "Authorization: Bearer $OAUTH_TOKEN" \ | |
| -H "User-Agent: GitHubCopilotChat/$COPILOT_VERSION" \ | |
| -H "Editor-Version: $EDITOR_VERSION" \ | |
| -H "Editor-Plugin-Version: copilot-chat/$COPILOT_VERSION" \ | |
| -H "Copilot-Integration-Id: vscode-chat" \ | |
| "https://api.github.com/copilot_internal/user" 2>/dev/null) | |
| API_BODY=$(echo "$API_RESP" | head -n -1) | |
| API_STATUS=$(echo "$API_RESP" | tail -n 1) | |
| fi | |
| if [[ "$API_STATUS" != "200" ]]; then | |
| echo "Error: Copilot API call failed (HTTP $API_STATUS): $API_BODY" >&2 | |
| return 1 | |
| fi | |
| # ── 4. Parse response, calculate pace, and render ────────────────────────── | |
| python3 - "$API_BODY" <<'PYEOF' | |
| import sys, json, math | |
| from datetime import date, timedelta | |
| import calendar | |
| BAR_WIDTH = 34 | |
| def bar(filled_pct, secondary_pct=None, width=BAR_WIDTH): | |
| """ | |
| Render a two-tone ASCII bar. | |
| filled_pct : primary fill (e.g. used) → '█' | |
| secondary_pct: secondary fill (e.g. month) → '▒' (shown only when > filled_pct) | |
| Both are clamped to [0, 100]. | |
| """ | |
| filled_pct = max(0.0, min(100.0, filled_pct)) | |
| n_fill = round(filled_pct / 100 * width) | |
| n_fill = max(0, min(width, n_fill)) | |
| if secondary_pct is not None: | |
| secondary_pct = max(0.0, min(100.0, secondary_pct)) | |
| n_sec = round(secondary_pct / 100 * width) | |
| n_sec = max(n_fill, min(width, n_sec)) # secondary never less than primary | |
| else: | |
| n_sec = n_fill | |
| filled = '█' * n_fill | |
| secondary = '▒' * (n_sec - n_fill) | |
| empty = '░' * (width - n_sec) | |
| return f"[{filled}{secondary}{empty}]" | |
| raw = sys.argv[1] | |
| try: | |
| data = json.loads(raw) | |
| except json.JSONDecodeError: | |
| print("Error: Failed to parse API response", file=sys.stderr) | |
| sys.exit(1) | |
| snapshots = data.get("quota_snapshots", {}) | |
| premium = snapshots.get("premium_interactions", {}) | |
| if not premium: | |
| print("Error: No premium_interactions quota data found", file=sys.stderr) | |
| sys.exit(1) | |
| if premium.get("unlimited"): | |
| print("── Copilot Premium Requests ─────────────────") | |
| print(" Entitlement : Unlimited") | |
| sys.exit(0) | |
| entitlement = premium.get("entitlement", 0) | |
| remaining = premium.get("remaining", 0) | |
| used = entitlement - remaining | |
| pct_used = (used / entitlement * 100) if entitlement > 0 else 0.0 | |
| # ── Billing cycle dates ──────────────────────────────────────────────────── | |
| reset_date_str = data.get("quota_reset_date", "") | |
| try: | |
| reset_date = date.fromisoformat(reset_date_str.replace("Z", "").split("T")[0]) | |
| year, month = reset_date.year, reset_date.month | |
| if month == 1: | |
| cycle_start = date(year - 1, 12, reset_date.day) | |
| else: | |
| last_day = calendar.monthrange(year, month - 1)[1] | |
| day = min(reset_date.day, last_day) | |
| cycle_start = date(year, month - 1, day) | |
| today = date.today() | |
| days_elapsed = (today - cycle_start).days + 1 | |
| days_in_month = (reset_date - cycle_start).days | |
| days_remaining_in_month = days_in_month - days_elapsed | |
| except Exception: | |
| cycle_start = today = date.today() | |
| days_elapsed = 1 | |
| days_in_month = 30 | |
| days_remaining_in_month = 29 | |
| pct_month_elapsed = (days_elapsed / days_in_month * 100) if days_in_month > 0 else 0.0 | |
| # ── Derived metrics ──────────────────────────────────────────────────────── | |
| daily_budget = entitlement / days_in_month if days_in_month > 0 else 0 | |
| actual_daily_rate = used / days_elapsed if days_elapsed > 0 else 0 | |
| expected_used = math.floor(entitlement * days_elapsed / days_in_month) if days_in_month > 0 else 0 | |
| pace_diff = expected_used - used # positive = under pace (good) | |
| # Days ahead/behind: how many days of budget the diff represents | |
| days_delta = abs(pace_diff) / daily_budget if daily_budget > 0 else 0 | |
| # Projected EOM usage at current burn rate | |
| projected_eom = round(actual_daily_rate * days_in_month) | |
| # Days until exhaustion at current rate (from today) | |
| if actual_daily_rate > 0: | |
| days_until_empty = remaining / actual_daily_rate | |
| else: | |
| days_until_empty = float('inf') | |
| # ── Render ───────────────────────────────────────────────────────────────── | |
| print("── Copilot Premium Requests ─────────────────────────") | |
| print(f" Entitlement : {entitlement} / month ({daily_budget:.1f} / day)") | |
| print(f" Used : {used} ({pct_used:.1f}%)") | |
| print(f" Remaining : {remaining}") | |
| print() | |
| # ASCII chart — two rows | |
| usage_bar = bar(pct_used, pct_month_elapsed) | |
| month_bar = bar(pct_month_elapsed) | |
| print("── Usage vs Month ───────────────────────────────────") | |
| print(f" Usage {usage_bar} {pct_used:.1f}%") | |
| print(f" Month {month_bar} {pct_month_elapsed:.1f}% (day {days_elapsed}/{days_in_month})") | |
| print() | |
| # Pace block | |
| print(f"── Pace Check ───────────────────────────────────────") | |
| print(f" Burn rate : {actual_daily_rate:.1f} req/day (budget {daily_budget:.1f}/day)") | |
| print(f" Expected used: {expected_used}") | |
| print(f" Actual used : {used}") | |
| pace_pct = round(abs(pace_diff) / entitlement * 100, 1) if entitlement > 0 else 0 | |
| if pace_diff >= 0: | |
| print(f" Status : ✅ UNDER pace by {pace_diff} req ({pace_pct}%) · {days_delta:.1f} days ahead") | |
| else: | |
| over = -pace_diff | |
| print(f" Status : ❌ OVER pace by {over} req ({pace_pct}%) · {days_delta:.1f} days behind") | |
| print() | |
| print(f"── Forecast (at {actual_daily_rate:.1f} req/day current burn) ────────") | |
| projected_eom_smart = used + round(actual_daily_rate * max(0, days_remaining_in_month)) | |
| proj_pct = projected_eom_smart / entitlement * 100 if entitlement > 0 else 0 | |
| print(f" Projected EOM : {projected_eom_smart} req ({proj_pct:.1f}% of entitlement)") | |
| if math.isinf(days_until_empty): | |
| print(f" Quota runs out : never at current rate") | |
| elif days_until_empty > days_remaining_in_month: | |
| projected_spend = round(actual_daily_rate * days_remaining_in_month) | |
| surplus = remaining - projected_spend | |
| print(f" Quota runs out : after reset (~{surplus:+} req surplus at current pace)") | |
| else: | |
| exhaustion_date = today + timedelta(days=days_until_empty) | |
| print(f" Quota runs out : ~{exhaustion_date.strftime('%b %d')} ({days_until_empty:.1f} days at current rate)") | |
| PYEOF | |
| } |
Author
Thanks @m0wer for putting this together, it's super helpful!
I've adapted it as a PowerShell (pwsh core) version here: https://gist.github.com/joeskeen/ba9d090d58d93f1522f6439fd800f1cd
That allows you to pass -AsObject and then be able to pipe the raw data object to whatever other commands you want.
Author
Improved the forescast to use the current pace:
── Copilot Premium Requests ─────────────────────────
Entitlement : 1500 / month (50.0 / day)
Used : 804 (53.6%)
Remaining : 696
── Usage vs Month ───────────────────────────────────
Usage [██████████████████░░░░░░░░░░░░░░░░] 53.6%
Month [██████████████████░░░░░░░░░░░░░░░░] 53.3% (day 16/30)
── Pace Check ───────────────────────────────────────
Burn rate : 50.2 req/day (budget 50.0/day)
Expected used: 800
Actual used : 804
Status : ❌ OVER pace by 4 req (0.3%) · 0.1 days behind
── Forecast (at 50.2 req/day current burn) ────────
Projected EOM : 1508 req (100.5% of entitlement)
Quota runs out : ~Apr 29 (13.9 days at current rate)
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Example output: