From ba48c7efbe3b93a7d9d38edab4048f74b4f7ed46 Mon Sep 17 00:00:00 2001 From: "Shawn L. Kiser" <35721408+slkiser@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:56:05 +0100 Subject: [PATCH 1/2] fix(copilot): correct premium request usage parsing and diagnostics --- README.md | 40 +- src/lib/copilot.ts | 927 +++++++++++++++++++++++++++----------- src/lib/quota-status.ts | 36 ++ src/lib/types.ts | 66 +-- src/providers/copilot.ts | 2 +- tests/lib.copilot.test.ts | 516 +++++++++++++++++++-- 6 files changed, 1234 insertions(+), 353 deletions(-) diff --git a/README.md b/README.md index 11e01e0..72233e9 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,47 @@ 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` +<<<<<<< Updated upstream +PAT scope guidance (read-only first): + +- Personal/user-billed Copilot usage: fine-grained PAT with **Account permissions > Plan > Read**. +- Organization-managed Copilot usage metrics: classic token with **`read:org`** (or fine-grained org permission **Organization Copilot metrics: read** when using org metrics endpoints). +- Enterprise-managed Copilot usage metrics: classic token with **`read:enterprise`**. + +GitHub notes that user-level billing endpoints may not include usage for org/enterprise-managed seats; use org/enterprise metrics endpoints in that case. + +When both Copilot OAuth auth and `copilot-quota-token.json` are present, the plugin prefers the PAT billing path for quota metrics. +Run `/quota_status` and check `copilot_quota_auth` to confirm `pat_state`, candidate paths checked, and `effective_source`/`override`. + +======= +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`. +>>>>>>> Stashed changes
@@ -386,7 +407,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..404fda6 100644 --- a/src/lib/copilot.ts +++ b/src/lib/copilot.ts @@ -1,28 +1,29 @@ /** - * 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"; +<<<<<<< Updated upstream import { existsSync, readFileSync } from "fs"; -import { homedir } from "os"; import { join } from "path"; // ============================================================================= @@ -39,11 +40,7 @@ const EDITOR_VERSION = "vscode/1.107.0"; const EDITOR_PLUGIN_VERSION = `copilot-chat/${COPILOT_VERSION}`; const USER_AGENT = `GitHubCopilotChat/${COPILOT_VERSION}`; -const COPILOT_QUOTA_CONFIG_PATH = join( - process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), - "opencode", - "copilot-quota-token.json", -); +const COPILOT_QUOTA_CONFIG_FILENAME = "copilot-quota-token.json"; // ============================================================================= // Helpers @@ -74,8 +71,295 @@ function buildLegacyTokenHeaders(token: string): Record { ...COPILOT_HEADERS, }; } +======= + +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"; +>>>>>>> Stashed changes 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; +} + +export interface CopilotQuotaAuthDiagnostics { + pat: CopilotPatReadResult; + oauth: { + configured: boolean; + keyName: CopilotAuthKeyName | null; + hasRefreshToken: boolean; + hasAccessToken: boolean; + }; + effectiveSource: EffectiveCopilotAuthSource; + override: "pat_overrides_oauth" | "none"; +} + +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; +} + +interface BillingUsageResponse { + timePeriod?: { year: number; month?: number }; + time_period?: { year: number; month?: number }; + user?: string; + organization?: string; + usageItems?: BillingUsageItem[]; + usage_items?: BillingUsageItem[]; +} + +interface GitHubViewerResponse { + login?: string; +} + +const COPILOT_PLAN_LIMITS: Record = { + free: 50, + pro: 300, + "pro+": 1500, + business: 300, + enterprise: 1000, +}; + +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 { + config: { + token, + tier: tier as CopilotTier, + username, + organization, + }, + }; +} + +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 { + 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 CopilotAuthKeyName = "github-copilot" | "copilot" | "copilot-chat"; + +type CopilotPatTokenKind = "github_pat" | "ghp" | "other"; + +export type CopilotPatState = "absent" | "invalid" | "valid"; + +export interface CopilotPatReadResult { + state: CopilotPatState; + checkedPaths: string[]; + selectedPath?: string; + config?: CopilotQuotaConfig; + error?: string; + tokenKind?: CopilotPatTokenKind; +} + +export interface CopilotQuotaAuthDiagnostics { + pat: CopilotPatReadResult; + oauth: { + configured: boolean; + keyName: CopilotAuthKeyName | null; + hasRefreshToken: boolean; + hasAccessToken: boolean; + }; + effectiveSource: "pat" | "oauth" | "none"; + override: "pat_overrides_oauth" | "none"; +} + +function classifyPatTokenKind(token: string): CopilotPatTokenKind { + const trimmed = token.trim(); + if (trimmed.startsWith("github_pat_")) return "github_pat"; + if (trimmed.startsWith("ghp_")) return "ghp"; + return "other"; +} + +function dedupePaths(paths: string[]): string[] { + const out: string[] = []; + const seen = new Set(); + + for (const path of paths) { + if (!path) continue; + if (seen.has(path)) continue; + seen.add(path); + out.push(path); + } + + return out; +} + +export function getCopilotPatConfigCandidatePaths(): string[] { + const candidates = getOpencodeRuntimeDirCandidates(); + return dedupePaths( + candidates.configDirs.map((configDir) => join(configDir, COPILOT_QUOTA_CONFIG_FILENAME)), + ); +} function buildGitHubRestHeaders( token: string, @@ -84,21 +368,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,29 +385,117 @@ 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); } +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 tierRaw = typeof obj.tier === "string" ? obj.tier.trim() : ""; + const usernameRaw = obj.username; + + if (!token) { + return { config: null, error: "Missing required string field: token" }; + } + + const validTiers: CopilotTier[] = ["free", "pro", "pro+", "business", "enterprise"]; + if (!validTiers.includes(tierRaw as CopilotTier)) { + return { + config: null, + error: "Invalid tier; expected one of: free, pro, pro+, business, enterprise", + }; + } + + let username: string | undefined; + if (usernameRaw != null) { + if (typeof usernameRaw !== "string") { + return { config: null, error: "username must be a non-empty string when provided" }; + } + const trimmed = usernameRaw.trim(); + if (!trimmed) { + return { config: null, error: "username must be a non-empty string when provided" }; + } + username = trimmed; + } + + return { + config: { + token, + tier: tierRaw as CopilotTier, + username, + }, + }; +} + +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 (err) { + const msg = err instanceof Error ? err.message : String(err); + return { + state: "invalid", + checkedPaths, + selectedPath: path, + error: msg, + }; + } + } + + return { + state: "absent", + checkedPaths, + }; +} + 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,6 +511,7 @@ async function fetchGitHubRestJsonOnce( }; } +<<<<<<< Updated upstream /** * Read Copilot auth data from auth.json * @@ -154,289 +519,294 @@ async function fetchGitHubRestJsonOnce( */ async function readCopilotAuth(): Promise { const authData = await readAuthFile(); - if (!authData) return null; + return selectCopilotAuth(authData).auth; +} - // Try known key names in priority order - const copilotAuth = - authData["github-copilot"] ?? - (authData as Record)["copilot"] ?? - (authData as Record)["copilot-chat"]; +/** + * Select Copilot OAuth auth entry from auth.json-shaped data. + */ +function selectCopilotAuth( + authData: AuthData | null, +): { auth: CopilotAuthData | null; keyName: CopilotAuthKeyName | null } { + if (!authData) { + return { auth: null, keyName: null }; + } - if (!copilotAuth || copilotAuth.type !== "oauth" || !copilotAuth.refresh) { - return null; + const candidates: Array<[CopilotAuthKeyName, CopilotAuthData | undefined]> = [ + ["github-copilot", authData["github-copilot"]], + ["copilot", (authData as Record).copilot], + ["copilot-chat", (authData as Record)["copilot-chat"]], + ]; + + for (const [keyName, candidate] of candidates) { + if (!candidate) continue; + if (candidate.type !== "oauth") continue; + if (!candidate.refresh) continue; + return { auth: candidate, keyName }; } - return copilotAuth; + return { auth: null, keyName: null }; } -/** - * 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; - } +export function getCopilotQuotaAuthDiagnostics(authData: AuthData | null): CopilotQuotaAuthDiagnostics { + const pat = readQuotaConfigWithMeta(); + const { auth, keyName } = selectCopilotAuth(authData); + const oauthConfigured = Boolean(auth); - const content = readFileSync(COPILOT_QUOTA_CONFIG_PATH, "utf-8"); - const parsed = JSON.parse(content) as CopilotQuotaConfig; + let effectiveSource: "pat" | "oauth" | "none" = "none"; + if (pat.state === "valid") { + effectiveSource = "pat"; + } else if (oauthConfigured) { + effectiveSource = "oauth"; + } - if (!parsed || typeof parsed !== "object") return null; + return { + pat, + oauth: { + configured: oauthConfigured, + keyName, + hasRefreshToken: Boolean(auth?.refresh), + hasAccessToken: Boolean(auth?.access), + }, + effectiveSource, + override: pat.state === "valid" && oauthConfigured ? "pat_overrides_oauth" : "none", + }; +} - if (typeof parsed.token !== "string" || parsed.token.trim() === "") return null; - if (typeof parsed.tier !== "string" || parsed.tier.trim() === "") return null; +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; +} +======= +async function resolveGitHubUsername(token: string): Promise { + const url = `${GITHUB_API_BASE_URL}/user`; + let unauthorized: { status: number; message: string } | null = null; + + for (const scheme of preferredSchemesForToken(token)) { + const result = await fetchGitHubRestJsonOnce(url, token, scheme); + + if (result.ok) { + const login = result.data.login?.trim(); + if (login) return login; + throw new Error("GitHub /user response did not include a login"); + } - // 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}`); + } +>>>>>>> Stashed changes - 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".`, + ); + } - const used = premiumItems.reduce((sum, item) => sum + (item.grossQuantity || 0), 0); +<<<<<<< Updated upstream + const normalizedUsed = Math.max(0, used); + const percentRemaining = computePercentRemainingFromUsed({ used: normalizedUsed, total }); +======= + if (premiumItems.length === 0) { + throw new Error("Billing API returned empty usageItems array for Copilot premium requests."); + } + + 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))); +>>>>>>> Stashed changes return { success: true, - used, + used: normalizedUsed, 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 { +<<<<<<< Updated upstream // Strategy 1: Try public billing API with user's fine-grained PAT. - const quotaConfig = readQuotaConfig(); - if (quotaConfig) { + const quotaConfigRead = readQuotaConfigWithMeta(); + if (quotaConfigRead.state === "valid" && quotaConfigRead.config) { try { - const billing = await fetchPublicBillingUsage(quotaConfig); - return toQuotaResultFromBilling(billing, quotaConfig.tier); + const billing = await fetchPublicBillingUsage(quotaConfigRead.config); + return toQuotaResultFromBilling(billing, quotaConfigRead.config.tier); } catch (err) { return { success: false, error: err instanceof Error ? err.message : String(err), } as QuotaError; +======= + 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 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)); +>>>>>>> Stashed changes } } - // 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; } +<<<<<<< Updated upstream try { const data = await fetchCopilotUsage(auth); const premium = data.quota_snapshots.premium_interactions; @@ -459,8 +829,30 @@ export async function queryCopilotQuota(): Promise { } const total = premium.entitlement; - const used = total - premium.remaining; - const percentRemaining = Math.round(premium.percent_remaining); + if (!Number.isFinite(total) || total <= 0) { + return { + success: false, + error: "Invalid premium quota entitlement", + } as QuotaError; + } + + const remainingRaw = + typeof premium.remaining === "number" + ? premium.remaining + : typeof premium.quota_remaining === "number" + ? premium.quota_remaining + : NaN; + + if (!Number.isFinite(remainingRaw)) { + return { + success: false, + error: "Invalid premium quota remaining value", + } as QuotaError; + } + + const remaining = Math.max(0, Math.min(total, remainingRaw)); + const used = Math.max(0, total - remaining); + const percentRemaining = computePercentRemainingFromUsed({ used, total }); return { success: true, @@ -474,26 +866,33 @@ export async function queryCopilotQuota(): Promise { success: false, error: err instanceof Error ? err.message : String(err), } as QuotaError; - } -} - -/** - * 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) { +======= + const tokenCandidates = getOAuthTokenCandidates(auth); + if (tokenCandidates.length === 0) { return null; +>>>>>>> Stashed changes } - if (!result.success) { - return null; + let lastError: string | null = null; + + for (const token of tokenCandidates) { + try { + const response = await fetchPremiumRequestUsage({ token }); + return toQuotaResultFromBilling(response); + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } } - if (result.total === -1) { - return "Copilot Unlimited"; + 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.", + ); +} + +export function formatCopilotQuota(result: CopilotResult): string | null { + if (!result || !result.success) { + return null; } const percentUsed = 100 - result.percentRemaining; diff --git a/src/lib/quota-status.ts b/src/lib/quota-status.ts index a25e980..43d4a58 100644 --- a/src/lib/quota-status.ts +++ b/src/lib/quota-status.ts @@ -6,12 +6,14 @@ 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, readQwenLocalQuotaState, } from "./qwen-local-quota.js"; import { hasQwenOAuthAuth } from "./qwen-auth.js"; +import { getCopilotQuotaAuthDiagnostics } from "./copilot.js"; import { getPricingSnapshotHealth, getPricingRefreshPolicy, @@ -334,6 +336,40 @@ 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}`); + } +<<<<<<< Updated upstream +======= + if (copilotDiag.pat.config?.organization) { + lines.push(`- pat_organization: ${copilotDiag.pat.config.organization}`); + } +>>>>>>> Stashed changes + 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}`); +<<<<<<< Updated upstream + +======= +>>>>>>> Stashed changes 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..6ae07a3 100644 --- a/tests/lib.copilot.test.ts +++ b/tests/lib.copilot.test.ts @@ -1,72 +1,205 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +const fsMocks = vi.hoisted(() => ({ +<<<<<<< Updated upstream + existsSync: vi.fn<(path: string) => boolean>(() => false), + readFileSync: vi.fn<(path: string, encoding: BufferEncoding) => string>(() => ""), +})); + +const runtimeMocks = vi.hoisted(() => ({ + getOpencodeRuntimeDirCandidates: vi.fn(() => ({ + dataDirs: ["/home/test/.local/share/opencode"], + configDirs: [ + "/home/test/.config/opencode", + "/home/test/Library/Application Support/opencode", + ], + cacheDirs: ["/home/test/.cache/opencode"], + stateDirs: ["/home/test/.local/state/opencode"], + })), + getOpencodeRuntimeDirs: vi.fn(() => ({ + dataDir: "/home/test/.local/share/opencode", + configDir: "/home/test/.config/opencode", + cacheDir: "/home/test/.cache/opencode", + stateDir: "/home/test/.local/state/opencode", + })), +======= + existsSync: vi.fn(() => false), + readFileSync: vi.fn(), +>>>>>>> Stashed changes +})); + +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, }; }); vi.mock("../src/lib/opencode-runtime-paths.js", () => ({ +<<<<<<< Updated upstream + getOpencodeRuntimeDirCandidates: runtimeMocks.getOpencodeRuntimeDirCandidates, + getOpencodeRuntimeDirs: runtimeMocks.getOpencodeRuntimeDirs, +======= getOpencodeRuntimeDirCandidates: () => ({ dataDirs: ["/home/test/.local/share/opencode"], configDirs: ["/home/test/.config/opencode"], 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", - }), +>>>>>>> Stashed changes })); 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; +const patPath = "/home/test/.config/opencode/copilot-quota-token.json"; describe("queryCopilotQuota", () => { beforeEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-15T12:00:00.000Z")); process.env = { ...realEnv }; +<<<<<<< Updated upstream vi.resetModules(); + + fsMocks.existsSync.mockReset(); + fsMocks.readFileSync.mockReset(); + authMocks.readAuthFile.mockReset(); + + fsMocks.existsSync.mockReturnValue(false); + fsMocks.readFileSync.mockReturnValue(""); + authMocks.readAuthFile.mockResolvedValue({}); +======= + 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); +>>>>>>> Stashed changes }); 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({}); +<<<<<<< Updated upstream + authMocks.readAuthFile.mockResolvedValueOnce({}); +======= +>>>>>>> Stashed changes await expect(queryCopilotQuota()).resolves.toBeNull(); }); - it("uses token exchange when legacy internal call fails", async () => { +<<<<<<< Updated upstream + it("uses PAT billing API when PAT config exists and overrides OAuth auth", async () => { 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" }, + +======= + it("prefers PAT billing config over OpenCode auth when both exist", async () => { +>>>>>>> Stashed changes + fsMocks.existsSync.mockImplementation((path) => path === patPath); + fsMocks.readFileSync.mockReturnValue( + JSON.stringify({ + token: "github_pat_123456789", + tier: "pro", +<<<<<<< Updated upstream + }), + ); + + authMocks.readAuthFile.mockResolvedValueOnce({ + "github-copilot": { type: "oauth", refresh: "gho_oauth_token" }, }); - const fetchMock = vi.fn(async (url: any, _opts: any) => { + const fetchMock = vi.fn(async (url: unknown, opts: RequestInit | undefined) => { const s = 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 (s.includes("/user/settings/billing/premium_request/usage")) { + expect((opts?.headers as Record | undefined)?.Authorization).toBe( + "Bearer github_pat_123456789", + ); + + return new Response( + JSON.stringify({ + timePeriod: { year: 2026, month: 1 }, + user: "halfwalker", + usageItems: [ + { + product: "copilot", + sku: "Copilot Premium Request", + unitType: "count", + grossQuantity: 1, + netQuantity: 1, +======= + 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, +>>>>>>> Stashed changes + limit: 300, + }, + ], + }), + { status: 200 }, + ); + } + + return new Response("not found", { status: 404 }); + }); + + vi.stubGlobal("fetch", fetchMock as any); + +<<<<<<< Updated upstream + const out = await queryCopilotQuota(); + expect(out && out.success ? out.total : -1).toBe(300); + expect(out && out.success ? out.used : -1).toBe(1); + expect(out && out.success ? out.percentRemaining : -1).toBe(99); + expect(authMocks.readAuthFile).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("falls back to OAuth/internal flow when PAT config is invalid", async () => { + const { getCopilotQuotaAuthDiagnostics, queryCopilotQuota } = await import("../src/lib/copilot.js"); + + fsMocks.existsSync.mockImplementation((path) => path === patPath); + fsMocks.readFileSync.mockReturnValue("{bad-json"); + + const oauthAuth = { + "github-copilot": { type: "oauth", refresh: "gho_abc" }, + }; + authMocks.readAuthFile.mockResolvedValueOnce(oauthAuth); + + const fetchMock = vi.fn(async (url: unknown) => { + const s = String(url); + if (s.includes("/copilot_internal/user")) { return new Response( JSON.stringify({ copilot_plan: "pro", @@ -74,8 +207,8 @@ describe("queryCopilotQuota", () => { quota_snapshots: { premium_interactions: { entitlement: 300, - remaining: 200, - percent_remaining: 66.7, + remaining: 299, + percent_remaining: 100, unlimited: false, overage_count: 0, overage_permitted: false, @@ -83,18 +216,152 @@ describe("queryCopilotQuota", () => { quota_remaining: 0, }, }, +======= + const { queryCopilotQuota } = await import("../src/lib/copilot.js"); + 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: unknown) => { + const target = String(url); + + 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({ + usageItems: [ + { + sku: "Copilot Premium Request", + grossQuantity: 12, + limit: 300, + }, + ], +>>>>>>> Stashed changes }), { status: 200 }, ); } - if (s.includes("/copilot_internal/v2/token")) { + return new Response("not found", { status: 404 }); + }); + + vi.stubGlobal("fetch", fetchMock as any); + +<<<<<<< Updated upstream + const out = await queryCopilotQuota(); + expect(out && out.success ? out.total : -1).toBe(300); + expect(out && out.success ? out.used : -1).toBe(1); + expect(out && out.success ? out.percentRemaining : -1).toBe(99); + + const diag = getCopilotQuotaAuthDiagnostics(oauthAuth as any); + expect(diag.pat.state).toBe("invalid"); + expect(diag.pat.selectedPath).toBe(patPath); + expect(diag.effectiveSource).toBe("oauth"); + expect(diag.override).toBe("none"); + }); + + it("returns PAT error and does not fall back to OAuth when PAT is rejected", async () => { + const { queryCopilotQuota } = await import("../src/lib/copilot.js"); + +======= + 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({ - token: "cpt_sess", - expires_at: Date.now() + 60_000, - refresh_in: 30_000, - endpoints: { api: "https://api.github.com" }, + organization: "acme-corp", + usageItems: [ + { + sku: "Copilot Premium Request", + grossQuantity: 9, + }, + ], }), { status: 200 }, ); @@ -105,8 +372,193 @@ describe("queryCopilotQuota", () => { 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 () => { +>>>>>>> Stashed changes + fsMocks.existsSync.mockImplementation((path) => path === patPath); + fsMocks.readFileSync.mockReturnValue( + JSON.stringify({ + token: "github_pat_123456789", + tier: "pro", +<<<<<<< Updated upstream + }), + ); + + authMocks.readAuthFile.mockResolvedValueOnce({ + "github-copilot": { type: "oauth", refresh: "gho_should_not_be_used" }, + }); + + const fetchMock = vi.fn(async (url: unknown) => { + const s = String(url); + + if (s.includes("/user/settings/billing/premium_request/usage")) { + return new Response(JSON.stringify({ message: "Forbidden" }), { status: 403 }); + } + + if (s.includes("/copilot_internal/user")) { + return new Response("unexpected oauth fallback", { status: 200 }); + } + + return new Response("not found", { status: 404 }); + }); + + 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); + expect(out && !out.success ? out.error : "").toContain("GitHub API error 403"); + expect(fetchMock.mock.calls.some(([url]) => String(url).includes("/copilot_internal/user"))).toBe( + false, + ); + expect(authMocks.readAuthFile).not.toHaveBeenCalled(); + }); + + it("computes remaining percentage from entitlement/remaining when OAuth response percent is stale", async () => { + const { queryCopilotQuota } = await import("../src/lib/copilot.js"); + + authMocks.readAuthFile.mockResolvedValueOnce({ + "github-copilot": { type: "oauth", refresh: "gho_abc" }, + }); + + const fetchMock = vi.fn(async (url: unknown) => { + const s = String(url); + + if (s.includes("/copilot_internal/user")) { + return new Response( + JSON.stringify({ + copilot_plan: "pro", + quota_reset_date: "2026-02-01T00:00:00.000Z", + quota_snapshots: { + premium_interactions: { + entitlement: 300, + remaining: 299, + percent_remaining: 100, + unlimited: false, + overage_count: 0, + overage_permitted: false, + quota_id: "x", + quota_remaining: 299, + }, + }, +======= + 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({ + usage_items: [ + { + sku: "Copilot Premium Request", + gross_quantity: 9, + limit: 300, + }, + ], +>>>>>>> Stashed changes + }), + { status: 200 }, + ); + } + + return new Response("not found", { status: 404 }); + }); + + vi.stubGlobal("fetch", fetchMock as any); + +<<<<<<< Updated upstream + const out = await queryCopilotQuota(); + expect(out && out.success ? out.used : -1).toBe(1); + expect(out && out.success ? out.percentRemaining : -1).toBe(99); +======= + 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"); +>>>>>>> Stashed changes }); }); From 976af5a280b72aedf3594c40546274a4b6a42284 Mon Sep 17 00:00:00 2001 From: "Shawn L. Kiser" <35721408+slkiser@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:17:51 +0100 Subject: [PATCH 2/2] Fix copied merge conflict markers in Copilot quota files --- README.md | 14 -- src/lib/copilot.ts | 348 +------------------------------------- src/lib/quota-status.ts | 8 - tests/lib.copilot.test.ts | 206 ---------------------- 4 files changed, 1 insertion(+), 575 deletions(-) diff --git a/README.md b/README.md index 72233e9..562e5c1 100644 --- a/README.md +++ b/README.md @@ -138,25 +138,11 @@ For organization-managed Copilot plans such as `business` or `enterprise`, `orga Tier options: `free`, `pro`, `pro+`, `business`, `enterprise` -<<<<<<< Updated upstream -PAT scope guidance (read-only first): - -- Personal/user-billed Copilot usage: fine-grained PAT with **Account permissions > Plan > Read**. -- Organization-managed Copilot usage metrics: classic token with **`read:org`** (or fine-grained org permission **Organization Copilot metrics: read** when using org metrics endpoints). -- Enterprise-managed Copilot usage metrics: classic token with **`read:enterprise`**. - -GitHub notes that user-level billing endpoints may not include usage for org/enterprise-managed seats; use org/enterprise metrics endpoints in that case. - -When both Copilot OAuth auth and `copilot-quota-token.json` are present, the plugin prefers the PAT billing path for quota metrics. -Run `/quota_status` and check `copilot_quota_auth` to confirm `pat_state`, candidate paths checked, and `effective_source`/`override`. - -======= 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`. ->>>>>>> Stashed changes
diff --git a/src/lib/copilot.ts b/src/lib/copilot.ts index 404fda6..a2cefae 100644 --- a/src/lib/copilot.ts +++ b/src/lib/copilot.ts @@ -21,63 +21,11 @@ import type { import { fetchWithTimeout } from "./http.js"; import { readAuthFile } from "./opencode-auth.js"; import { getOpencodeRuntimeDirCandidates } from "./opencode-runtime-paths.js"; -<<<<<<< Updated upstream - -import { existsSync, readFileSync } from "fs"; -import { join } from "path"; - -// ============================================================================= -// Constants -// ============================================================================= - -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`; - -// 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}`; - -const COPILOT_QUOTA_CONFIG_FILENAME = "copilot-quota-token.json"; - -// ============================================================================= -// Helpers -// ============================================================================= - -/** - * 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", -}; - -function buildBearerHeaders(token: string): Record { - return { - Accept: "application/json", - Authorization: `Bearer ${token}`, - ...COPILOT_HEADERS, - }; -} - -function buildLegacyTokenHeaders(token: string): Record { - return { - Accept: "application/json", - Authorization: `token ${token}`, - ...COPILOT_HEADERS, - }; -} -======= 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"; ->>>>>>> Stashed changes type GitHubRestAuthScheme = "bearer" | "token"; type CopilotAuthKeyName = @@ -306,61 +254,6 @@ export function getCopilotQuotaAuthDiagnostics(authData: AuthData | null): Copil }; } -type CopilotAuthKeyName = "github-copilot" | "copilot" | "copilot-chat"; - -type CopilotPatTokenKind = "github_pat" | "ghp" | "other"; - -export type CopilotPatState = "absent" | "invalid" | "valid"; - -export interface CopilotPatReadResult { - state: CopilotPatState; - checkedPaths: string[]; - selectedPath?: string; - config?: CopilotQuotaConfig; - error?: string; - tokenKind?: CopilotPatTokenKind; -} - -export interface CopilotQuotaAuthDiagnostics { - pat: CopilotPatReadResult; - oauth: { - configured: boolean; - keyName: CopilotAuthKeyName | null; - hasRefreshToken: boolean; - hasAccessToken: boolean; - }; - effectiveSource: "pat" | "oauth" | "none"; - override: "pat_overrides_oauth" | "none"; -} - -function classifyPatTokenKind(token: string): CopilotPatTokenKind { - const trimmed = token.trim(); - if (trimmed.startsWith("github_pat_")) return "github_pat"; - if (trimmed.startsWith("ghp_")) return "ghp"; - return "other"; -} - -function dedupePaths(paths: string[]): string[] { - const out: string[] = []; - const seen = new Set(); - - for (const path of paths) { - if (!path) continue; - if (seen.has(path)) continue; - seen.add(path); - out.push(path); - } - - return out; -} - -export function getCopilotPatConfigCandidatePaths(): string[] { - const candidates = getOpencodeRuntimeDirCandidates(); - return dedupePaths( - candidates.configDirs.map((configDir) => join(configDir, COPILOT_QUOTA_CONFIG_FILENAME)), - ); -} - function buildGitHubRestHeaders( token: string, scheme: GitHubRestAuthScheme, @@ -404,93 +297,6 @@ async function readGitHubRestErrorMessage(response: Response): Promise { return text.slice(0, 160); } -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 tierRaw = typeof obj.tier === "string" ? obj.tier.trim() : ""; - const usernameRaw = obj.username; - - if (!token) { - return { config: null, error: "Missing required string field: token" }; - } - - const validTiers: CopilotTier[] = ["free", "pro", "pro+", "business", "enterprise"]; - if (!validTiers.includes(tierRaw as CopilotTier)) { - return { - config: null, - error: "Invalid tier; expected one of: free, pro, pro+, business, enterprise", - }; - } - - let username: string | undefined; - if (usernameRaw != null) { - if (typeof usernameRaw !== "string") { - return { config: null, error: "username must be a non-empty string when provided" }; - } - const trimmed = usernameRaw.trim(); - if (!trimmed) { - return { config: null, error: "username must be a non-empty string when provided" }; - } - username = trimmed; - } - - return { - config: { - token, - tier: tierRaw as CopilotTier, - username, - }, - }; -} - -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 (err) { - const msg = err instanceof Error ? err.message : String(err); - return { - state: "invalid", - checkedPaths, - selectedPath: path, - error: msg, - }; - } - } - - return { - state: "absent", - checkedPaths, - }; -} - async function fetchGitHubRestJsonOnce( url: string, token: string, @@ -511,76 +317,6 @@ async function fetchGitHubRestJsonOnce( }; } -<<<<<<< Updated upstream -/** - * 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(); - return selectCopilotAuth(authData).auth; -} - -/** - * Select Copilot OAuth auth entry from auth.json-shaped data. - */ -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 as Record).copilot], - ["copilot-chat", (authData as Record)["copilot-chat"]], - ]; - - for (const [keyName, candidate] of candidates) { - if (!candidate) continue; - if (candidate.type !== "oauth") continue; - if (!candidate.refresh) continue; - return { auth: candidate, keyName }; - } - - return { auth: null, keyName: null }; -} - -export function getCopilotQuotaAuthDiagnostics(authData: AuthData | null): CopilotQuotaAuthDiagnostics { - const pat = readQuotaConfigWithMeta(); - const { auth, keyName } = selectCopilotAuth(authData); - const oauthConfigured = Boolean(auth); - - let effectiveSource: "pat" | "oauth" | "none" = "none"; - if (pat.state === "valid") { - effectiveSource = "pat"; - } else if (oauthConfigured) { - effectiveSource = "oauth"; - } - - return { - pat, - oauth: { - configured: oauthConfigured, - keyName, - hasRefreshToken: Boolean(auth?.refresh), - hasAccessToken: Boolean(auth?.access), - }, - effectiveSource, - override: pat.state === "valid" && oauthConfigured ? "pat_overrides_oauth" : "none", - }; -} - -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; -} -======= async function resolveGitHubUsername(token: string): Promise { const url = `${GITHUB_API_BASE_URL}/user`; let unauthorized: { status: number; message: string } | null = null; @@ -601,7 +337,6 @@ async function resolveGitHubUsername(token: string): Promise { throw new Error(`GitHub API error ${result.status}: ${result.message}`); } ->>>>>>> Stashed changes if (unauthorized) { throw new Error( @@ -701,10 +436,6 @@ function toQuotaResultFromBilling( ); } -<<<<<<< Updated upstream - const normalizedUsed = Math.max(0, used); - const percentRemaining = computePercentRemainingFromUsed({ used: normalizedUsed, total }); -======= if (premiumItems.length === 0) { throw new Error("Billing API returned empty usageItems array for Copilot premium requests."); } @@ -725,11 +456,10 @@ function toQuotaResultFromBilling( "Copilot billing response did not include a limit. Configure copilot-quota-token.json with your tier so the plugin can compute quota totals.", ); } ->>>>>>> Stashed changes return { success: true, - used: normalizedUsed, + used, total, percentRemaining: computePercentRemainingFromUsed({ used, total }), resetTimeIso: getApproxNextResetIso(), @@ -762,19 +492,6 @@ function validatePatBillingScope(config: CopilotQuotaConfig): string | null { * PAT configuration wins over OpenCode OAuth auth when both are present. */ export async function queryCopilotQuota(): Promise { -<<<<<<< Updated upstream - // Strategy 1: Try public billing API with user's fine-grained PAT. - const quotaConfigRead = readQuotaConfigWithMeta(); - if (quotaConfigRead.state === "valid" && quotaConfigRead.config) { - try { - const billing = await fetchPublicBillingUsage(quotaConfigRead.config); - return toQuotaResultFromBilling(billing, quotaConfigRead.config.tier); - } catch (err) { - return { - success: false, - error: err instanceof Error ? err.message : String(err), - } as QuotaError; -======= const pat = readQuotaConfigWithMeta(); if (pat.state === "invalid") { @@ -796,7 +513,6 @@ export async function queryCopilotQuota(): Promise { return toQuotaResultFromBilling(response, pat.config.tier); } catch (error) { return toQuotaError(error instanceof Error ? error.message : String(error)); ->>>>>>> Stashed changes } } @@ -806,71 +522,9 @@ export async function queryCopilotQuota(): Promise { return null; } -<<<<<<< Updated upstream - try { - const data = await fetchCopilotUsage(auth); - const premium = data.quota_snapshots.premium_interactions; - - if (!premium) { - return { - success: false, - error: "No premium quota data", - } as QuotaError; - } - - if (premium.unlimited) { - return { - success: true, - used: 0, - total: -1, // Indicate unlimited - percentRemaining: 100, - resetTimeIso: data.quota_reset_date, - } as CopilotQuotaResult; - } - - const total = premium.entitlement; - if (!Number.isFinite(total) || total <= 0) { - return { - success: false, - error: "Invalid premium quota entitlement", - } as QuotaError; - } - - const remainingRaw = - typeof premium.remaining === "number" - ? premium.remaining - : typeof premium.quota_remaining === "number" - ? premium.quota_remaining - : NaN; - - if (!Number.isFinite(remainingRaw)) { - return { - success: false, - error: "Invalid premium quota remaining value", - } as QuotaError; - } - - const remaining = Math.max(0, Math.min(total, remainingRaw)); - const used = Math.max(0, total - remaining); - const percentRemaining = computePercentRemainingFromUsed({ used, total }); - - 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; -======= const tokenCandidates = getOAuthTokenCandidates(auth); if (tokenCandidates.length === 0) { return null; ->>>>>>> Stashed changes } let lastError: string | null = null; diff --git a/src/lib/quota-status.ts b/src/lib/quota-status.ts index 43d4a58..d49af80 100644 --- a/src/lib/quota-status.ts +++ b/src/lib/quota-status.ts @@ -13,7 +13,6 @@ import { readQwenLocalQuotaState, } from "./qwen-local-quota.js"; import { hasQwenOAuthAuth } from "./qwen-auth.js"; -import { getCopilotQuotaAuthDiagnostics } from "./copilot.js"; import { getPricingSnapshotHealth, getPricingRefreshPolicy, @@ -349,12 +348,9 @@ export async function buildQuotaStatusReport(params: { if (copilotDiag.pat.config?.tier) { lines.push(`- pat_tier: ${copilotDiag.pat.config.tier}`); } -<<<<<<< Updated upstream -======= if (copilotDiag.pat.config?.organization) { lines.push(`- pat_organization: ${copilotDiag.pat.config.organization}`); } ->>>>>>> Stashed changes if (copilotDiag.pat.error) { lines.push(`- pat_error: ${copilotDiag.pat.error}`); } @@ -366,10 +362,6 @@ export async function buildQuotaStatusReport(params: { ); lines.push(`- effective_source: ${copilotDiag.effectiveSource}`); lines.push(`- override: ${copilotDiag.override}`); -<<<<<<< Updated upstream - -======= ->>>>>>> Stashed changes const googleTokenCachePath = getGoogleTokenCachePath(); lines.push( `- google token cache: ${googleTokenCachePath}${(await pathExists(googleTokenCachePath)) ? "" : " (missing)"}`, diff --git a/tests/lib.copilot.test.ts b/tests/lib.copilot.test.ts index 6ae07a3..885831a 100644 --- a/tests/lib.copilot.test.ts +++ b/tests/lib.copilot.test.ts @@ -1,31 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const fsMocks = vi.hoisted(() => ({ -<<<<<<< Updated upstream - existsSync: vi.fn<(path: string) => boolean>(() => false), - readFileSync: vi.fn<(path: string, encoding: BufferEncoding) => string>(() => ""), -})); - -const runtimeMocks = vi.hoisted(() => ({ - getOpencodeRuntimeDirCandidates: vi.fn(() => ({ - dataDirs: ["/home/test/.local/share/opencode"], - configDirs: [ - "/home/test/.config/opencode", - "/home/test/Library/Application Support/opencode", - ], - cacheDirs: ["/home/test/.cache/opencode"], - stateDirs: ["/home/test/.local/state/opencode"], - })), - getOpencodeRuntimeDirs: vi.fn(() => ({ - dataDir: "/home/test/.local/share/opencode", - configDir: "/home/test/.config/opencode", - cacheDir: "/home/test/.cache/opencode", - stateDir: "/home/test/.local/state/opencode", - })), -======= existsSync: vi.fn(() => false), readFileSync: vi.fn(), ->>>>>>> Stashed changes })); const authMocks = vi.hoisted(() => ({ @@ -42,17 +19,12 @@ vi.mock("fs", async (importOriginal) => { }); vi.mock("../src/lib/opencode-runtime-paths.js", () => ({ -<<<<<<< Updated upstream - getOpencodeRuntimeDirCandidates: runtimeMocks.getOpencodeRuntimeDirCandidates, - getOpencodeRuntimeDirs: runtimeMocks.getOpencodeRuntimeDirs, -======= getOpencodeRuntimeDirCandidates: () => ({ dataDirs: ["/home/test/.local/share/opencode"], configDirs: ["/home/test/.config/opencode"], cacheDirs: ["/home/test/.cache/opencode"], stateDirs: ["/home/test/.local/state/opencode"], }), ->>>>>>> Stashed changes })); vi.mock("../src/lib/opencode-auth.js", () => ({ @@ -61,7 +33,6 @@ vi.mock("../src/lib/opencode-auth.js", () => ({ const patPath = "/home/test/.config/opencode/copilot-quota-token.json"; const realEnv = process.env; -const patPath = "/home/test/.config/opencode/copilot-quota-token.json"; describe("queryCopilotQuota", () => { beforeEach(() => { @@ -70,24 +41,12 @@ describe("queryCopilotQuota", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-15T12:00:00.000Z")); process.env = { ...realEnv }; -<<<<<<< Updated upstream - vi.resetModules(); - - fsMocks.existsSync.mockReset(); - fsMocks.readFileSync.mockReset(); - authMocks.readAuthFile.mockReset(); - - fsMocks.existsSync.mockReturnValue(false); - fsMocks.readFileSync.mockReturnValue(""); - authMocks.readAuthFile.mockResolvedValue({}); -======= 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); ->>>>>>> Stashed changes }); afterEach(() => { @@ -98,54 +57,16 @@ describe("queryCopilotQuota", () => { it("returns null when no PAT config and no OpenCode Copilot auth exist", async () => { const { queryCopilotQuota } = await import("../src/lib/copilot.js"); -<<<<<<< Updated upstream - authMocks.readAuthFile.mockResolvedValueOnce({}); -======= ->>>>>>> Stashed changes await expect(queryCopilotQuota()).resolves.toBeNull(); }); -<<<<<<< Updated upstream - it("uses PAT billing API when PAT config exists and overrides OAuth auth", async () => { - const { queryCopilotQuota } = await import("../src/lib/copilot.js"); - -======= it("prefers PAT billing config over OpenCode auth when both exist", async () => { ->>>>>>> Stashed changes fsMocks.existsSync.mockImplementation((path) => path === patPath); fsMocks.readFileSync.mockReturnValue( JSON.stringify({ token: "github_pat_123456789", tier: "pro", -<<<<<<< Updated upstream - }), - ); - - authMocks.readAuthFile.mockResolvedValueOnce({ - "github-copilot": { type: "oauth", refresh: "gho_oauth_token" }, - }); - - const fetchMock = vi.fn(async (url: unknown, opts: RequestInit | undefined) => { - const s = String(url); - - if (s.includes("/user/settings/billing/premium_request/usage")) { - expect((opts?.headers as Record | undefined)?.Authorization).toBe( - "Bearer github_pat_123456789", - ); - - return new Response( - JSON.stringify({ - timePeriod: { year: 2026, month: 1 }, - user: "halfwalker", - usageItems: [ - { - product: "copilot", - sku: "Copilot Premium Request", - unitType: "count", - grossQuantity: 1, - netQuantity: 1, -======= username: "alice", }), ); @@ -163,7 +84,6 @@ describe("queryCopilotQuota", () => { { sku: "Copilot Premium Request", grossQuantity: 42, ->>>>>>> Stashed changes limit: 300, }, ], @@ -177,46 +97,6 @@ describe("queryCopilotQuota", () => { vi.stubGlobal("fetch", fetchMock as any); -<<<<<<< Updated upstream - const out = await queryCopilotQuota(); - expect(out && out.success ? out.total : -1).toBe(300); - expect(out && out.success ? out.used : -1).toBe(1); - expect(out && out.success ? out.percentRemaining : -1).toBe(99); - expect(authMocks.readAuthFile).not.toHaveBeenCalled(); - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - - it("falls back to OAuth/internal flow when PAT config is invalid", async () => { - const { getCopilotQuotaAuthDiagnostics, queryCopilotQuota } = await import("../src/lib/copilot.js"); - - fsMocks.existsSync.mockImplementation((path) => path === patPath); - fsMocks.readFileSync.mockReturnValue("{bad-json"); - - const oauthAuth = { - "github-copilot": { type: "oauth", refresh: "gho_abc" }, - }; - authMocks.readAuthFile.mockResolvedValueOnce(oauthAuth); - - const fetchMock = vi.fn(async (url: unknown) => { - const s = String(url); - if (s.includes("/copilot_internal/user")) { - return new Response( - JSON.stringify({ - copilot_plan: "pro", - quota_reset_date: "2026-02-01T00:00:00.000Z", - quota_snapshots: { - premium_interactions: { - entitlement: 300, - remaining: 299, - percent_remaining: 100, - unlimited: false, - overage_count: 0, - overage_permitted: false, - quota_id: "x", - quota_remaining: 0, - }, - }, -======= const { queryCopilotQuota } = await import("../src/lib/copilot.js"); const result = await queryCopilotQuota(); @@ -255,7 +135,6 @@ describe("queryCopilotQuota", () => { limit: 300, }, ], ->>>>>>> Stashed changes }), { status: 200 }, ); @@ -266,23 +145,6 @@ describe("queryCopilotQuota", () => { vi.stubGlobal("fetch", fetchMock as any); -<<<<<<< Updated upstream - const out = await queryCopilotQuota(); - expect(out && out.success ? out.total : -1).toBe(300); - expect(out && out.success ? out.used : -1).toBe(1); - expect(out && out.success ? out.percentRemaining : -1).toBe(99); - - const diag = getCopilotQuotaAuthDiagnostics(oauthAuth as any); - expect(diag.pat.state).toBe("invalid"); - expect(diag.pat.selectedPath).toBe(patPath); - expect(diag.effectiveSource).toBe("oauth"); - expect(diag.override).toBe("none"); - }); - - it("returns PAT error and does not fall back to OAuth when PAT is rejected", async () => { - const { queryCopilotQuota } = await import("../src/lib/copilot.js"); - -======= const { queryCopilotQuota } = await import("../src/lib/copilot.js"); const result = await queryCopilotQuota(); @@ -388,72 +250,11 @@ describe("queryCopilotQuota", () => { }); it("handles snake_case billing response fields", async () => { ->>>>>>> Stashed changes fsMocks.existsSync.mockImplementation((path) => path === patPath); fsMocks.readFileSync.mockReturnValue( JSON.stringify({ token: "github_pat_123456789", tier: "pro", -<<<<<<< Updated upstream - }), - ); - - authMocks.readAuthFile.mockResolvedValueOnce({ - "github-copilot": { type: "oauth", refresh: "gho_should_not_be_used" }, - }); - - const fetchMock = vi.fn(async (url: unknown) => { - const s = String(url); - - if (s.includes("/user/settings/billing/premium_request/usage")) { - return new Response(JSON.stringify({ message: "Forbidden" }), { status: 403 }); - } - - if (s.includes("/copilot_internal/user")) { - return new Response("unexpected oauth fallback", { status: 200 }); - } - - return new Response("not found", { status: 404 }); - }); - - vi.stubGlobal("fetch", fetchMock as any); - - const out = await queryCopilotQuota(); - expect(out && !out.success ? out.error : "").toContain("GitHub API error 403"); - expect(fetchMock.mock.calls.some(([url]) => String(url).includes("/copilot_internal/user"))).toBe( - false, - ); - expect(authMocks.readAuthFile).not.toHaveBeenCalled(); - }); - - it("computes remaining percentage from entitlement/remaining when OAuth response percent is stale", async () => { - const { queryCopilotQuota } = await import("../src/lib/copilot.js"); - - authMocks.readAuthFile.mockResolvedValueOnce({ - "github-copilot": { type: "oauth", refresh: "gho_abc" }, - }); - - const fetchMock = vi.fn(async (url: unknown) => { - const s = String(url); - - if (s.includes("/copilot_internal/user")) { - return new Response( - JSON.stringify({ - copilot_plan: "pro", - quota_reset_date: "2026-02-01T00:00:00.000Z", - quota_snapshots: { - premium_interactions: { - entitlement: 300, - remaining: 299, - percent_remaining: 100, - unlimited: false, - overage_count: 0, - overage_permitted: false, - quota_id: "x", - quota_remaining: 299, - }, - }, -======= username: "alice", }), ); @@ -471,7 +272,6 @@ describe("queryCopilotQuota", () => { limit: 300, }, ], ->>>>>>> Stashed changes }), { status: 200 }, ); @@ -482,11 +282,6 @@ describe("queryCopilotQuota", () => { vi.stubGlobal("fetch", fetchMock as any); -<<<<<<< Updated upstream - const out = await queryCopilotQuota(); - expect(out && out.success ? out.used : -1).toBe(1); - expect(out && out.success ? out.percentRemaining : -1).toBe(99); -======= const { queryCopilotQuota } = await import("../src/lib/copilot.js"); const result = await queryCopilotQuota(); @@ -559,6 +354,5 @@ describe("queryCopilotQuota", () => { expect(diagnostics.oauth.configured).toBe(true); expect(diagnostics.effectiveSource).toBe("pat"); expect(diagnostics.override).toBe("pat_overrides_oauth"); ->>>>>>> Stashed changes }); });