Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 17 additions & 13 deletions docs/widgets/(Widget)-Copilot.md
Original file line number Diff line number Diff line change
@@ -1,24 +1,25 @@
# Copilot Widget

The Copilot widget displays your GitHub Copilot premium request usage with a detailed menu showing statistics, usage breakdown by model, and a daily usage chart.
The Copilot widget displays your GitHub Copilot AI credits billing usage with a detailed menu showing statistics, usage breakdown by model, and a daily usage chart.

> **Note**: This widget requires a **GitHub Copilot Pro** or **Pro+** subscription. The free tier is not supported due to API limitations.
> **Note**: This widget requires a **GitHub Copilot Pro**, **Pro+**, or **Max** subscription. The free tier is not supported due to API limitations.

## Features

- **Premium Request Tracking**: Shows used/allowance in the status bar
- **AI Credits Tracking**: Shows used/allowance in the status bar
- **Color-coded Thresholds**: Visual warnings when approaching limits
- **Detailed Menu**:
- Usage progress bar
- Spending breakdown (included, overage, total cost)
- Usage by AI model (Claude, GPT-4.1, etc.)
- Usage by AI model (Claude, GPT-4, Gemini, etc.)
- Daily usage chart (from start of month)
- **Plan Support**: Pro (300 requests), Pro+ (1500 requests) - configurable via `plan` option
- **Plan Support**: Pro (1,500 credits), Pro+ (7,000 credits), Max (20,000 credits) - configurable via `plan` option
- **Automatic Refresh**: Configurable update interval

## Requirements

- **GitHub Copilot Pro or Pro+** subscription
- **GitHub Copilot Pro**, **Pro+**, or **Max** subscription (individual plans)
- *Note*: Organization-billed subscriptions (such as **Copilot Business** or **Copilot Enterprise**) are not supported because their usage metrics are managed at the organization level and cannot be retrieved via personal account endpoints.

## Authentication

Expand All @@ -43,7 +44,7 @@ copilot:
label: "{icon}"
label_alt: "{used}/{allowance}"
token: "github_pat_xxxxxxxxxxxx"
plan: "pro" #Set your plan "pro" or "pro_plus"
plan: "pro" #Set your plan "pro", "pro_plus" or "max"
tooltip: true
update_interval: 120
icons:
Expand All @@ -68,10 +69,10 @@ copilot:

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `label` | string | `"{icon}"` | Label format for the bar. Supports `{icon}`, `{used}`, `{allowance}`, `{percentage}`, `{total_cost}` |
| `label` | string | `"{icon}"` | Label format for the bar. Supports `{icon}`, `{used}`, `{allowance}`, `{percentage}`, `{total_cost}`, `{additional_usage}`, `{status}`, `{reset_date}` |
| `label_alt` | string | `"{used}/{allowance}"` | Alternative label (toggle with right-click) |
| `token` | string | `""` | GitHub token. Leave empty to use OAuth sign-in, or set to `"env"` to read from `YASB_COPILOT_TOKEN` env var |
| `plan` | string | `"pro"` | Your Copilot plan: `"pro"` (300 requests) or `"pro_plus"` (1500 requests) |
| `plan` | string | `"pro"` | Your Copilot plan: `"pro"` (1,500 credits), `"pro_plus"` (7,000 credits) or `"max"` (20,000 credits) |
| `tooltip` | boolean | `true` | Show tooltip on hover |
| `update_interval` | integer | `3600` | Refresh interval in seconds (min: 300, max: 86400) |
| `icons.copilot` | string | `"\uf113"` | Icon for Copilot (main widget and empty state) |
Expand Down Expand Up @@ -109,7 +110,7 @@ token: "env"
Then set this environment variable:
- `YASB_COPILOT_TOKEN` - Your GitHub fine-grained PAT

> **Note**: Username is automatically detected from the token. You must set the `plan` option to match your subscription (`"pro"` or `"pro_plus"`) as the GitHub API does not provide plan information.
> **Note**: Username is automatically detected from the token. You must set the `plan` option to match your subscription (`"pro"`, `"pro_plus"`, or `"max"`) as the GitHub API does not provide plan information.

## Label Placeholders

Expand All @@ -118,10 +119,13 @@ The following placeholders can be used in `label` and `label_alt`:
| Placeholder | Description |
|-------------|-------------|
| `{icon}` | The Copilot icon |
| `{used}` | Number of premium requests used this month |
| `{allowance}` | Your monthly allowance based on plan |
| `{used}` | Number of AI Credits used this month |
| `{allowance}` | Your monthly credit allowance/limit based on plan |
| `{percentage}` | Usage percentage |
| `{total_cost}` | Total cost this month |
| `{total_cost}` | Total cost of credits consumed this month |
| `{additional_usage}` | Overage credits used beyond your allowance |
| `{status}` | Connection status (`active` or `inactive`) |
| `{reset_date}` | Cycle reset date (e.g. `Jul 01`) |

## Styling

Expand Down
47 changes: 23 additions & 24 deletions src/core/widgets/services/copilot/api.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""
GitHub Copilot API client for fetching premium request usage data.
GitHub Copilot API client for fetching AI credits billing usage data.
"""

import calendar
Expand All @@ -17,27 +17,27 @@

API_BASE_URL = "https://api.github.com"
# I have set version of GitHub API to a fixed date to avoid unexpected changes
API_VERSION = "2022-11-28"
API_VERSION = "2026-03-10"
DEFAULT_TIMEOUT = 30

# Plan allowances for premium requests per month
# Plan allowances for AI Credits per month
PLAN_ALLOWANCES = {
"pro": 300,
"pro_plus": 1500,
"pro": 1500,
"pro_plus": 7000,
"max": 20000,
}


@dataclass
class CopilotUsageData:
"""Aggregated Copilot usage data."""

total_requests: int = 0
total_credits: float = 0.0
total_cost: float = 0.0
allowance: int = 0
plan_type: str = ""
username: str = ""
requests_by_model: dict[str, int] = field(default_factory=dict)
cost_by_model: dict[str, float] = field(default_factory=dict)
credits_by_model: dict[str, float] = field(default_factory=dict)
daily_usage: list[dict[str, Any]] = field(default_factory=list)
last_updated: datetime | None = None
error: str | None = None
Expand All @@ -61,7 +61,7 @@ class CopilotDataManager:
_callbacks: list[Callable[[CopilotUsageData], None]] = []
_update_thread: Thread | None = None
_chart_enabled: bool = True
_daily_cache: dict[str, int] = {} # Cache for daily data (date_str -> requests)
_daily_cache: dict[str, float] = {} # Cache for daily data (date_str -> credits)

@classmethod
def get_instance(cls) -> CopilotDataManager:
Expand Down Expand Up @@ -143,7 +143,7 @@ def _fetch_data(self) -> None:
# Fetch monthly usage
now = datetime.now(UTC)
url = (
f"{API_BASE_URL}/users/{cls._username}/settings/billing/premium_request/usage"
f"{API_BASE_URL}/users/{cls._username}/settings/billing/ai_credit/usage"
f"?year={now.year}&month={now.month}"
)

Expand All @@ -165,9 +165,9 @@ def _fetch_data(self) -> None:
usage_data.last_updated = now
cls._data = usage_data
elif status_code == 403:
cls._data = CopilotUsageData(error="Access denied. Token needs Plan permission.")
cls._data = CopilotUsageData(error="Access denied. Token needs Plan/Billing permission.")
elif status_code == 404:
cls._data = CopilotUsageData(error="Requires Copilot Pro/Pro+ subscription.")
cls._data = CopilotUsageData(error="Requires Copilot Pro/Pro+/Max subscription.")
else:
cls._data = CopilotUsageData(error=f"API error: {status_code}")

Expand Down Expand Up @@ -227,13 +227,12 @@ def _parse_usage_response(self, data: dict[str, Any]) -> CopilotUsageData:
continue

model = item.get("model") or "Unknown"
quantity = int(item.get("grossQuantity") or item.get("netQuantity") or 0)
amount = item.get("netAmount", 0.0)
quantity = float(item.get("grossQuantity") or item.get("netQuantity") or 0.0)
amount = float(item.get("grossAmount", 0.0))

usage_data.total_requests += quantity
usage_data.total_credits += quantity
usage_data.total_cost += amount
usage_data.requests_by_model[model] = usage_data.requests_by_model.get(model, 0) + quantity
usage_data.cost_by_model[model] = usage_data.cost_by_model.get(model, 0.0) + amount
usage_data.credits_by_model[model] = usage_data.credits_by_model.get(model, 0.0) + quantity

return usage_data

Expand Down Expand Up @@ -265,32 +264,32 @@ def _fetch_daily_data_parallel(self, now: datetime, monthly_data: dict[str, Any]
if day == current_day or date_str not in cls._daily_cache:
days_to_fetch.append(day)

def fetch_day(day: int) -> tuple[str, int]:
def fetch_day(day: int) -> tuple[str, float]:
date_str = f"{year}-{month:02d}-{day:02d}"
url = f"{API_BASE_URL}/users/{cls._username}/settings/billing/premium_request/usage?year={year}&month={month}&day={day}"
url = f"{API_BASE_URL}/users/{cls._username}/settings/billing/ai_credit/usage?year={year}&month={month}&day={day}"
data, status_code, _ = self._make_request(url, timeout=10)
if status_code == 200 and data:
total = sum(
int(item.get("grossQuantity") or item.get("netQuantity") or 0)
float(item.get("grossQuantity") or item.get("netQuantity") or 0.0)
for item in data.get("usageItems", [])
if "copilot" in item.get("product", "").lower()
)
return date_str, total
return date_str, 0
return date_str, 0.0

# Fetch only needed days in parallel
if days_to_fetch:
with ThreadPoolExecutor(max_workers=5) as executor:
futures = {executor.submit(fetch_day, day): day for day in days_to_fetch}
for future in as_completed(futures):
date_str, requests = future.result()
cls._daily_cache[date_str] = requests
date_str, credits_val = future.result()
cls._daily_cache[date_str] = credits_val

# Build result for ALL days in the detected month
result = [
{
"date": f"{year}-{month:02d}-{day:02d}",
"requests": cls._daily_cache.get(f"{year}-{month:02d}-{day:02d}", 0) if day <= current_day else 0,
"credits": cls._daily_cache.get(f"{year}-{month:02d}-{day:02d}", 0.0) if day <= current_day else 0.0,
}
for day in range(1, days_in_month + 1)
]
Expand Down
53 changes: 30 additions & 23 deletions src/core/widgets/yasb/copilot.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""
GitHub Copilot Usage Widget for YASB.
Displays premium request usage data with a popup showing detailed statistics.
Displays AI credits billing usage data with a popup showing detailed statistics.
"""

import os
Expand Down Expand Up @@ -41,7 +41,7 @@ def __init__(self, parent: QWidget | None = None):
self._fill.setProperty("class", "fill")
self._fill.setGeometry(0, 0, 0, self.height())

def set_value(self, value: int, max_value: int = 100) -> None:
def set_value(self, value: float, max_value: float = 100.0) -> None:
self._value = min(value, max_value)
self._max_value = max_value
self._update_state()
Expand Down Expand Up @@ -123,13 +123,13 @@ def _calculate_points(self) -> None:
pad_top = self._pad_top
pad_bottom = self._pad_bottom
chart_h = h - pad_top - pad_bottom
max_val = max((d.get("requests", 0) for d in self._data), default=1) or 1
max_val = max((d.get("credits", 0.0) for d in self._data), default=1.0) or 1.0
n = len(self._data)
x_step = w / (n - 1) if n > 1 else w

for i, item in enumerate(self._data):
x = i * x_step
y = pad_top + chart_h - (item.get("requests", 0) / max_val * chart_h)
y = pad_top + chart_h - (item.get("credits", 0.0) / max_val * chart_h)
self._points.append(QPointF(x, y))

def resizeEvent(self, event):
Expand Down Expand Up @@ -225,7 +225,7 @@ def _show_tooltip(self, idx: int) -> None:
self._tooltip = CustomToolTip()
self._tooltip._position = "top"

self._tooltip.label.setText(f"{formatted}\n{item.get('requests', 0)} requests")
self._tooltip.label.setText(f"{formatted}\n{item.get('credits', 0.0):.2f} credits")
self._tooltip.adjustSize()
pos = self.mapToGlobal(self._points[idx].toPoint())

Expand Down Expand Up @@ -382,20 +382,27 @@ def _toggle_label(self):

def _update_label(self):
data = CopilotDataManager.get_data()
used = data.total_requests
used = data.total_credits
allowance = data.allowance
pct = (used * 100 // allowance) if allowance else 0
pct = round(used * 100 / allowance) if allowance else 0

active_widgets = self._widgets_alt if self._show_alt_label else self._widgets
active_label = self.config.label_alt if self._show_alt_label else self.config.label
label_parts = [p for p in re.split(r"(<span.*?>.*?</span>)", active_label) if p]

now = datetime.now(UTC)
next_reset = datetime(now.year + 1, 1, 1) if now.month == 12 else datetime(now.year, now.month + 1, 1)
reset_date_str = next_reset.strftime("%b %d")

label_options = {
"{icon}": self.config.icons.copilot,
"{used}": str(used),
"{used}": f"{used:.2f}" if isinstance(used, float) else str(used),
"{allowance}": str(allowance),
"{percentage}": str(pct),
"{total_cost}": f"{data.total_cost:.2f}",
"{additional_usage}": f"{max(0.0, used - allowance):.2f}",
"{status}": "inactive" if data.error else "active",
"{reset_date}": reset_date_str,
}

for i, part in enumerate(label_parts):
Expand All @@ -416,7 +423,7 @@ def _update_label(self):
if pct >= self.config.thresholds.warning
else ""
)
tip = f"Error: {data.error}" if data.error else f"Copilot: {used}/{allowance} ({pct}%)"
tip = f"Error: {data.error}" if data.error else f"Copilot: {used:.2f}/{allowance} ({pct}%)"

for widget in active_widgets:
if self.config.tooltip:
Expand All @@ -443,15 +450,15 @@ def _show_popup(self):
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)

if data.plan_type in ("pro", "pro_plus"):
if data.plan_type in ("pro", "pro_plus", "max"):
layout.addWidget(self._create_header(data))

if data.error:
layout.addWidget(self._create_error_section(data.error))
elif data.total_requests or data.requests_by_model or data.daily_usage:
elif data.total_credits or data.credits_by_model or data.daily_usage:
layout.addWidget(self._create_progress_section(data))
layout.addWidget(self._create_spending_section(data))
if data.requests_by_model:
if data.credits_by_model:
layout.addWidget(self._create_model_section(data))
if self.config.menu.chart and data.daily_usage:
layout.addWidget(self._create_chart_section(data))
Expand Down Expand Up @@ -482,7 +489,7 @@ def _create_empty_state(self, layout: QVBoxLayout) -> None:
layout.addLayout(center)

def _create_header(self, data: CopilotUsageData) -> QLabel:
plan_names = {"pro": "Pro", "pro_plus": "Pro+"}
plan_names = {"pro": "Pro", "pro_plus": "Pro+", "max": "Max"}
plan_name = plan_names.get(data.plan_type, "")
header = QLabel(f"<span style='font-weight:bold'>GitHub</span> Copilot ({plan_name})")
header.setProperty("class", "header")
Expand All @@ -498,7 +505,7 @@ def _create_progress_section(self, data: CopilotUsageData) -> QFrame:
# Title row with reset date on right
title_row = QHBoxLayout()
title_row.setContentsMargins(0, 0, 0, 0)
title = QLabel("Premium Requests")
title = QLabel("AI Credits")
title.setProperty("class", "section-title")
title_row.addWidget(title)
title_row.addStretch()
Expand All @@ -512,15 +519,15 @@ def _create_progress_section(self, data: CopilotUsageData) -> QFrame:

bar = ProgressBar()
bar.setMinimumHeight(8)
bar.set_value(data.total_requests, data.allowance)
bar.set_value(data.total_credits, data.allowance)
bar.set_thresholds(self.config.thresholds.warning, self.config.thresholds.critical)
layout.addWidget(bar)

stats = QHBoxLayout()
stats.setContentsMargins(0, 2, 0, 0)
pct = (data.total_requests * 100 // data.allowance) if data.allowance else 0
pct = round(data.total_credits * 100 / data.allowance) if data.allowance else 0

used_lbl = QLabel(f"{data.total_requests} / {data.allowance}")
used_lbl = QLabel(f"{data.total_credits:.2f} / {data.allowance}")
used_lbl.setProperty("class", "usage-count")
stats.addWidget(used_lbl)
stats.addStretch()
Expand All @@ -542,10 +549,10 @@ def _create_spending_section(self, data: CopilotUsageData) -> QFrame:
title.setProperty("class", "section-title")
layout.addWidget(title)

overage = max(0, data.total_requests - data.allowance)
overage = max(0.0, data.total_credits - data.allowance)

layout.addWidget(self._stat_row("Included:", f"{min(data.total_requests, data.allowance)} requests"))
layout.addWidget(self._stat_row("Overage:", f"{overage} requests (${overage * 0.04:.2f})"))
layout.addWidget(self._stat_row("Included:", f"{min(data.total_credits, data.allowance):.2f} credits"))
layout.addWidget(self._stat_row("Overage:", f"{overage:.2f} credits (${overage * 0.01:.2f})"))
total_row = self._stat_row("Total Cost:", f"${data.total_cost:.2f}")
total_row.setProperty("class", "stat-row total")
layout.addWidget(total_row)
Expand Down Expand Up @@ -580,8 +587,8 @@ def _create_model_section(self, data: CopilotUsageData) -> QFrame:
title.setProperty("class", "section-title")
layout.addWidget(title)

max_count = max(data.requests_by_model.values(), default=1)
sorted_models = sorted(data.requests_by_model.items(), key=lambda x: x[1], reverse=True)
max_count = max(data.credits_by_model.values(), default=1.0)
sorted_models = sorted(data.credits_by_model.items(), key=lambda x: x[1], reverse=True)

for i, (model, count) in enumerate(sorted_models[:5]):
row = QFrame(self._menu)
Expand All @@ -600,7 +607,7 @@ def _create_model_section(self, data: CopilotUsageData) -> QFrame:
bar.set_class(f"model-{i % 5}")
row_layout.addWidget(bar, 1)

count_lbl = QLabel(str(count))
count_lbl = QLabel(f"{count:.2f}")
count_lbl.setProperty("class", "model-count")
count_lbl.setFixedWidth(50)
count_lbl.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
Expand Down