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
});
});