diff --git a/README.md b/README.md index 11e01e0..562e5c1 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ Token reporting commands are the `/tokens_*` family (there is no `/token` comman | Provider | Config ID | Auth Source | | ------------------ | -------------------- | --------------------------------------------- | -| GitHub Copilot | `copilot` | OpenCode auth (automatic) | +| GitHub Copilot | `copilot` | OpenCode auth (automatic), PAT for org billing | | OpenAI (Plus/Pro) | `openai` | OpenCode auth (automatic) | | Qwen Code (OAuth) | `qwen-code` | OpenCode auth via `opencode-qwencode-auth` | | Firmware AI | `firmware` | OpenCode auth or API key | @@ -116,26 +116,33 @@ npm run build
GitHub Copilot -**Setup:** Works automatically if OpenCode has Copilot configured and logged in. +**Setup:** Personal Copilot usage works automatically when OpenCode has Copilot configured and logged in. -**Optional:** For more reliable quota reporting, provide a fine-grained PAT: +**Personal usage:** No extra setup required. The plugin uses OpenCode auth to query your user billing report. -1. Create a fine-grained PAT at GitHub with **Account permissions > Plan > Read** -2. Create `copilot-quota-token.json` under OpenCode's runtime config directory (see `opencode debug paths`): +**Organization usage:** Create `copilot-quota-token.json` under OpenCode's runtime config directory (see `opencode debug paths`). + +Organization example: ```json { "token": "github_pat_...", - "tier": "pro" + "tier": "business", + "organization": "your-org-slug" } ``` -`username` is optional (kept for backwards compatibility). If provided, it is used only as a fallback for legacy GitHub REST paths. +For organization-managed Copilot plans such as `business` or `enterprise`, `organization` is required. `username` is optional and is only used as the `?user=` filter on the organization report. -Both fine-grained PATs (`github_pat_...`) and classic PATs (`ghp_...`) should work. Fine-grained PATs must include **Account permissions > Plan > Read**. +- **Organization PAT permission:** fine-grained PAT with **Organization permissions > Administration > Read**. Tier options: `free`, `pro`, `pro+`, `business`, `enterprise` +If both OpenCode Copilot auth and `copilot-quota-token.json` are present, the plugin uses the PAT config first. + +For personal plans, a PAT is optional. Use it only if you want an explicit tier override for quota totals. + +Run `/quota_status` and check `copilot_quota_auth` to confirm `pat_state`, `pat_organization`, candidate paths checked, and `effective_source`/`override`.
@@ -386,7 +393,8 @@ After configuration, instruct the user to: - **Toast not showing**: Run `/quota_status` to diagnose - **Google Antigravity not working**: Ensure `opencode-antigravity-auth` plugin is installed and accounts are configured -- **Copilot quota unreliable**: Consider setting up a fine-grained PAT (see Provider-Specific Setup above) +- **Copilot quota unreliable**: For personal plans, OpenCode auth should work without extra setup; add `copilot-quota-token.json` only if you need a PAT-based override +- **Copilot organization-managed usage missing**: Add `"organization": "your-org-slug"` to `copilot-quota-token.json` so the plugin uses the organization billing report
diff --git a/src/lib/copilot.ts b/src/lib/copilot.ts index 4252e34..a2cefae 100644 --- a/src/lib/copilot.ts +++ b/src/lib/copilot.ts @@ -1,82 +1,259 @@ /** - * GitHub Copilot quota fetcher + * GitHub Copilot premium request usage fetcher. * - * Strategy (new Copilot API reality): - * - * 1) Preferred: GitHub public billing API using a fine-grained PAT - * configured in ~/.config/opencode/copilot-quota-token.json. - * 2) Best-effort: internal endpoint using OpenCode's stored OAuth token - * (legacy formats or via token exchange). + * The plugin only uses documented GitHub billing APIs: + * - /users/{username}/settings/billing/premium_request/usage + * - /organizations/{org}/settings/billing/premium_request/usage */ +import { existsSync, readFileSync } from "fs"; +import { join } from "path"; + import type { + AuthData, CopilotAuthData, CopilotQuotaConfig, - CopilotTier, - CopilotUsageResponse, CopilotQuotaResult, - QuotaError, CopilotResult, + CopilotTier, + QuotaError, } from "./types.js"; import { fetchWithTimeout } from "./http.js"; import { readAuthFile } from "./opencode-auth.js"; +import { getOpencodeRuntimeDirCandidates } from "./opencode-runtime-paths.js"; -import { existsSync, readFileSync } from "fs"; -import { homedir } from "os"; -import { join } from "path"; +const GITHUB_API_BASE_URL = "https://api.github.com"; +const GITHUB_API_VERSION = "2022-11-28"; +const COPILOT_QUOTA_CONFIG_FILENAME = "copilot-quota-token.json"; +const USER_AGENT = "opencode-quota/copilot-billing"; -// ============================================================================= -// Constants -// ============================================================================= +type GitHubRestAuthScheme = "bearer" | "token"; +type CopilotAuthKeyName = + | "github-copilot" + | "copilot" + | "copilot-chat" + | "github-copilot-chat"; +type CopilotPatTokenKind = "github_pat" | "ghp" | "other"; +type EffectiveCopilotAuthSource = "pat" | "oauth" | "none"; + +export type CopilotPatState = "absent" | "invalid" | "valid"; + +export interface CopilotPatReadResult { + state: CopilotPatState; + checkedPaths: string[]; + selectedPath?: string; + config?: CopilotQuotaConfig; + error?: string; + tokenKind?: CopilotPatTokenKind; +} -const GITHUB_API_BASE_URL = "https://api.github.com"; -const COPILOT_INTERNAL_USER_URL = `${GITHUB_API_BASE_URL}/copilot_internal/user`; -const COPILOT_TOKEN_EXCHANGE_URL = `${GITHUB_API_BASE_URL}/copilot_internal/v2/token`; +export interface CopilotQuotaAuthDiagnostics { + pat: CopilotPatReadResult; + oauth: { + configured: boolean; + keyName: CopilotAuthKeyName | null; + hasRefreshToken: boolean; + hasAccessToken: boolean; + }; + effectiveSource: EffectiveCopilotAuthSource; + override: "pat_overrides_oauth" | "none"; +} -// Keep these aligned with current Copilot/VSC versions to avoid API heuristics. -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}`; +interface BillingUsageItem { + product?: string; + sku?: string; + model?: string; + unitType?: string; + unit_type?: string; + grossQuantity?: number; + gross_quantity?: number; + netQuantity?: number; + net_quantity?: number; + limit?: number; +} -const COPILOT_QUOTA_CONFIG_PATH = join( - process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), - "opencode", - "copilot-quota-token.json", -); +interface BillingUsageResponse { + timePeriod?: { year: number; month?: number }; + time_period?: { year: number; month?: number }; + user?: string; + organization?: string; + usageItems?: BillingUsageItem[]; + usage_items?: BillingUsageItem[]; +} -// ============================================================================= -// Helpers -// ============================================================================= +interface GitHubViewerResponse { + login?: string; +} -/** - * Build headers for GitHub API requests - */ -const COPILOT_HEADERS: Record = { - "User-Agent": USER_AGENT, - "Editor-Version": EDITOR_VERSION, - "Editor-Plugin-Version": EDITOR_PLUGIN_VERSION, - "Copilot-Integration-Id": "vscode-chat", +const COPILOT_PLAN_LIMITS: Record = { + free: 50, + pro: 300, + "pro+": 1500, + business: 300, + enterprise: 1000, }; -function buildBearerHeaders(token: string): Record { +function dedupeStrings(values: Array): string[] { + const out: string[] = []; + const seen = new Set(); + + for (const value of values) { + const trimmed = value?.trim(); + if (!trimmed || seen.has(trimmed)) continue; + seen.add(trimmed); + out.push(trimmed); + } + + return out; +} + +function classifyPatTokenKind(token: string): CopilotPatTokenKind { + if (token.startsWith("github_pat_")) return "github_pat"; + if (token.startsWith("ghp_")) return "ghp"; + return "other"; +} + +export function getCopilotPatConfigCandidatePaths(): string[] { + const { configDirs } = getOpencodeRuntimeDirCandidates(); + return dedupeStrings( + configDirs.map((configDir) => join(configDir, COPILOT_QUOTA_CONFIG_FILENAME)), + ); +} + +function validateQuotaConfig(raw: unknown): { config: CopilotQuotaConfig | null; error?: string } { + if (!raw || typeof raw !== "object") { + return { config: null, error: "Config must be a JSON object" }; + } + + const obj = raw as Record; + const token = typeof obj.token === "string" ? obj.token.trim() : ""; + const tier = typeof obj.tier === "string" ? obj.tier.trim() : ""; + + if (!token) { + return { config: null, error: "Missing required string field: token" }; + } + + const validTiers: CopilotTier[] = ["free", "pro", "pro+", "business", "enterprise"]; + if (!validTiers.includes(tier as CopilotTier)) { + return { + config: null, + error: "Invalid tier; expected one of: free, pro, pro+, business, enterprise", + }; + } + + const usernameRaw = obj.username; + let username: string | undefined; + if (usernameRaw != null) { + if (typeof usernameRaw !== "string" || !usernameRaw.trim()) { + return { config: null, error: "username must be a non-empty string when provided" }; + } + username = usernameRaw.trim(); + } + + const organizationRaw = obj.organization; + let organization: string | undefined; + if (organizationRaw != null) { + if (typeof organizationRaw !== "string" || !organizationRaw.trim()) { + return { config: null, error: "organization must be a non-empty string when provided" }; + } + organization = organizationRaw.trim(); + } + return { - Accept: "application/json", - Authorization: `Bearer ${token}`, - ...COPILOT_HEADERS, + config: { + token, + tier: tier as CopilotTier, + username, + organization, + }, }; } -function buildLegacyTokenHeaders(token: string): Record { +export function readQuotaConfigWithMeta(): CopilotPatReadResult { + const checkedPaths = getCopilotPatConfigCandidatePaths(); + + for (const path of checkedPaths) { + if (!existsSync(path)) continue; + + try { + const content = readFileSync(path, "utf-8"); + const parsed = JSON.parse(content) as unknown; + const validated = validateQuotaConfig(parsed); + + if (!validated.config) { + return { + state: "invalid", + checkedPaths, + selectedPath: path, + error: validated.error ?? "Invalid config", + }; + } + + return { + state: "valid", + checkedPaths, + selectedPath: path, + config: validated.config, + tokenKind: classifyPatTokenKind(validated.config.token), + }; + } catch (error) { + return { + state: "invalid", + checkedPaths, + selectedPath: path, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + return { state: "absent", checkedPaths }; +} + +function selectCopilotAuth(authData: AuthData | null): { + auth: CopilotAuthData | null; + keyName: CopilotAuthKeyName | null; +} { + if (!authData) { + return { auth: null, keyName: null }; + } + + const candidates: Array<[CopilotAuthKeyName, CopilotAuthData | undefined]> = [ + ["github-copilot", authData["github-copilot"]], + ["copilot", authData.copilot], + ["copilot-chat", authData["copilot-chat"]], + ["github-copilot-chat", authData["github-copilot-chat"]], + ]; + + for (const [keyName, auth] of candidates) { + if (!auth || auth.type !== "oauth") continue; + if (!auth.access && !auth.refresh) continue; + return { auth, keyName }; + } + + return { auth: null, keyName: null }; +} + +export function getCopilotQuotaAuthDiagnostics(authData: AuthData | null): CopilotQuotaAuthDiagnostics { + const pat = readQuotaConfigWithMeta(); + const { auth, keyName } = selectCopilotAuth(authData); + + let effectiveSource: EffectiveCopilotAuthSource = "none"; + if (pat.state === "valid") effectiveSource = "pat"; + else if (auth) effectiveSource = "oauth"; + return { - Accept: "application/json", - Authorization: `token ${token}`, - ...COPILOT_HEADERS, + pat, + oauth: { + configured: Boolean(auth), + keyName, + hasRefreshToken: Boolean(auth?.refresh), + hasAccessToken: Boolean(auth?.access), + }, + effectiveSource, + override: pat.state === "valid" && auth ? "pat_overrides_oauth" : "none", }; } -type GitHubRestAuthScheme = "bearer" | "token"; - function buildGitHubRestHeaders( token: string, scheme: GitHubRestAuthScheme, @@ -84,21 +261,13 @@ function buildGitHubRestHeaders( return { Accept: "application/vnd.github+json", Authorization: scheme === "bearer" ? `Bearer ${token}` : `token ${token}`, - "X-GitHub-Api-Version": "2022-11-28", + "X-GitHub-Api-Version": GITHUB_API_VERSION, "User-Agent": USER_AGENT, }; } function preferredSchemesForToken(token: string): GitHubRestAuthScheme[] { - const t = token.trim(); - - // Fine-grained PATs usually prefer Bearer. - if (t.startsWith("github_pat_")) { - return ["bearer", "token"]; - } - - // Classic PATs historically prefer legacy `token`. - if (t.startsWith("ghp_")) { + if (token.startsWith("ghp_")) { return ["token", "bearer"]; } @@ -109,16 +278,20 @@ async function readGitHubRestErrorMessage(response: Response): Promise { const text = await response.text(); try { - const parsed = JSON.parse(text) as unknown; - if (parsed && typeof parsed === "object") { - const obj = parsed as Record; - const msg = typeof obj.message === "string" ? obj.message : null; - const doc = typeof obj.documentation_url === "string" ? obj.documentation_url : null; - if (msg && doc) return `${msg} (${doc})`; - if (msg) return msg; + const parsed = JSON.parse(text) as Record; + const message = typeof parsed.message === "string" ? parsed.message : null; + const documentationUrl = + typeof parsed.documentation_url === "string" ? parsed.documentation_url : null; + + if (message && documentationUrl) { + return `${message} (${documentationUrl})`; + } + + if (message) { + return message; } } catch { - // ignore + // ignore parse failures } return text.slice(0, 160); @@ -128,10 +301,7 @@ async function fetchGitHubRestJsonOnce( url: string, token: string, scheme: GitHubRestAuthScheme, -): Promise< - | { ok: true; status: number; data: T } - | { ok: false; status: number; message: string } -> { +): Promise<{ ok: true; status: number; data: T } | { ok: false; status: number; message: string }> { const response = await fetchWithTimeout(url, { headers: buildGitHubRestHeaders(token, scheme), }); @@ -147,355 +317,238 @@ async function fetchGitHubRestJsonOnce( }; } -/** - * Read Copilot auth data from auth.json - * - * Tries multiple key names to handle different OpenCode versions/configs. - */ -async function readCopilotAuth(): Promise { - const authData = await readAuthFile(); - if (!authData) return null; - - // Try known key names in priority order - const copilotAuth = - authData["github-copilot"] ?? - (authData as Record)["copilot"] ?? - (authData as Record)["copilot-chat"]; +async function resolveGitHubUsername(token: string): Promise { + const url = `${GITHUB_API_BASE_URL}/user`; + let unauthorized: { status: number; message: string } | null = null; - if (!copilotAuth || copilotAuth.type !== "oauth" || !copilotAuth.refresh) { - return null; - } + for (const scheme of preferredSchemesForToken(token)) { + const result = await fetchGitHubRestJsonOnce(url, token, scheme); - return copilotAuth; -} - -/** - * 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 (!existsSync(COPILOT_QUOTA_CONFIG_PATH)) { - return null; + if (result.ok) { + const login = result.data.login?.trim(); + if (login) return login; + throw new Error("GitHub /user response did not include a login"); } - const content = readFileSync(COPILOT_QUOTA_CONFIG_PATH, "utf-8"); - const parsed = JSON.parse(content) as CopilotQuotaConfig; - - if (!parsed || typeof parsed !== "object") return null; - - if (typeof parsed.token !== "string" || parsed.token.trim() === "") return null; - if (typeof parsed.tier !== "string" || parsed.tier.trim() === "") return null; - - // Username is optional now that we prefer the /user/... billing endpoint. - if (parsed.username != null) { - if (typeof parsed.username !== "string" || parsed.username.trim() === "") return null; + if (result.status === 401) { + unauthorized = { status: result.status, message: result.message }; + continue; } - const validTiers: CopilotTier[] = ["free", "pro", "pro+", "business", "enterprise"]; - if (!validTiers.includes(parsed.tier as CopilotTier)) return null; + throw new Error(`GitHub API error ${result.status}: ${result.message}`); + } - return parsed; - } catch { - return null; + if (unauthorized) { + throw new Error( + `GitHub API error ${unauthorized.status}: ${unauthorized.message} (token rejected while resolving username)`, + ); } -} -// Public billing API response types (keep local; only used here) -interface BillingUsageItem { - product: string; - sku: string; - model?: string; - unitType: string; - grossQuantity: number; - netQuantity: number; - limit?: number; + throw new Error("Unable to resolve GitHub username for Copilot billing request"); } -interface BillingUsageResponse { - timePeriod: { year: number; month?: number }; - user: string; - usageItems: BillingUsageItem[]; -} +function getBillingRequestUrl(params: { + organization?: string; + username?: string; +}): string { + if (params.organization) { + const base = `${GITHUB_API_BASE_URL}/organizations/${encodeURIComponent(params.organization)}/settings/billing/premium_request/usage`; + return params.username ? `${base}?user=${encodeURIComponent(params.username)}` : base; + } -const COPILOT_PLAN_LIMITS: Record = { - free: 50, - pro: 300, - "pro+": 1500, - business: 300, - enterprise: 1000, -}; + if (!params.username) { + throw new Error("GitHub username is required for user premium request usage"); + } -function getApproxNextResetIso(nowMs: number = Date.now()): string { - const now = new Date(nowMs); - const year = now.getUTCFullYear(); - const month = now.getUTCMonth(); - return new Date(Date.UTC(year, month + 1, 1, 0, 0, 0, 0)).toISOString(); + return `${GITHUB_API_BASE_URL}/users/${encodeURIComponent(params.username)}/settings/billing/premium_request/usage`; } -async function fetchPublicBillingUsage(config: CopilotQuotaConfig): Promise { - const token = config.token; - const schemes = preferredSchemesForToken(token); +async function fetchPremiumRequestUsage(params: { + token: string; + username?: string; + organization?: string; +}): Promise { + const username = params.organization ? params.username : params.username ?? (await resolveGitHubUsername(params.token)); + const url = getBillingRequestUrl({ + organization: params.organization, + username, + }); - // Prefer authenticated-user endpoint; fall back to /users/{username} for older behavior. - const urls: string[] = [`${GITHUB_API_BASE_URL}/user/settings/billing/premium_request/usage`]; - if (config.username) { - urls.push( - `${GITHUB_API_BASE_URL}/users/${config.username}/settings/billing/premium_request/usage`, - ); - } + let unauthorized: { status: number; message: string } | null = null; - for (const url of urls) { - let lastUnauthorized: { status: number; message: string } | null = null; + for (const scheme of preferredSchemesForToken(params.token)) { + const result = await fetchGitHubRestJsonOnce(url, params.token, scheme); - for (const scheme of schemes) { - const res = await fetchGitHubRestJsonOnce(url, token, scheme); + if (result.ok) { + return result.data; + } - if (res.ok) { - return res.data; - } + if (result.status === 401) { + unauthorized = { status: result.status, message: result.message }; + continue; + } - if (res.status === 401) { - lastUnauthorized = { status: res.status, message: res.message }; - continue; // retry with alternate scheme - } + throw new Error(`GitHub API error ${result.status}: ${result.message}`); + } - // If /user/... isn't supported for some reason, fall back to /users/... when available. - if (res.status === 404 && url.includes("/user/")) { - break; - } + if (unauthorized) { + throw new Error( + `GitHub API error ${unauthorized.status}: ${unauthorized.message} (token rejected for Copilot premium request usage)`, + ); + } - throw new Error(`GitHub API error ${res.status}: ${res.message}`); - } + throw new Error("Unable to fetch Copilot premium request usage"); +} - if (lastUnauthorized) { - throw new Error( - `GitHub API error ${lastUnauthorized.status}: ${lastUnauthorized.message} (token rejected; verify PAT and permissions)`, - ); - } - } +function getApproxNextResetIso(nowMs: number = Date.now()): string { + const now = new Date(nowMs); + return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)).toISOString(); +} - throw new Error("GitHub API error 404: Not Found"); +function computePercentRemainingFromUsed(params: { used: number; total: number }): number { + const { used, total } = params; + if (!Number.isFinite(total) || total <= 0) return 0; + if (!Number.isFinite(used) || used <= 0) return 100; + const usedPct = Math.max(0, Math.min(100, Math.ceil((used / total) * 100))); + return 100 - usedPct; } function toQuotaResultFromBilling( - data: BillingUsageResponse, - tier: CopilotTier, + response: BillingUsageResponse, + fallbackTier?: CopilotTier, ): CopilotQuotaResult { - const items = Array.isArray(data.usageItems) ? data.usageItems : []; - - const premiumItems = items.filter( - (item) => - item && - typeof item === "object" && - typeof item.sku === "string" && - (item.sku === "Copilot Premium Request" || item.sku.includes("Premium")), - ); + const items = Array.isArray(response.usageItems) + ? response.usageItems + : Array.isArray(response.usage_items) + ? response.usage_items + : []; + + const premiumItems = items.filter((item) => { + if (!item || typeof item !== "object") return false; + if (typeof item.sku !== "string") return false; + return item.sku === "Copilot Premium Request" || item.sku.includes("Premium"); + }); + + if (premiumItems.length === 0 && items.length > 0) { + const skus = items.map((item) => (typeof item?.sku === "string" ? item.sku : "?")).join(", "); + throw new Error( + `No premium-request items found in billing response (${items.length} items, SKUs: ${skus}). Expected an item with SKU containing "Premium".`, + ); + } + + if (premiumItems.length === 0) { + throw new Error("Billing API returned empty usageItems array for Copilot premium requests."); + } - const used = premiumItems.reduce((sum, item) => sum + (item.grossQuantity || 0), 0); + const used = premiumItems.reduce((sum, item) => { + const gross = item.grossQuantity ?? item.gross_quantity ?? 0; + return sum + (typeof gross === "number" ? gross : 0); + }, 0); - const limits = premiumItems + const apiLimits = premiumItems .map((item) => item.limit) - .filter((n): n is number => typeof n === "number" && n > 0); + .filter((limit): limit is number => typeof limit === "number" && limit > 0); - // Prefer API-provided limits when available (more future-proof than hardcoding). - const total = limits.length ? Math.max(...limits) : COPILOT_PLAN_LIMITS[tier]; + const total = apiLimits.length > 0 ? Math.max(...apiLimits) : fallbackTier ? COPILOT_PLAN_LIMITS[fallbackTier] : undefined; if (!total || total <= 0) { - throw new Error(`Unsupported Copilot tier: ${tier}`); + throw new Error( + "Copilot billing response did not include a limit. Configure copilot-quota-token.json with your tier so the plugin can compute quota totals.", + ); } - const remaining = Math.max(0, total - used); - const percentRemaining = Math.max(0, Math.min(100, Math.round((remaining / total) * 100))); - return { success: true, used, total, - percentRemaining, + percentRemaining: computePercentRemainingFromUsed({ used, total }), resetTimeIso: getApproxNextResetIso(), }; } -interface CopilotTokenResponse { - token: string; - expires_at: number; - refresh_in: number; - endpoints: { api: string }; +function getOAuthTokenCandidates(auth: CopilotAuthData): string[] { + return dedupeStrings([auth.access, auth.refresh]); } -async function exchangeForCopilotToken(oauthToken: string): Promise { - try { - const response = await fetchWithTimeout(COPILOT_TOKEN_EXCHANGE_URL, { - headers: { - Accept: "application/json", - Authorization: `Bearer ${oauthToken}`, - ...COPILOT_HEADERS, - }, - }); - - if (!response.ok) { - return null; - } - - const tokenData = (await response.json()) as CopilotTokenResponse; - if (!tokenData || typeof tokenData.token !== "string") return null; - return tokenData.token; - } catch { - return null; - } +function toQuotaError(message: string): QuotaError { + return { success: false, error: message }; } -/** - * Fetch Copilot usage from GitHub internal API. - * Tries multiple authentication methods to handle old/new token formats. - */ -async function fetchCopilotUsage(authData: CopilotAuthData): Promise { - 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(COPILOT_INTERNAL_USER_URL, { - headers: buildBearerHeaders(cachedAccessToken), - }); - - if (response.ok) { - return response.json() as Promise; - } - } - - // Strategy 2: Try direct call with OAuth token (newer tokens generally expect Bearer). - const directBearerResponse = await fetchWithTimeout(COPILOT_INTERNAL_USER_URL, { - headers: buildBearerHeaders(oauthToken), - }); - - if (directBearerResponse.ok) { - return directBearerResponse.json() as Promise; - } - - // Strategy 2b: Legacy auth format. - const directLegacyResponse = await fetchWithTimeout(COPILOT_INTERNAL_USER_URL, { - headers: buildLegacyTokenHeaders(oauthToken), - }); - - if (directLegacyResponse.ok) { - return directLegacyResponse.json() as Promise; - } - - // Strategy 3: Exchange OAuth token for Copilot session token (new auth flow). - const copilotToken = await exchangeForCopilotToken(oauthToken); - if (!copilotToken) { - const errorText = await directLegacyResponse.text(); - throw new Error(`GitHub Copilot quota unavailable: ${errorText.slice(0, 160)}`); - } - - const exchangedResponse = await fetchWithTimeout(COPILOT_INTERNAL_USER_URL, { - headers: buildBearerHeaders(copilotToken), - }); - - if (!exchangedResponse.ok) { - const errorText = await exchangedResponse.text(); - throw new Error(`GitHub API error ${exchangedResponse.status}: ${errorText.slice(0, 160)}`); +function validatePatBillingScope(config: CopilotQuotaConfig): string | null { + const isOrgTier = config.tier === "business" || config.tier === "enterprise"; + if (isOrgTier && !config.organization) { + return ( + `Copilot ${config.tier} usage requires an organization-scoped billing report. ` + + `Add "organization": "your-org-slug" to copilot-quota-token.json.` + ); } - return exchangedResponse.json() as Promise; + return null; } -// ============================================================================= -// Export -// ============================================================================= - /** - * Query GitHub Copilot premium requests quota + * Query GitHub Copilot premium request usage. * - * @returns Quota result, error, or null if not configured + * PAT configuration wins over OpenCode OAuth auth when both are present. */ export async function queryCopilotQuota(): Promise { - // Strategy 1: Try public billing API with user's fine-grained PAT. - const quotaConfig = readQuotaConfig(); - if (quotaConfig) { + const pat = readQuotaConfigWithMeta(); + + if (pat.state === "invalid") { + return toQuotaError( + `Invalid copilot-quota-token.json: ${pat.error ?? "unknown error"}${pat.selectedPath ? ` (${pat.selectedPath})` : ""}`, + ); + } + + if (pat.state === "valid" && pat.config) { + const scopeError = validatePatBillingScope(pat.config); + if (scopeError) return toQuotaError(scopeError); + try { - const billing = await fetchPublicBillingUsage(quotaConfig); - return toQuotaResultFromBilling(billing, quotaConfig.tier); - } catch (err) { - return { - success: false, - error: err instanceof Error ? err.message : String(err), - } as QuotaError; + const response = await fetchPremiumRequestUsage({ + token: pat.config.token, + username: pat.config.username, + organization: pat.config.organization, + }); + return toQuotaResultFromBilling(response, pat.config.tier); + } catch (error) { + return toQuotaError(error instanceof Error ? error.message : String(error)); } } - // Strategy 2: Best-effort internal API using OpenCode auth. - const auth = await readCopilotAuth(); + const authData = await readAuthFile(); + const { auth } = selectCopilotAuth(authData); if (!auth) { - return null; // Not configured + return null; } - try { - const data = await fetchCopilotUsage(auth); - const premium = data.quota_snapshots.premium_interactions; + const tokenCandidates = getOAuthTokenCandidates(auth); + if (tokenCandidates.length === 0) { + return null; + } - if (!premium) { - return { - success: false, - error: "No premium quota data", - } as QuotaError; - } + let lastError: string | null = null; - if (premium.unlimited) { - return { - success: true, - used: 0, - total: -1, // Indicate unlimited - percentRemaining: 100, - resetTimeIso: data.quota_reset_date, - } as CopilotQuotaResult; + for (const token of tokenCandidates) { + try { + const response = await fetchPremiumRequestUsage({ token }); + return toQuotaResultFromBilling(response); + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); } - - const total = premium.entitlement; - const used = total - premium.remaining; - const percentRemaining = Math.round(premium.percent_remaining); - - return { - success: true, - used, - total, - percentRemaining, - resetTimeIso: data.quota_reset_date, - } as CopilotQuotaResult; - } catch (err) { - return { - success: false, - error: err instanceof Error ? err.message : String(err), - } as QuotaError; } + + return toQuotaError( + lastError ?? + "Copilot billing usage could not be fetched from OpenCode auth. Configure copilot-quota-token.json to provide an explicit tier and PAT.", + ); } -/** - * Format Copilot quota for toast display - * - * @param result - Copilot quota result - * @returns Formatted string like "Copilot 229/300 (24%)" or null - */ export function formatCopilotQuota(result: CopilotResult): string | null { - if (!result) { - return null; - } - - if (!result.success) { + if (!result || !result.success) { return null; } - if (result.total === -1) { - return "Copilot Unlimited"; - } - const percentUsed = 100 - result.percentRemaining; return `Copilot ${result.used}/${result.total} (${percentUsed}%)`; } diff --git a/src/lib/quota-status.ts b/src/lib/quota-status.ts index a25e980..d49af80 100644 --- a/src/lib/quota-status.ts +++ b/src/lib/quota-status.ts @@ -6,6 +6,7 @@ import { getGoogleTokenCachePath } from "./google-token-cache.js"; import { getAntigravityAccountsCandidatePaths, readAntigravityAccounts } from "./google.js"; import { getFirmwareKeyDiagnostics } from "./firmware.js"; import { getChutesKeyDiagnostics } from "./chutes.js"; +import { getCopilotQuotaAuthDiagnostics } from "./copilot.js"; import { computeQwenQuota, getQwenLocalQuotaPath, @@ -334,6 +335,33 @@ export async function buildQuotaStatusReport(params: { lines.push(`- chutes api key checked: ${chutesDiag.checkedPaths.join(" | ")}`); } + const copilotDiag = getCopilotQuotaAuthDiagnostics(authData); + lines.push(""); + lines.push("copilot_quota_auth:"); + lines.push(`- pat_state: ${copilotDiag.pat.state}`); + if (copilotDiag.pat.selectedPath) { + lines.push(`- pat_path: ${copilotDiag.pat.selectedPath}`); + } + if (copilotDiag.pat.tokenKind) { + lines.push(`- pat_token_kind: ${copilotDiag.pat.tokenKind}`); + } + if (copilotDiag.pat.config?.tier) { + lines.push(`- pat_tier: ${copilotDiag.pat.config.tier}`); + } + if (copilotDiag.pat.config?.organization) { + lines.push(`- pat_organization: ${copilotDiag.pat.config.organization}`); + } + if (copilotDiag.pat.error) { + lines.push(`- pat_error: ${copilotDiag.pat.error}`); + } + lines.push( + `- pat_checked_paths: ${copilotDiag.pat.checkedPaths.length ? copilotDiag.pat.checkedPaths.join(" | ") : "(none)"}`, + ); + lines.push( + `- oauth_configured: ${copilotDiag.oauth.configured ? "true" : "false"} key=${copilotDiag.oauth.keyName ?? "(none)"} refresh=${copilotDiag.oauth.hasRefreshToken ? "true" : "false"} access=${copilotDiag.oauth.hasAccessToken ? "true" : "false"}`, + ); + lines.push(`- effective_source: ${copilotDiag.effectiveSource}`); + lines.push(`- override: ${copilotDiag.override}`); const googleTokenCachePath = getGoogleTokenCachePath(); lines.push( `- google token cache: ${googleTokenCachePath}${(await pathExists(googleTokenCachePath)) ? "" : " (missing)"}`, diff --git a/src/lib/types.ts b/src/lib/types.ts index 4152a70..b20bc16 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -117,17 +117,25 @@ export type CopilotTier = "free" | "pro" | "pro+" | "business" | "enterprise"; * Copilot quota token configuration. * * Stored locally in: - * - $XDG_CONFIG_HOME/opencode/copilot-quota-token.json, or - * - ~/.config/opencode/copilot-quota-token.json + * - OpenCode runtime config candidate directories as + * `.../opencode/copilot-quota-token.json` + * (for example `$XDG_CONFIG_HOME/opencode` or `~/.config/opencode`) * * Users can create a fine-grained PAT with "Plan" read permission * to enable quota checking via GitHub's public billing API. */ export interface CopilotQuotaConfig { - /** Fine-grained PAT with "Plan" read permission */ + /** Fine-grained PAT with GitHub billing-report access */ token: string; - /** GitHub username (optional; used for legacy /users/{username} fallback) */ + /** Optional user login override for user-scoped reports or org user filtering */ username?: string; + /** + * Optional organization slug for organization-scoped premium request reports. + * + * When present, the plugin queries + * `/organizations/{org}/settings/billing/premium_request/usage`. + */ + organization?: string; /** Copilot subscription tier (determines monthly quota limit) */ tier: CopilotTier; } @@ -135,6 +143,9 @@ export interface CopilotQuotaConfig { /** Full auth.json structure (partial - only what we need) */ export interface AuthData { "github-copilot"?: CopilotAuthData; + copilot?: CopilotAuthData; + "copilot-chat"?: CopilotAuthData; + "github-copilot-chat"?: CopilotAuthData; google?: { type: string; access?: string; @@ -234,45 +245,6 @@ export interface GoogleQuotaResponse { >; } -/** Copilot API response includes a quota reset date */ -export interface CopilotQuotaResponseWithReset extends CopilotUsageResponse { - quota_reset_date: string; -} - -// ============================================================================= -// Copilot API Types -// ============================================================================= - -interface QuotaDetail { - entitlement: number; - overage_count: number; - overage_permitted: boolean; - percent_remaining: number; - quota_id: string; - quota_remaining: number; - remaining: number; - unlimited: boolean; -} - -interface QuotaSnapshots { - chat?: QuotaDetail; - completions?: QuotaDetail; - premium_interactions: QuotaDetail; -} - -export interface CopilotUsageResponse { - access_type_sku: string; - analytics_tracking_id: string; - assigned_date: string; - can_signup_for_limited: boolean; - chat_enabled: boolean; - copilot_plan: string; - organization_login_list: unknown[]; - organization_list: unknown[]; - quota_reset_date: string; - quota_snapshots: QuotaSnapshots; -} - // ============================================================================= // Z.ai Types // ============================================================================= @@ -368,10 +340,10 @@ export type GoogleResult = GoogleQuotaResult | QuotaError | null; export type ZaiResult = ZaiQuotaResult | QuotaError | null; export type ChutesResult = | { - success: true; - percentRemaining: number; - resetTimeIso?: string; - } + success: true; + percentRemaining: number; + resetTimeIso?: string; + } | QuotaError | null; diff --git a/src/providers/copilot.ts b/src/providers/copilot.ts index afe0ee6..e68b9ae 100644 --- a/src/providers/copilot.ts +++ b/src/providers/copilot.ts @@ -14,7 +14,7 @@ export const copilotProvider: QuotaProvider = { try { const resp = await ctx.client.config.providers(); const ids = new Set((resp.data?.providers ?? []).map((p) => p.id)); - return ids.has("github-copilot") || ids.has("copilot") || ids.has("copilot-chat"); + return ids.has("github-copilot") || ids.has("copilot") || ids.has("copilot-chat") || ids.has("github-copilot-chat"); } catch { return false; } diff --git a/tests/lib.copilot.test.ts b/tests/lib.copilot.test.ts index d57f23b..885831a 100644 --- a/tests/lib.copilot.test.ts +++ b/tests/lib.copilot.test.ts @@ -1,11 +1,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +const fsMocks = vi.hoisted(() => ({ + existsSync: vi.fn(() => false), + readFileSync: vi.fn(), +})); + +const authMocks = vi.hoisted(() => ({ + readAuthFile: vi.fn(), +})); + vi.mock("fs", async (importOriginal) => { const mod = await importOriginal(); return { ...mod, - // Prevent test environment from accidentally using a real local PAT config. - existsSync: vi.fn(() => false), + existsSync: fsMocks.existsSync, + readFileSync: fsMocks.readFileSync, }; }); @@ -16,85 +25,253 @@ vi.mock("../src/lib/opencode-runtime-paths.js", () => ({ cacheDirs: ["/home/test/.cache/opencode"], stateDirs: ["/home/test/.local/state/opencode"], }), - getOpencodeRuntimeDirs: () => ({ - dataDir: "/home/test/.local/share/opencode", - configDir: "/home/test/.config/opencode", - cacheDir: "/home/test/.cache/opencode", - stateDir: "/home/test/.local/state/opencode", - }), })); vi.mock("../src/lib/opencode-auth.js", () => ({ - readAuthFile: vi.fn(), + readAuthFile: authMocks.readAuthFile, })); +const patPath = "/home/test/.config/opencode/copilot-quota-token.json"; const realEnv = process.env; describe("queryCopilotQuota", () => { beforeEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-15T12:00:00.000Z")); process.env = { ...realEnv }; - vi.resetModules(); + fsMocks.existsSync.mockReset(); + fsMocks.existsSync.mockReturnValue(false); + fsMocks.readFileSync.mockReset(); + authMocks.readAuthFile.mockReset(); + authMocks.readAuthFile.mockResolvedValue({}); + vi.stubGlobal("fetch", vi.fn(async () => new Response("not found", { status: 404 })) as any); }); afterEach(() => { process.env = realEnv; + vi.unstubAllGlobals(); + vi.useRealTimers(); }); - it("returns null when not configured and no PAT config", async () => { + it("returns null when no PAT config and no OpenCode Copilot auth exist", async () => { const { queryCopilotQuota } = await import("../src/lib/copilot.js"); - const { readAuthFile } = await import("../src/lib/opencode-auth.js"); - (readAuthFile as any).mockResolvedValueOnce({}); await expect(queryCopilotQuota()).resolves.toBeNull(); }); - it("uses token exchange when legacy internal call fails", async () => { + it("prefers PAT billing config over OpenCode auth when both exist", async () => { + fsMocks.existsSync.mockImplementation((path) => path === patPath); + fsMocks.readFileSync.mockReturnValue( + JSON.stringify({ + token: "github_pat_123456789", + tier: "pro", + username: "alice", + }), + ); + authMocks.readAuthFile.mockResolvedValueOnce({ + "github-copilot": { type: "oauth", access: "oauth_access_token" }, + }); + + const fetchMock = vi.fn(async (url: unknown) => { + const target = String(url); + + if (target.includes("/users/alice/settings/billing/premium_request/usage")) { + return new Response( + JSON.stringify({ + usageItems: [ + { + sku: "Copilot Premium Request", + grossQuantity: 42, + limit: 300, + }, + ], + }), + { status: 200 }, + ); + } + + return new Response("not found", { status: 404 }); + }); + + vi.stubGlobal("fetch", fetchMock as any); + const { queryCopilotQuota } = await import("../src/lib/copilot.js"); - const { readAuthFile } = await import("../src/lib/opencode-auth.js"); - (readAuthFile as any).mockResolvedValueOnce({ - "github-copilot": { type: "oauth", refresh: "gho_abc" }, + const result = await queryCopilotQuota(); + + expect(result).toEqual({ + success: true, + used: 42, + total: 300, + percentRemaining: 85, + resetTimeIso: "2026-02-01T00:00:00.000Z", + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(String(fetchMock.mock.calls[0]?.[0])).toContain( + "/users/alice/settings/billing/premium_request/usage", + ); + }); + + it("uses OpenCode auth against the documented user billing endpoint when PAT is absent", async () => { + authMocks.readAuthFile.mockResolvedValueOnce({ + "github-copilot-chat": { type: "oauth", access: "oauth_access_token" }, }); - const fetchMock = vi.fn(async (url: any, _opts: any) => { - const s = String(url); + const fetchMock = vi.fn(async (url: unknown) => { + const target = String(url); - if (s.includes("/copilot_internal/user")) { - // first attempt: legacy auth call fails, second attempt: bearer works - const auth = _opts?.headers?.Authorization || _opts?.headers?.authorization; - if (typeof auth === "string" && auth.startsWith("token ")) { - return new Response("forbidden", { status: 403 }); - } + if (target.endsWith("/user")) { + return new Response(JSON.stringify({ login: "octocat" }), { status: 200 }); + } + if (target.includes("/users/octocat/settings/billing/premium_request/usage")) { return new Response( JSON.stringify({ - copilot_plan: "pro", - quota_reset_date: "2026-02-01T00:00:00.000Z", - quota_snapshots: { - premium_interactions: { - entitlement: 300, - remaining: 200, - percent_remaining: 66.7, - unlimited: false, - overage_count: 0, - overage_permitted: false, - quota_id: "x", - quota_remaining: 0, + usageItems: [ + { + sku: "Copilot Premium Request", + grossQuantity: 12, + limit: 300, }, - }, + ], }), { status: 200 }, ); } - if (s.includes("/copilot_internal/v2/token")) { + return new Response("not found", { status: 404 }); + }); + + vi.stubGlobal("fetch", fetchMock as any); + + const { queryCopilotQuota } = await import("../src/lib/copilot.js"); + const result = await queryCopilotQuota(); + + expect(result).toEqual({ + success: true, + used: 12, + total: 300, + percentRemaining: 96, + resetTimeIso: "2026-02-01T00:00:00.000Z", + }); + expect(fetchMock.mock.calls.map((call) => String(call[0]))).toEqual([ + "https://api.github.com/user", + "https://api.github.com/users/octocat/settings/billing/premium_request/usage", + ]); + }); + + it("does not fall back to OpenCode auth when PAT config is invalid", async () => { + fsMocks.existsSync.mockImplementation((path) => path === patPath); + fsMocks.readFileSync.mockReturnValue(JSON.stringify({ token: "github_pat_123456789" })); + authMocks.readAuthFile.mockResolvedValueOnce({ + "github-copilot": { type: "oauth", access: "oauth_access_token" }, + }); + + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock as any); + + const { queryCopilotQuota } = await import("../src/lib/copilot.js"); + const result = await queryCopilotQuota(); + + expect(result && !result.success ? result.error : "").toContain( + "Invalid copilot-quota-token.json", + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("errors when business tier config omits organization", async () => { + fsMocks.existsSync.mockImplementation((path) => path === patPath); + fsMocks.readFileSync.mockReturnValue( + JSON.stringify({ + token: "github_pat_123456789", + tier: "business", + }), + ); + + const { queryCopilotQuota } = await import("../src/lib/copilot.js"); + const result = await queryCopilotQuota(); + + expect(result && !result.success ? result.error : "").toContain( + 'Add "organization": "your-org-slug"', + ); + }); + + it("uses the documented organization billing endpoint when organization is configured", async () => { + fsMocks.existsSync.mockImplementation((path) => path === patPath); + fsMocks.readFileSync.mockReturnValue( + JSON.stringify({ + token: "github_pat_123456789", + tier: "business", + organization: "acme-corp", + username: "alice", + }), + ); + + const fetchMock = vi.fn(async (url: unknown) => { + const target = String(url); + + if ( + target === + "https://api.github.com/organizations/acme-corp/settings/billing/premium_request/usage?user=alice" + ) { + return new Response( + JSON.stringify({ + organization: "acme-corp", + usageItems: [ + { + sku: "Copilot Premium Request", + grossQuantity: 9, + }, + ], + }), + { status: 200 }, + ); + } + + return new Response("not found", { status: 404 }); + }); + + vi.stubGlobal("fetch", fetchMock as any); + + const { queryCopilotQuota } = await import("../src/lib/copilot.js"); + const result = await queryCopilotQuota(); + + expect(result).toEqual({ + success: true, + used: 9, + total: 300, + percentRemaining: 97, + resetTimeIso: "2026-02-01T00:00:00.000Z", + }); + expect(String(fetchMock.mock.calls[0]?.[0])).toContain( + "/organizations/acme-corp/settings/billing/premium_request/usage?user=alice", + ); + }); + + it("handles snake_case billing response fields", async () => { + fsMocks.existsSync.mockImplementation((path) => path === patPath); + fsMocks.readFileSync.mockReturnValue( + JSON.stringify({ + token: "github_pat_123456789", + tier: "pro", + username: "alice", + }), + ); + + const fetchMock = vi.fn(async (url: unknown) => { + const target = String(url); + + if (target.includes("/users/alice/settings/billing/premium_request/usage")) { return new Response( JSON.stringify({ - token: "cpt_sess", - expires_at: Date.now() + 60_000, - refresh_in: 30_000, - endpoints: { api: "https://api.github.com" }, + usage_items: [ + { + sku: "Copilot Premium Request", + gross_quantity: 9, + limit: 300, + }, + ], }), { status: 200 }, ); @@ -105,8 +282,77 @@ describe("queryCopilotQuota", () => { vi.stubGlobal("fetch", fetchMock as any); - const out = await queryCopilotQuota(); - expect(out && out.success ? out.total : -1).toBe(300); - expect(out && out.success ? out.used : -1).toBe(100); + const { queryCopilotQuota } = await import("../src/lib/copilot.js"); + const result = await queryCopilotQuota(); + + expect(result).toEqual({ + success: true, + used: 9, + total: 300, + percentRemaining: 97, + resetTimeIso: "2026-02-01T00:00:00.000Z", + }); + }); + + it("errors when billing response contains no premium request SKU", async () => { + fsMocks.existsSync.mockImplementation((path) => path === patPath); + fsMocks.readFileSync.mockReturnValue( + JSON.stringify({ + token: "github_pat_123456789", + tier: "pro", + username: "alice", + }), + ); + + const fetchMock = vi.fn(async (url: unknown) => { + const target = String(url); + + if (target.includes("/users/alice/settings/billing/premium_request/usage")) { + return new Response( + JSON.stringify({ + usageItems: [ + { + sku: "Some Other SKU", + grossQuantity: 5, + }, + ], + }), + { status: 200 }, + ); + } + + return new Response("not found", { status: 404 }); + }); + + vi.stubGlobal("fetch", fetchMock as any); + + const { queryCopilotQuota } = await import("../src/lib/copilot.js"); + const result = await queryCopilotQuota(); + + expect(result && !result.success ? result.error : "").toContain( + "No premium-request items found", + ); + }); + + it("surfaces PAT precedence and organization details in diagnostics", async () => { + fsMocks.existsSync.mockImplementation((path) => path === patPath); + fsMocks.readFileSync.mockReturnValue( + JSON.stringify({ + token: "github_pat_123456789", + tier: "business", + organization: "acme-corp", + }), + ); + + const { getCopilotQuotaAuthDiagnostics } = await import("../src/lib/copilot.js"); + const diagnostics = getCopilotQuotaAuthDiagnostics({ + "github-copilot": { type: "oauth", access: "oauth_access_token" }, + }); + + expect(diagnostics.pat.state).toBe("valid"); + expect(diagnostics.pat.config?.organization).toBe("acme-corp"); + expect(diagnostics.oauth.configured).toBe(true); + expect(diagnostics.effectiveSource).toBe("pat"); + expect(diagnostics.override).toBe("pat_overrides_oauth"); }); });