Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,17 @@ Both fine-grained PATs (`github_pat_...`) and classic PATs (`ghp_...`) should wo

Tier options: `free`, `pro`, `pro+`, `business`, `enterprise`

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`.

</details>

<details>
Expand Down
215 changes: 108 additions & 107 deletions package-lock.json

Large diffs are not rendered by default.

276 changes: 226 additions & 50 deletions src/lib/copilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
*/

import type {
AuthData,
CopilotAuthData,
CopilotQuotaConfig,
CopilotTier,
Expand All @@ -20,9 +21,9 @@ import type {
} from "./types.js";
import { fetchWithTimeout } from "./http.js";
import { readAuthFile } from "./opencode-auth.js";
import { getOpencodeRuntimeDirCandidates } from "./opencode-runtime-paths.js";

import { existsSync, readFileSync } from "fs";
import { homedir } from "os";
import { join } from "path";

// =============================================================================
Expand All @@ -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
Expand Down Expand Up @@ -77,6 +74,61 @@ function buildLegacyTokenHeaders(token: string): Record<string, string> {

type GitHubRestAuthScheme = "bearer" | "token";

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<string>();

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,
Expand Down Expand Up @@ -124,6 +176,93 @@ async function readGitHubRestErrorMessage(response: Response): Promise<string> {
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<string, unknown>;
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<T>(
url: string,
token: string,
Expand Down Expand Up @@ -154,51 +293,66 @@ async function fetchGitHubRestJsonOnce<T>(
*/
async function readCopilotAuth(): Promise<CopilotAuthData | null> {
const authData = await readAuthFile();
if (!authData) return null;

// Try known key names in priority order
const copilotAuth =
authData["github-copilot"] ??
(authData as Record<string, CopilotAuthData | undefined>)["copilot"] ??
(authData as Record<string, CopilotAuthData | undefined>)["copilot-chat"];

if (!copilotAuth || copilotAuth.type !== "oauth" || !copilotAuth.refresh) {
return null;
}

return copilotAuth;
return selectCopilotAuth(authData).auth;
}

/**
* Read optional Copilot quota config from user's config file.
* Returns null if file doesn't exist or is invalid.
* Select Copilot OAuth auth entry from auth.json-shaped data.
*/
function readQuotaConfig(): CopilotQuotaConfig | null {
try {
if (!existsSync(COPILOT_QUOTA_CONFIG_PATH)) {
return null;
}
function selectCopilotAuth(
authData: AuthData | null,
): { auth: CopilotAuthData | null; keyName: CopilotAuthKeyName | null } {
if (!authData) {
return { auth: null, keyName: null };
}

const content = readFileSync(COPILOT_QUOTA_CONFIG_PATH, "utf-8");
const parsed = JSON.parse(content) as CopilotQuotaConfig;
const candidates: Array<[CopilotAuthKeyName, CopilotAuthData | undefined]> = [
["github-copilot", authData["github-copilot"]],
["copilot", (authData as Record<string, CopilotAuthData | undefined>).copilot],
["copilot-chat", (authData as Record<string, CopilotAuthData | undefined>)["copilot-chat"]],
];

for (const [keyName, candidate] of candidates) {
if (!candidate) continue;
if (candidate.type !== "oauth") continue;
if (!candidate.refresh) continue;
return { auth: candidate, keyName };
}

if (!parsed || typeof parsed !== "object") return null;
return { auth: null, keyName: null };
}

if (typeof parsed.token !== "string" || parsed.token.trim() === "") return null;
if (typeof parsed.tier !== "string" || parsed.tier.trim() === "") return null;
export function getCopilotQuotaAuthDiagnostics(authData: AuthData | null): CopilotQuotaAuthDiagnostics {
const pat = readQuotaConfigWithMeta();
const { auth, keyName } = selectCopilotAuth(authData);
const oauthConfigured = Boolean(auth);

// 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;
}
let effectiveSource: "pat" | "oauth" | "none" = "none";
if (pat.state === "valid") {
effectiveSource = "pat";
} else if (oauthConfigured) {
effectiveSource = "oauth";
}

const validTiers: CopilotTier[] = ["free", "pro", "pro+", "business", "enterprise"];
if (!validTiers.includes(parsed.tier as CopilotTier)) 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",
};
}

return parsed;
} catch {
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;
}

// Public billing API response types (keep local; only used here)
Expand Down Expand Up @@ -305,12 +459,12 @@ function toQuotaResultFromBilling(
throw new Error(`Unsupported Copilot tier: ${tier}`);
}

const remaining = Math.max(0, total - used);
const percentRemaining = Math.max(0, Math.min(100, Math.round((remaining / total) * 100)));
const normalizedUsed = Math.max(0, used);
const percentRemaining = computePercentRemainingFromUsed({ used: normalizedUsed, total });

return {
success: true,
used,
used: normalizedUsed,
total,
percentRemaining,
resetTimeIso: getApproxNextResetIso(),
Expand Down Expand Up @@ -418,11 +572,11 @@ async function fetchCopilotUsage(authData: CopilotAuthData): Promise<CopilotUsag
*/
export async function queryCopilotQuota(): Promise<CopilotResult> {
// 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,
Expand Down Expand Up @@ -459,8 +613,30 @@ export async function queryCopilotQuota(): Promise<CopilotResult> {
}

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,
Expand Down
Loading