From 95dc14bf8557b04eea7a7461fe5018ab5a1c116f Mon Sep 17 00:00:00 2001 From: AmN <16545063+amnweb@users.noreply.github.com> Date: Mon, 29 Jun 2026 02:01:34 +0200 Subject: [PATCH] feat(copilot): migrate from premium requests to AI credits billing API - Update API version to 2026-03-10 and endpoints to use ai_credit/usage - Add support for Copilot Max plan with 20,000 credits allowance - Update plan allowances for Pro (1500) and Pro+ (7000) to reflect credits - Replace integer request tracking with float credit values across data models - Add new label placeholders: {additional_usage}, {status}, {reset_date} - Update documentation, UI labels, and tooltips to use "credits" terminology - Handle fractional credit values in progress bars, chart, and spending sections - Change overage cost calculation to use updated pricing model --- docs/widgets/(Widget)-Copilot.md | 30 ++++++++------ src/core/widgets/services/copilot/api.py | 47 ++++++++++----------- src/core/widgets/yasb/copilot.py | 53 ++++++++++++++---------- 3 files changed, 70 insertions(+), 60 deletions(-) diff --git a/docs/widgets/(Widget)-Copilot.md b/docs/widgets/(Widget)-Copilot.md index 86b3c18be..bae538a90 100644 --- a/docs/widgets/(Widget)-Copilot.md +++ b/docs/widgets/(Widget)-Copilot.md @@ -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 @@ -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: @@ -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) | @@ -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 @@ -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 diff --git a/src/core/widgets/services/copilot/api.py b/src/core/widgets/services/copilot/api.py index 610e6398a..e6d485c36 100644 --- a/src/core/widgets/services/copilot/api.py +++ b/src/core/widgets/services/copilot/api.py @@ -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 @@ -17,13 +17,14 @@ 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, } @@ -31,13 +32,12 @@ 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 @@ -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: @@ -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}" ) @@ -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}") @@ -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 @@ -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) ] diff --git a/src/core/widgets/yasb/copilot.py b/src/core/widgets/yasb/copilot.py index c027fb6b3..d3fde4e46 100644 --- a/src/core/widgets/yasb/copilot.py +++ b/src/core/widgets/yasb/copilot.py @@ -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 @@ -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() @@ -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): @@ -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()) @@ -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"(.*?)", 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): @@ -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: @@ -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)) @@ -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"GitHub Copilot ({plan_name})") header.setProperty("class", "header") @@ -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() @@ -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() @@ -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) @@ -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) @@ -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)