From 5bd39d05ba1b8502ce57ec85a9c10e78ba09f198 Mon Sep 17 00:00:00 2001 From: Farkhod Fayzullaev Date: Sat, 17 Jan 2026 23:02:52 -0700 Subject: [PATCH] fix: use public billing API for Copilot quota after OpenCode partnership change OpenCode's new OAuth integration (Jan 2026) doesn't grant access to the internal /copilot_internal/* endpoints. This adds support for querying quota via GitHub's public billing API using a user-configured fine-grained PAT with 'Plan' read permission. Changes: - Add CopilotQuotaConfig type with token, username, and tier fields - Add CopilotTier type for subscription tiers (free/pro/pro+/business/enterprise) - Read config from ~/.config/opencode/copilot-quota-token.json - Call public API: GET /users/{username}/settings/billing/premium_request/usage - Format response with progress bar, model breakdown, and billing period - Fall back to internal API for legacy tokens - Add helpful setup instructions in error messages (EN/ZH) Tier limits: - free: 50 requests/month - pro: 300 requests/month - pro+: 1500 requests/month - business: 300 requests/month - enterprise: 1000 requests/month --- plugin/lib/copilot.ts | 360 ++++++++++++++++++++++++++++++++++++++---- plugin/lib/i18n.ts | 28 ++++ plugin/lib/types.ts | 22 +++ 3 files changed, 383 insertions(+), 27 deletions(-) diff --git a/plugin/lib/copilot.ts b/plugin/lib/copilot.ts index 861e2b3..af69106 100644 --- a/plugin/lib/copilot.ts +++ b/plugin/lib/copilot.ts @@ -5,11 +5,23 @@ * [Output]: Formatted quota usage information with progress bars * [Location]: Called by mystatus.ts to handle GitHub Copilot accounts * [Sync]: mystatus.ts, types.ts, utils.ts, i18n.ts + * + * [Updated]: Jan 2026 - Handle new OpenCode official partnership auth flow + * The new OAuth tokens (gho_) need to be exchanged for Copilot session tokens + * before calling the internal quota API. */ import { t } from "./i18n"; -import { type QueryResult, type CopilotAuthData } from "./types"; +import { + type QueryResult, + type CopilotAuthData, + type CopilotQuotaConfig, + type CopilotTier, +} from "./types"; import { createProgressBar, fetchWithTimeout } from "./utils"; +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; // ============================================================================ // Type Definitions @@ -45,55 +57,251 @@ interface CopilotUsageResponse { quota_snapshots: QuotaSnapshots; } +interface CopilotTokenResponse { + token: string; + expires_at: number; + refresh_in: number; + endpoints: { + api: string; + }; +} + +// Public Billing API response types +interface BillingUsageItem { + product: string; + sku: string; + model?: string; + unitType: string; + grossQuantity: number; + netQuantity: number; + limit?: number; +} + +interface BillingUsageResponse { + timePeriod: { year: number; month?: number }; + user: string; + usageItems: BillingUsageItem[]; +} + // ============================================================================ // Constants // ============================================================================ const GITHUB_API_BASE_URL = "https://api.github.com"; -const COPILOT_VERSION = "0.26.7"; +const COPILOT_API_BASE_URL = "https://api.githubcopilot.com"; + +// Config file path for user's fine-grained PAT +const COPILOT_QUOTA_CONFIG_PATH = path.join( + os.homedir(), + ".config", + "opencode", + "copilot-quota-token.json", +); + +// Updated to match current VS Code Copilot extension version +const COPILOT_VERSION = "0.35.0"; +const EDITOR_VERSION = "vscode/1.107.0"; const EDITOR_PLUGIN_VERSION = `copilot-chat/${COPILOT_VERSION}`; const USER_AGENT = `GitHubCopilotChat/${COPILOT_VERSION}`; -const API_VERSION = "2025-04-01"; + +// Headers matching opencode-copilot-auth plugin (required for token exchange) +const COPILOT_HEADERS = { + "User-Agent": USER_AGENT, + "Editor-Version": EDITOR_VERSION, + "Editor-Plugin-Version": EDITOR_PLUGIN_VERSION, + "Copilot-Integration-Id": "vscode-chat", +}; + +// ============================================================================ +// Token Exchange (New auth flow for official OpenCode partnership) +// ============================================================================ + +/** + * Read optional Copilot quota config from user's config file + * Returns null if file doesn't exist or is invalid + */ +function readQuotaConfig(): CopilotQuotaConfig | null { + try { + if (!fs.existsSync(COPILOT_QUOTA_CONFIG_PATH)) { + return null; + } + const content = fs.readFileSync(COPILOT_QUOTA_CONFIG_PATH, "utf-8"); + const config = JSON.parse(content) as CopilotQuotaConfig; + + // Validate required fields + if (!config.token || !config.username || !config.tier) { + return null; + } + + // Validate tier is valid + const validTiers: CopilotTier[] = [ + "free", + "pro", + "pro+", + "business", + "enterprise", + ]; + if (!validTiers.includes(config.tier)) { + return null; + } + + return config; + } catch { + return null; + } +} + +/** + * Fetch quota using the public GitHub REST API + * Requires a fine-grained PAT with "Plan" read permission + */ +async function fetchPublicBillingUsage( + config: CopilotQuotaConfig, +): Promise { + const response = await fetchWithTimeout( + `${GITHUB_API_BASE_URL}/users/${config.username}/settings/billing/premium_request/usage`, + { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${config.token}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + }, + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(t.copilotApiError(response.status, errorText)); + } + + return response.json() as Promise; +} + +/** + * Exchange OAuth token for a Copilot session token + * Required for the new OpenCode official partnership auth flow (Jan 2026+) + */ +async function exchangeForCopilotToken( + oauthToken: string, +): Promise { + try { + const response = await fetchWithTimeout( + `${GITHUB_API_BASE_URL}/copilot_internal/v2/token`, + { + headers: { + Accept: "application/json", + Authorization: `Bearer ${oauthToken}`, + ...COPILOT_HEADERS, + }, + }, + ); + + if (!response.ok) { + // Token exchange failed - might be old token format or API change + return null; + } + + const tokenData: CopilotTokenResponse = await response.json(); + return tokenData.token; + } catch { + return null; + } +} // ============================================================================ // API Call // ============================================================================ /** - * Build headers for GitHub API requests + * Build headers for GitHub API requests (quota endpoint) */ function buildGitHubHeaders(token: string): Record { return { - "content-type": "application/json", - accept: "application/json", - authorization: `token ${token}`, - "editor-version": "vscode/1.96.0", - "editor-plugin-version": EDITOR_PLUGIN_VERSION, - "user-agent": USER_AGENT, - "x-github-api-version": API_VERSION, - "x-vscode-user-agent-library-version": "electron-fetch", + "Content-Type": "application/json", + Accept: "application/json", + Authorization: `Bearer ${token}`, + ...COPILOT_HEADERS, + }; +} + +/** + * Build headers for legacy token format + */ +function buildLegacyHeaders(token: string): Record { + return { + "Content-Type": "application/json", + Accept: "application/json", + Authorization: `token ${token}`, + ...COPILOT_HEADERS, }; } /** * Fetch GitHub Copilot usage data + * Tries multiple authentication methods to handle both old and new token formats */ async function fetchCopilotUsage( - token: string + authData: CopilotAuthData, ): Promise { - const response = await fetchWithTimeout( - `${GITHUB_API_BASE_URL}/copilot_internal/user`, - { - headers: buildGitHubHeaders(token), + // Use refresh token as the OAuth token (required) + // In new auth flow, access === refresh (both are the OAuth token) + const oauthToken = authData.refresh || authData.access; + if (!oauthToken) { + throw new Error("No OAuth token found in auth data"); + } + + const cachedAccessToken = authData.access; + const tokenExpiry = authData.expires || 0; + + // Strategy 1: If we have a valid cached access token (from previous exchange), use it + if ( + cachedAccessToken && + cachedAccessToken !== oauthToken && + tokenExpiry > Date.now() + ) { + const response = await fetchWithTimeout( + `${GITHUB_API_BASE_URL}/copilot_internal/user`, + { headers: buildGitHubHeaders(cachedAccessToken) }, + ); + + if (response.ok) { + return response.json() as Promise; } + } + + // Strategy 2: Try direct call with OAuth token (works with older token formats) + const directResponse = await fetchWithTimeout( + `${GITHUB_API_BASE_URL}/copilot_internal/user`, + { headers: buildLegacyHeaders(oauthToken) }, ); - if (!response.ok) { - const errorText = await response.text(); - throw new Error(t.copilotApiError(response.status, errorText)); + if (directResponse.ok) { + return directResponse.json() as Promise; + } + + // Strategy 3: Exchange OAuth token for Copilot session token (new auth flow) + const copilotToken = await exchangeForCopilotToken(oauthToken); + + if (copilotToken) { + const exchangedResponse = await fetchWithTimeout( + `${GITHUB_API_BASE_URL}/copilot_internal/user`, + { headers: buildGitHubHeaders(copilotToken) }, + ); + + if (exchangedResponse.ok) { + return exchangedResponse.json() as Promise; + } + + const errorText = await exchangedResponse.text(); + throw new Error(t.copilotApiError(exchangedResponse.status, errorText)); } - return response.json() as Promise; + // All strategies failed - likely due to OpenCode's OAuth token lacking copilot scope + // The new OpenCode partnership uses a different OAuth client that doesn't grant + // access to the /copilot_internal/* endpoints + throw new Error( + t.copilotQuotaUnavailable + "\n\n" + t.copilotQuotaWorkaround, + ); } // ============================================================================ @@ -106,7 +314,7 @@ async function fetchCopilotUsage( function formatQuotaLine( name: string, quota: QuotaDetail | undefined, - width: number = 20 + width: number = 20, ): string { if (!quota) return ""; @@ -185,6 +393,81 @@ function formatCopilotUsage(data: CopilotUsageResponse): string { return lines.join("\n"); } +// Copilot plan limits (premium requests per month) +// Source: https://docs.github.com/en/copilot/about-github-copilot/subscription-plans-for-github-copilot +const COPILOT_PLAN_LIMITS: Record = { + free: 50, // Copilot Free: 50 premium requests/month + pro: 300, // Copilot Pro: 300 premium requests/month + "pro+": 1500, // Copilot Pro+: 1500 premium requests/month + business: 300, // Copilot Business: 300 premium requests/month + enterprise: 1000, // Copilot Enterprise: 1000 premium requests/month +}; + +/** + * Format public billing API response + * Different structure from internal API - aggregates usage items + * Uses grossQuantity (total requests made) since netQuantity shows post-discount amount + */ +function formatPublicBillingUsage( + data: BillingUsageResponse, + tier: CopilotTier, +): string { + const lines: string[] = []; + + // Account info + lines.push(`${t.account} GitHub Copilot (@${data.user})`); + lines.push(""); + + // Aggregate all premium request usage (sum grossQuantity across all models) + const premiumItems = data.usageItems.filter( + (item) => + item.sku === "Copilot Premium Request" || item.sku.includes("Premium"), + ); + + const totalUsed = premiumItems.reduce( + (sum, item) => sum + item.grossQuantity, + 0, + ); + + // Get limit from tier + const limit = COPILOT_PLAN_LIMITS[tier]; + + const remaining = Math.max(0, limit - totalUsed); + const percentRemaining = Math.round((remaining / limit) * 100); + const progressBar = createProgressBar(percentRemaining, 20); + lines.push( + `${t.premiumRequests.padEnd(14)} ${progressBar} ${percentRemaining}% (${totalUsed}/${limit})`, + ); + + // Show model breakdown + const modelItems = data.usageItems.filter( + (item) => item.model && item.grossQuantity > 0, + ); + + if (modelItems.length > 0) { + lines.push(""); + lines.push(t.modelBreakdown || "Model breakdown:"); + // Sort by usage descending + const sortedItems = [...modelItems].sort( + (a, b) => b.grossQuantity - a.grossQuantity, + ); + for (const item of sortedItems.slice(0, 5)) { + // Show top 5 models + lines.push(` ${item.model}: ${item.grossQuantity} ${item.unitType}`); + } + } + + // Time period info + lines.push(""); + const period = data.timePeriod; + const periodStr = period.month + ? `${period.year}-${String(period.month).padStart(2, "0")}` + : `${period.year}`; + lines.push(`${t.billingPeriod || "Period"}: ${periodStr}`); + + return lines.join("\n"); +} + // ============================================================================ // Export Interface // ============================================================================ @@ -193,19 +476,42 @@ export type { CopilotAuthData }; /** * Query GitHub Copilot account quota - * @param authData GitHub Copilot authentication data - * @returns Query result, null if account doesn't exist or is invalid + * @param authData GitHub Copilot authentication data (optional if using PAT config) + * @returns Query result, null if no account configured */ export async function queryCopilotUsage( - authData: CopilotAuthData | undefined + authData: CopilotAuthData | undefined, ): Promise { + // Strategy 1: Try public billing API with user's fine-grained PAT + const quotaConfig = readQuotaConfig(); + if (quotaConfig) { + try { + const billingUsage = await fetchPublicBillingUsage(quotaConfig); + return { + success: true, + output: formatPublicBillingUsage(billingUsage, quotaConfig.tier), + }; + } catch (err) { + // PAT config exists but failed - report the error + return { + success: false, + error: err instanceof Error ? err.message : String(err), + }; + } + } + + // Strategy 2: Try internal API with OAuth token (legacy, may not work with new OpenCode auth) // Check if account exists and has a refresh token (the GitHub OAuth token) if (!authData || authData.type !== "oauth" || !authData.refresh) { - return null; + // No auth data and no PAT config - show setup instructions + return { + success: false, + error: t.copilotQuotaUnavailable + "\n\n" + t.copilotQuotaWorkaround, + }; } try { - const usage = await fetchCopilotUsage(authData.refresh); + const usage = await fetchCopilotUsage(authData); return { success: true, output: formatCopilotUsage(usage), diff --git a/plugin/lib/i18n.ts b/plugin/lib/i18n.ts index 3ded730..2c15d4b 100644 --- a/plugin/lib/i18n.ts +++ b/plugin/lib/i18n.ts @@ -107,6 +107,20 @@ const translations = { overageRequests: "次请求", quotaResets: "配额重置", resetsSoon: "即将重置", + modelBreakdown: "模型使用明细:", + billingPeriod: "计费周期", + copilotQuotaUnavailable: + "⚠️ GitHub Copilot 配额查询暂时不可用。\n" + + "OpenCode 的新 OAuth 集成不支持访问配额 API。", + copilotQuotaWorkaround: + "解决方案:\n" + + "1. 创建一个 fine-grained PAT (访问 https://github.com/settings/tokens?type=beta)\n" + + "2. 在 'Account permissions' 中将 'Plan' 设为 'Read-only'\n" + + "3. 创建配置文件 ~/.config/opencode/copilot-quota-token.json:\n" + + ' {"token": "github_pat_xxx...", "username": "你的用户名"}\n\n' + + "其他方法:\n" + + "• 在 VS Code 中点击状态栏的 Copilot 图标查看配额\n" + + "• 访问 https://github.com/settings/billing 查看使用情况", }, en: { // 时间单位 @@ -171,6 +185,20 @@ const translations = { overageRequests: "requests", quotaResets: "Quota resets", resetsSoon: "Resets soon", + modelBreakdown: "Model breakdown:", + billingPeriod: "Period", + copilotQuotaUnavailable: + "⚠️ GitHub Copilot quota query unavailable.\n" + + "OpenCode's new OAuth integration doesn't support quota API access.", + copilotQuotaWorkaround: + "Solution:\n" + + "1. Create a fine-grained PAT (visit https://github.com/settings/tokens?type=beta)\n" + + "2. Under 'Account permissions', set 'Plan' to 'Read-only'\n" + + "3. Create config file ~/.config/opencode/copilot-quota-token.json:\n" + + ' {"token": "github_pat_xxx...", "username": "YourUsername"}\n\n' + + "Alternatives:\n" + + "• Click the Copilot icon in VS Code status bar to view quota\n" + + "• Visit https://github.com/settings/billing for usage info", }, } as const; diff --git a/plugin/lib/types.ts b/plugin/lib/types.ts index e798201..01543f8 100644 --- a/plugin/lib/types.ts +++ b/plugin/lib/types.ts @@ -50,6 +50,28 @@ export interface CopilotAuthData { expires?: number; } +/** + * Copilot subscription tier + * See: https://docs.github.com/en/copilot/about-github-copilot/subscription-plans-for-github-copilot + */ +export type CopilotTier = "free" | "pro" | "pro+" | "business" | "enterprise"; + +/** + * Copilot quota token configuration + * Stored in ~/.config/opencode/copilot-quota-token.json + * + * Users can create a fine-grained PAT with "Plan" read permission + * to enable quota checking via the public GitHub REST API. + */ +export interface CopilotQuotaConfig { + /** Fine-grained PAT with "Plan" read permission */ + token: string; + /** GitHub username (for API calls) */ + username: string; + /** Copilot subscription tier (determines monthly quota limit) */ + tier: CopilotTier; +} + /** * Antigravity 账号(来自 ~/.config/opencode/antigravity-accounts.json) */