@slkiser/opencode-quota 2.4.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +192 -226
  2. package/dist/index.d.ts +1 -1
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/lib/copilot.d.ts +48 -16
  6. package/dist/lib/copilot.d.ts.map +1 -1
  7. package/dist/lib/copilot.js +540 -283
  8. package/dist/lib/copilot.js.map +1 -1
  9. package/dist/lib/entries.d.ts +11 -3
  10. package/dist/lib/entries.d.ts.map +1 -1
  11. package/dist/lib/entries.js.map +1 -1
  12. package/dist/lib/format.js.map +1 -1
  13. package/dist/lib/grouped-entry-normalization.d.ts +7 -0
  14. package/dist/lib/grouped-entry-normalization.d.ts.map +1 -0
  15. package/dist/lib/grouped-entry-normalization.js +50 -0
  16. package/dist/lib/grouped-entry-normalization.js.map +1 -0
  17. package/dist/lib/grouped-header-format.d.ts +5 -0
  18. package/dist/lib/grouped-header-format.d.ts.map +1 -0
  19. package/dist/lib/grouped-header-format.js +16 -0
  20. package/dist/lib/grouped-header-format.js.map +1 -0
  21. package/dist/lib/quota-command-format.d.ts +2 -3
  22. package/dist/lib/quota-command-format.d.ts.map +1 -1
  23. package/dist/lib/quota-command-format.js +14 -43
  24. package/dist/lib/quota-command-format.js.map +1 -1
  25. package/dist/lib/quota-status.d.ts.map +1 -1
  26. package/dist/lib/quota-status.js +51 -0
  27. package/dist/lib/quota-status.js.map +1 -1
  28. package/dist/lib/toast-format-grouped.d.ts +1 -9
  29. package/dist/lib/toast-format-grouped.d.ts.map +1 -1
  30. package/dist/lib/toast-format-grouped.js +8 -25
  31. package/dist/lib/toast-format-grouped.js.map +1 -1
  32. package/dist/lib/types.d.ts +56 -39
  33. package/dist/lib/types.d.ts.map +1 -1
  34. package/dist/lib/types.js.map +1 -1
  35. package/dist/providers/copilot.d.ts.map +1 -1
  36. package/dist/providers/copilot.js +69 -8
  37. package/dist/providers/copilot.js.map +1 -1
  38. package/dist/providers/openai.d.ts.map +1 -1
  39. package/dist/providers/openai.js.map +1 -1
  40. package/dist/providers/qwen-code.d.ts.map +1 -1
  41. package/dist/providers/qwen-code.js.map +1 -1
  42. package/dist/providers/zai.d.ts.map +1 -1
  43. package/dist/providers/zai.js.map +1 -1
  44. package/package.json +1 -1
@@ -1,72 +1,330 @@
1
1
  /**
2
- * GitHub Copilot quota fetcher
2
+ * GitHub Copilot premium request usage fetcher.
3
3
  *
4
- * Strategy (new Copilot API reality):
5
- *
6
- * 1) Preferred: GitHub public billing API using a fine-grained PAT
7
- * configured in ~/.config/opencode/copilot-quota-token.json.
8
- * 2) Best-effort: internal endpoint using OpenCode's stored OAuth token
9
- * (legacy formats or via token exchange).
4
+ * The plugin only uses documented GitHub billing APIs:
5
+ * - /users/{username}/settings/billing/premium_request/usage
6
+ * - /organizations/{org}/settings/billing/premium_request/usage
7
+ * - /enterprises/{enterprise}/settings/billing/premium_request/usage
10
8
  */
11
- import { fetchWithTimeout } from "./http.js";
12
- import { readAuthFile } from "./opencode-auth.js";
13
9
  import { existsSync, readFileSync } from "fs";
14
- import { homedir } from "os";
15
10
  import { join } from "path";
16
- // =============================================================================
17
- // Constants
18
- // =============================================================================
11
+ import { fetchWithTimeout } from "./http.js";
12
+ import { readAuthFile } from "./opencode-auth.js";
13
+ import { getOpencodeRuntimeDirCandidates } from "./opencode-runtime-paths.js";
19
14
  const GITHUB_API_BASE_URL = "https://api.github.com";
20
- const COPILOT_INTERNAL_USER_URL = `${GITHUB_API_BASE_URL}/copilot_internal/user`;
21
- const COPILOT_TOKEN_EXCHANGE_URL = `${GITHUB_API_BASE_URL}/copilot_internal/v2/token`;
22
- // Keep these aligned with current Copilot/VSC versions to avoid API heuristics.
23
- const COPILOT_VERSION = "0.35.0";
24
- const EDITOR_VERSION = "vscode/1.107.0";
25
- const EDITOR_PLUGIN_VERSION = `copilot-chat/${COPILOT_VERSION}`;
26
- const USER_AGENT = `GitHubCopilotChat/${COPILOT_VERSION}`;
27
- const COPILOT_QUOTA_CONFIG_PATH = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "opencode", "copilot-quota-token.json");
28
- // =============================================================================
29
- // Helpers
30
- // =============================================================================
31
- /**
32
- * Build headers for GitHub API requests
33
- */
34
- const COPILOT_HEADERS = {
35
- "User-Agent": USER_AGENT,
36
- "Editor-Version": EDITOR_VERSION,
37
- "Editor-Plugin-Version": EDITOR_PLUGIN_VERSION,
38
- "Copilot-Integration-Id": "vscode-chat",
15
+ const GITHUB_API_VERSION = "2022-11-28";
16
+ const COPILOT_QUOTA_CONFIG_FILENAME = "copilot-quota-token.json";
17
+ const USER_AGENT = "opencode-quota/copilot-billing";
18
+ const COPILOT_PLAN_LIMITS = {
19
+ free: 50,
20
+ pro: 300,
21
+ "pro+": 1500,
22
+ business: 300,
23
+ enterprise: 1000,
39
24
  };
40
- function buildBearerHeaders(token) {
25
+ function dedupeStrings(values) {
26
+ const out = [];
27
+ const seen = new Set();
28
+ for (const value of values) {
29
+ const trimmed = value?.trim();
30
+ if (!trimmed || seen.has(trimmed))
31
+ continue;
32
+ seen.add(trimmed);
33
+ out.push(trimmed);
34
+ }
35
+ return out;
36
+ }
37
+ function classifyPatTokenKind(token) {
38
+ if (token.startsWith("github_pat_"))
39
+ return "github_pat";
40
+ if (token.startsWith("ghp_"))
41
+ return "ghp";
42
+ if (token.startsWith("ghu_"))
43
+ return "ghu";
44
+ if (token.startsWith("ghs_"))
45
+ return "ghs";
46
+ return "other";
47
+ }
48
+ function getCurrentBillingPeriod(now = new Date()) {
49
+ return {
50
+ year: now.getFullYear(),
51
+ month: now.getMonth() + 1,
52
+ };
53
+ }
54
+ function buildBillingPeriodQueryParams(period, options) {
55
+ const searchParams = new URLSearchParams();
56
+ searchParams.set("year", String(period.year));
57
+ searchParams.set("month", String(period.month));
58
+ if (options?.includeDay && typeof period.day === "number") {
59
+ searchParams.set("day", String(period.day));
60
+ }
61
+ if (options?.organization) {
62
+ searchParams.set("organization", options.organization);
63
+ }
64
+ if (options?.username) {
65
+ searchParams.set("user", options.username);
66
+ }
67
+ return searchParams;
68
+ }
69
+ function getBillingModeForTarget(target) {
70
+ if (!target)
71
+ return "none";
72
+ if (target.scope === "organization")
73
+ return "organization_usage";
74
+ if (target.scope === "enterprise")
75
+ return "enterprise_usage";
76
+ return "user_quota";
77
+ }
78
+ function getBillingScopeForTarget(target) {
79
+ return target?.scope ?? "none";
80
+ }
81
+ function getRemainingTotalsStateForTarget(target) {
82
+ if (!target)
83
+ return "unavailable";
84
+ if (target.scope === "organization")
85
+ return "not_available_from_org_usage";
86
+ if (target.scope === "enterprise")
87
+ return "not_available_from_enterprise_usage";
88
+ return "available";
89
+ }
90
+ function resolvePatBillingTarget(config) {
91
+ const billingPeriod = getCurrentBillingPeriod();
92
+ if (config.tier === "business") {
93
+ if (config.enterprise) {
94
+ return {
95
+ target: null,
96
+ error: 'Copilot business usage is organization-scoped. Remove "enterprise" and keep "organization" in copilot-quota-token.json.',
97
+ };
98
+ }
99
+ if (!config.organization) {
100
+ return {
101
+ target: null,
102
+ error: 'Copilot business usage requires an organization-scoped billing report. Add "organization": "your-org-slug" to copilot-quota-token.json.',
103
+ };
104
+ }
105
+ return {
106
+ target: {
107
+ scope: "organization",
108
+ organization: config.organization,
109
+ username: config.username,
110
+ billingPeriod,
111
+ },
112
+ };
113
+ }
114
+ if (config.tier === "enterprise") {
115
+ if (config.enterprise) {
116
+ return {
117
+ target: {
118
+ scope: "enterprise",
119
+ enterprise: config.enterprise,
120
+ organization: config.organization,
121
+ username: config.username,
122
+ billingPeriod,
123
+ },
124
+ };
125
+ }
126
+ if (config.organization) {
127
+ return {
128
+ target: {
129
+ scope: "organization",
130
+ organization: config.organization,
131
+ username: config.username,
132
+ billingPeriod,
133
+ },
134
+ };
135
+ }
136
+ return {
137
+ target: null,
138
+ error: 'Copilot enterprise usage requires an enterprise- or organization-scoped billing report. Add "enterprise": "your-enterprise-slug" or "organization": "your-org-slug" to copilot-quota-token.json.',
139
+ };
140
+ }
141
+ if (config.organization || config.enterprise) {
142
+ return {
143
+ target: null,
144
+ error: `Copilot ${config.tier} usage is user-scoped. Remove "organization"/"enterprise" from copilot-quota-token.json or switch to a managed tier.`,
145
+ };
146
+ }
41
147
  return {
42
- Accept: "application/json",
43
- Authorization: `Bearer ${token}`,
44
- ...COPILOT_HEADERS,
148
+ target: {
149
+ scope: "user",
150
+ username: config.username,
151
+ },
45
152
  };
46
153
  }
47
- function buildLegacyTokenHeaders(token) {
154
+ function validatePatTargetCompatibility(target, tokenKind) {
155
+ if (target.scope !== "enterprise" || !tokenKind) {
156
+ return null;
157
+ }
158
+ if (tokenKind === "github_pat") {
159
+ return ("GitHub's enterprise premium usage endpoint does not support fine-grained personal access tokens. " +
160
+ "Use a classic PAT or another supported non-fine-grained token for enterprise billing.");
161
+ }
162
+ if (tokenKind === "ghu" || tokenKind === "ghs") {
163
+ return ("GitHub's enterprise premium usage endpoint does not support GitHub App user or installation access tokens.");
164
+ }
165
+ return null;
166
+ }
167
+ export function getCopilotPatConfigCandidatePaths() {
168
+ const { configDirs } = getOpencodeRuntimeDirCandidates();
169
+ return dedupeStrings(configDirs.map((configDir) => join(configDir, COPILOT_QUOTA_CONFIG_FILENAME)));
170
+ }
171
+ function validateQuotaConfig(raw) {
172
+ if (!raw || typeof raw !== "object") {
173
+ return { config: null, error: "Config must be a JSON object" };
174
+ }
175
+ const obj = raw;
176
+ const token = typeof obj.token === "string" ? obj.token.trim() : "";
177
+ const tier = typeof obj.tier === "string" ? obj.tier.trim() : "";
178
+ if (!token) {
179
+ return { config: null, error: "Missing required string field: token" };
180
+ }
181
+ const validTiers = ["free", "pro", "pro+", "business", "enterprise"];
182
+ if (!validTiers.includes(tier)) {
183
+ return {
184
+ config: null,
185
+ error: "Invalid tier; expected one of: free, pro, pro+, business, enterprise",
186
+ };
187
+ }
188
+ const usernameRaw = obj.username;
189
+ let username;
190
+ if (usernameRaw != null) {
191
+ if (typeof usernameRaw !== "string" || !usernameRaw.trim()) {
192
+ return { config: null, error: "username must be a non-empty string when provided" };
193
+ }
194
+ username = usernameRaw.trim();
195
+ }
196
+ const organizationRaw = obj.organization;
197
+ let organization;
198
+ if (organizationRaw != null) {
199
+ if (typeof organizationRaw !== "string" || !organizationRaw.trim()) {
200
+ return { config: null, error: "organization must be a non-empty string when provided" };
201
+ }
202
+ organization = organizationRaw.trim();
203
+ }
204
+ const enterpriseRaw = obj.enterprise;
205
+ let enterprise;
206
+ if (enterpriseRaw != null) {
207
+ if (typeof enterpriseRaw !== "string" || !enterpriseRaw.trim()) {
208
+ return { config: null, error: "enterprise must be a non-empty string when provided" };
209
+ }
210
+ enterprise = enterpriseRaw.trim();
211
+ }
212
+ return {
213
+ config: {
214
+ token,
215
+ tier: tier,
216
+ username,
217
+ organization,
218
+ enterprise,
219
+ },
220
+ };
221
+ }
222
+ export function readQuotaConfigWithMeta() {
223
+ const checkedPaths = getCopilotPatConfigCandidatePaths();
224
+ for (const path of checkedPaths) {
225
+ if (!existsSync(path))
226
+ continue;
227
+ try {
228
+ const content = readFileSync(path, "utf-8");
229
+ const parsed = JSON.parse(content);
230
+ const validated = validateQuotaConfig(parsed);
231
+ if (!validated.config) {
232
+ return {
233
+ state: "invalid",
234
+ checkedPaths,
235
+ selectedPath: path,
236
+ error: validated.error ?? "Invalid config",
237
+ };
238
+ }
239
+ return {
240
+ state: "valid",
241
+ checkedPaths,
242
+ selectedPath: path,
243
+ config: validated.config,
244
+ tokenKind: classifyPatTokenKind(validated.config.token),
245
+ };
246
+ }
247
+ catch (error) {
248
+ return {
249
+ state: "invalid",
250
+ checkedPaths,
251
+ selectedPath: path,
252
+ error: error instanceof Error ? error.message : String(error),
253
+ };
254
+ }
255
+ }
256
+ return { state: "absent", checkedPaths };
257
+ }
258
+ function selectCopilotAuth(authData) {
259
+ if (!authData) {
260
+ return { auth: null, keyName: null };
261
+ }
262
+ const candidates = [
263
+ ["github-copilot", authData["github-copilot"]],
264
+ ["copilot", authData.copilot],
265
+ ["copilot-chat", authData["copilot-chat"]],
266
+ ["github-copilot-chat", authData["github-copilot-chat"]],
267
+ ];
268
+ for (const [keyName, auth] of candidates) {
269
+ if (!auth || auth.type !== "oauth")
270
+ continue;
271
+ if (!auth.access && !auth.refresh)
272
+ continue;
273
+ return { auth, keyName };
274
+ }
275
+ return { auth: null, keyName: null };
276
+ }
277
+ export function getCopilotQuotaAuthDiagnostics(authData) {
278
+ const pat = readQuotaConfigWithMeta();
279
+ const { auth, keyName } = selectCopilotAuth(authData);
280
+ const resolvedPatTarget = pat.state === "valid" && pat.config ? resolvePatBillingTarget(pat.config) : { target: null };
281
+ const tokenCompatibilityError = pat.state === "valid" && resolvedPatTarget.target
282
+ ? validatePatTargetCompatibility(resolvedPatTarget.target, pat.tokenKind)
283
+ : null;
284
+ const patBlocksOAuth = pat.state !== "absent";
285
+ let effectiveSource = "none";
286
+ if (patBlocksOAuth)
287
+ effectiveSource = "pat";
288
+ else if (auth)
289
+ effectiveSource = "oauth";
290
+ const billingTarget = pat.state === "valid" ? resolvedPatTarget.target : !patBlocksOAuth && auth ? { scope: "user" } : null;
291
+ const billingMode = getBillingModeForTarget(billingTarget);
48
292
  return {
49
- Accept: "application/json",
50
- Authorization: `token ${token}`,
51
- ...COPILOT_HEADERS,
293
+ pat,
294
+ oauth: {
295
+ configured: Boolean(auth),
296
+ keyName,
297
+ hasRefreshToken: Boolean(auth?.refresh),
298
+ hasAccessToken: Boolean(auth?.access),
299
+ },
300
+ effectiveSource,
301
+ override: patBlocksOAuth && auth ? "pat_overrides_oauth" : "none",
302
+ billingMode,
303
+ billingScope: getBillingScopeForTarget(billingTarget),
304
+ billingApiAccessLikely: effectiveSource === "pat"
305
+ ? Boolean(billingTarget) &&
306
+ !resolvedPatTarget.error &&
307
+ !tokenCompatibilityError
308
+ : effectiveSource === "oauth",
309
+ remainingTotalsState: getRemainingTotalsStateForTarget(billingTarget),
310
+ queryPeriod: billingTarget && billingTarget.scope !== "user"
311
+ ? billingTarget.billingPeriod
312
+ : undefined,
313
+ usernameFilter: pat.state === "valid" ? pat.config?.username : undefined,
314
+ billingTargetError: pat.state === "valid" ? resolvedPatTarget.error : undefined,
315
+ tokenCompatibilityError: tokenCompatibilityError ?? undefined,
52
316
  };
53
317
  }
54
318
  function buildGitHubRestHeaders(token, scheme) {
55
319
  return {
56
320
  Accept: "application/vnd.github+json",
57
321
  Authorization: scheme === "bearer" ? `Bearer ${token}` : `token ${token}`,
58
- "X-GitHub-Api-Version": "2022-11-28",
322
+ "X-GitHub-Api-Version": GITHUB_API_VERSION,
59
323
  "User-Agent": USER_AGENT,
60
324
  };
61
325
  }
62
326
  function preferredSchemesForToken(token) {
63
- const t = token.trim();
64
- // Fine-grained PATs usually prefer Bearer.
65
- if (t.startsWith("github_pat_")) {
66
- return ["bearer", "token"];
67
- }
68
- // Classic PATs historically prefer legacy `token`.
69
- if (t.startsWith("ghp_")) {
327
+ if (token.startsWith("ghp_")) {
70
328
  return ["token", "bearer"];
71
329
  }
72
330
  return ["bearer", "token"];
@@ -75,18 +333,17 @@ async function readGitHubRestErrorMessage(response) {
75
333
  const text = await response.text();
76
334
  try {
77
335
  const parsed = JSON.parse(text);
78
- if (parsed && typeof parsed === "object") {
79
- const obj = parsed;
80
- const msg = typeof obj.message === "string" ? obj.message : null;
81
- const doc = typeof obj.documentation_url === "string" ? obj.documentation_url : null;
82
- if (msg && doc)
83
- return `${msg} (${doc})`;
84
- if (msg)
85
- return msg;
336
+ const message = typeof parsed.message === "string" ? parsed.message : null;
337
+ const documentationUrl = typeof parsed.documentation_url === "string" ? parsed.documentation_url : null;
338
+ if (message && documentationUrl) {
339
+ return `${message} (${documentationUrl})`;
340
+ }
341
+ if (message) {
342
+ return message;
86
343
  }
87
344
  }
88
345
  catch {
89
- // ignore
346
+ // ignore parse failures
90
347
  }
91
348
  return text.slice(0, 160);
92
349
  }
@@ -103,273 +360,273 @@ async function fetchGitHubRestJsonOnce(url, token, scheme) {
103
360
  message: await readGitHubRestErrorMessage(response),
104
361
  };
105
362
  }
106
- /**
107
- * Read Copilot auth data from auth.json
108
- *
109
- * Tries multiple key names to handle different OpenCode versions/configs.
110
- */
111
- async function readCopilotAuth() {
112
- const authData = await readAuthFile();
113
- if (!authData)
114
- return null;
115
- // Try known key names in priority order
116
- const copilotAuth = authData["github-copilot"] ??
117
- authData["copilot"] ??
118
- authData["copilot-chat"];
119
- if (!copilotAuth || copilotAuth.type !== "oauth" || !copilotAuth.refresh) {
120
- return null;
363
+ async function resolveGitHubUsername(token) {
364
+ const url = `${GITHUB_API_BASE_URL}/user`;
365
+ let unauthorized = null;
366
+ for (const scheme of preferredSchemesForToken(token)) {
367
+ const result = await fetchGitHubRestJsonOnce(url, token, scheme);
368
+ if (result.ok) {
369
+ const login = result.data.login?.trim();
370
+ if (login)
371
+ return login;
372
+ throw new Error("GitHub /user response did not include a login");
373
+ }
374
+ if (result.status === 401) {
375
+ unauthorized = { status: result.status, message: result.message };
376
+ continue;
377
+ }
378
+ throw new Error(`GitHub API error ${result.status}: ${result.message}`);
379
+ }
380
+ if (unauthorized) {
381
+ throw new Error(`GitHub API error ${unauthorized.status}: ${unauthorized.message} (token rejected while resolving username)`);
121
382
  }
122
- return copilotAuth;
383
+ throw new Error("Unable to resolve GitHub username for Copilot billing request");
123
384
  }
124
- /**
125
- * Read optional Copilot quota config from user's config file.
126
- * Returns null if file doesn't exist or is invalid.
127
- */
128
- function readQuotaConfig() {
129
- try {
130
- if (!existsSync(COPILOT_QUOTA_CONFIG_PATH)) {
131
- return null;
385
+ function getBillingRequestUrl(target) {
386
+ if (target.scope === "enterprise") {
387
+ const base = `${GITHUB_API_BASE_URL}/enterprises/${encodeURIComponent(target.enterprise)}/settings/billing/premium_request/usage`;
388
+ const searchParams = buildBillingPeriodQueryParams(target.billingPeriod, {
389
+ organization: target.organization,
390
+ username: target.username,
391
+ });
392
+ return `${base}?${searchParams.toString()}`;
393
+ }
394
+ if (target.scope === "organization") {
395
+ const base = `${GITHUB_API_BASE_URL}/organizations/${encodeURIComponent(target.organization)}/settings/billing/premium_request/usage`;
396
+ const searchParams = buildBillingPeriodQueryParams(target.billingPeriod, {
397
+ username: target.username,
398
+ });
399
+ return `${base}?${searchParams.toString()}`;
400
+ }
401
+ return `${GITHUB_API_BASE_URL}/users/${encodeURIComponent(target.username)}/settings/billing/premium_request/usage`;
402
+ }
403
+ async function fetchPremiumRequestUsage(params) {
404
+ const requestTarget = params.target.scope === "user"
405
+ ? {
406
+ scope: "user",
407
+ username: params.target.username ?? (await resolveGitHubUsername(params.token)),
408
+ }
409
+ : params.target;
410
+ const url = getBillingRequestUrl(requestTarget);
411
+ let unauthorized = null;
412
+ for (const scheme of preferredSchemesForToken(params.token)) {
413
+ const result = await fetchGitHubRestJsonOnce(url, params.token, scheme);
414
+ if (result.ok) {
415
+ return {
416
+ response: result.data,
417
+ billingPeriod: requestTarget.scope === "user" ? undefined : requestTarget.billingPeriod,
418
+ };
132
419
  }
133
- const content = readFileSync(COPILOT_QUOTA_CONFIG_PATH, "utf-8");
134
- const parsed = JSON.parse(content);
135
- if (!parsed || typeof parsed !== "object")
136
- return null;
137
- if (typeof parsed.token !== "string" || parsed.token.trim() === "")
138
- return null;
139
- if (typeof parsed.tier !== "string" || parsed.tier.trim() === "")
140
- return null;
141
- // Username is optional now that we prefer the /user/... billing endpoint.
142
- if (parsed.username != null) {
143
- if (typeof parsed.username !== "string" || parsed.username.trim() === "")
144
- return null;
420
+ if (result.status === 401) {
421
+ unauthorized = { status: result.status, message: result.message };
422
+ continue;
145
423
  }
146
- const validTiers = ["free", "pro", "pro+", "business", "enterprise"];
147
- if (!validTiers.includes(parsed.tier))
148
- return null;
149
- return parsed;
424
+ throw new Error(`GitHub API error ${result.status}: ${result.message}`);
150
425
  }
151
- catch {
152
- return null;
426
+ if (unauthorized) {
427
+ throw new Error(`GitHub API error ${unauthorized.status}: ${unauthorized.message} (token rejected for Copilot premium request usage)`);
153
428
  }
429
+ throw new Error("Unable to fetch Copilot premium request usage");
154
430
  }
155
- const COPILOT_PLAN_LIMITS = {
156
- free: 50,
157
- pro: 300,
158
- "pro+": 1500,
159
- business: 300,
160
- enterprise: 1000,
161
- };
162
431
  function getApproxNextResetIso(nowMs = Date.now()) {
163
432
  const now = new Date(nowMs);
164
- const year = now.getUTCFullYear();
165
- const month = now.getUTCMonth();
166
- return new Date(Date.UTC(year, month + 1, 1, 0, 0, 0, 0)).toISOString();
167
- }
168
- async function fetchPublicBillingUsage(config) {
169
- const token = config.token;
170
- const schemes = preferredSchemesForToken(token);
171
- // Prefer authenticated-user endpoint; fall back to /users/{username} for older behavior.
172
- const urls = [`${GITHUB_API_BASE_URL}/user/settings/billing/premium_request/usage`];
173
- if (config.username) {
174
- urls.push(`${GITHUB_API_BASE_URL}/users/${config.username}/settings/billing/premium_request/usage`);
175
- }
176
- for (const url of urls) {
177
- let lastUnauthorized = null;
178
- for (const scheme of schemes) {
179
- const res = await fetchGitHubRestJsonOnce(url, token, scheme);
180
- if (res.ok) {
181
- return res.data;
182
- }
183
- if (res.status === 401) {
184
- lastUnauthorized = { status: res.status, message: res.message };
185
- continue; // retry with alternate scheme
186
- }
187
- // If /user/... isn't supported for some reason, fall back to /users/... when available.
188
- if (res.status === 404 && url.includes("/user/")) {
189
- break;
190
- }
191
- throw new Error(`GitHub API error ${res.status}: ${res.message}`);
192
- }
193
- if (lastUnauthorized) {
194
- throw new Error(`GitHub API error ${lastUnauthorized.status}: ${lastUnauthorized.message} (token rejected; verify PAT and permissions)`);
195
- }
433
+ return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)).toISOString();
434
+ }
435
+ function computePercentRemainingFromUsed(params) {
436
+ const { used, total } = params;
437
+ if (!Number.isFinite(total) || total <= 0)
438
+ return 0;
439
+ if (!Number.isFinite(used) || used <= 0)
440
+ return 100;
441
+ const remaining = Math.max(0, total - Math.max(0, used));
442
+ return Math.max(0, Math.min(100, Math.floor((remaining * 100) / total)));
443
+ }
444
+ function getPremiumUsageItems(response, options) {
445
+ const items = Array.isArray(response.usageItems)
446
+ ? response.usageItems
447
+ : Array.isArray(response.usage_items)
448
+ ? response.usage_items
449
+ : [];
450
+ const premiumItems = items.filter((item) => {
451
+ if (!item || typeof item !== "object")
452
+ return false;
453
+ if (typeof item.sku !== "string")
454
+ return false;
455
+ return item.sku === "Copilot Premium Request" || item.sku.includes("Premium");
456
+ });
457
+ if (premiumItems.length === 0 && items.length > 0) {
458
+ const skus = items.map((item) => (typeof item?.sku === "string" ? item.sku : "?")).join(", ");
459
+ throw new Error(`No premium-request items found in billing response (${items.length} items, SKUs: ${skus}). Expected an item with SKU containing "Premium".`);
460
+ }
461
+ if (premiumItems.length === 0 && options?.allowEmpty) {
462
+ return [];
196
463
  }
197
- throw new Error("GitHub API error 404: Not Found");
464
+ if (premiumItems.length === 0) {
465
+ throw new Error("Billing API returned empty usageItems array for Copilot premium requests.");
466
+ }
467
+ return premiumItems;
468
+ }
469
+ function sumUsedUnits(items) {
470
+ return items.reduce((sum, item) => {
471
+ const used = item.grossQuantity ??
472
+ item.gross_quantity ??
473
+ item.netQuantity ??
474
+ item.net_quantity ??
475
+ 0;
476
+ return sum + (typeof used === "number" ? used : 0);
477
+ }, 0);
478
+ }
479
+ function formatBillingPeriod(period) {
480
+ return `${period.year}-${String(period.month).padStart(2, "0")}`;
198
481
  }
199
- function toQuotaResultFromBilling(data, tier) {
200
- const items = Array.isArray(data.usageItems) ? data.usageItems : [];
201
- const premiumItems = items.filter((item) => item &&
202
- typeof item === "object" &&
203
- typeof item.sku === "string" &&
204
- (item.sku === "Copilot Premium Request" || item.sku.includes("Premium")));
205
- const used = premiumItems.reduce((sum, item) => sum + (item.grossQuantity || 0), 0);
206
- const limits = premiumItems
482
+ function getBillingResponsePeriod(response, fallbackPeriod) {
483
+ const timePeriod = response.timePeriod ?? response.time_period;
484
+ const year = typeof timePeriod?.year === "number" ? timePeriod.year : fallbackPeriod.year;
485
+ const month = typeof timePeriod?.month === "number" ? timePeriod.month : fallbackPeriod.month;
486
+ return { year, month };
487
+ }
488
+ function toUserQuotaResultFromBilling(response, fallbackTier) {
489
+ const premiumItems = getPremiumUsageItems(response);
490
+ const used = sumUsedUnits(premiumItems);
491
+ const apiLimits = premiumItems
207
492
  .map((item) => item.limit)
208
- .filter((n) => typeof n === "number" && n > 0);
209
- // Prefer API-provided limits when available (more future-proof than hardcoding).
210
- const total = limits.length ? Math.max(...limits) : COPILOT_PLAN_LIMITS[tier];
493
+ .filter((limit) => typeof limit === "number" && limit > 0);
494
+ const total = apiLimits.length > 0 ? Math.max(...apiLimits) : fallbackTier ? COPILOT_PLAN_LIMITS[fallbackTier] : undefined;
211
495
  if (!total || total <= 0) {
212
- throw new Error(`Unsupported Copilot tier: ${tier}`);
496
+ 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.");
213
497
  }
214
- const remaining = Math.max(0, total - used);
215
- const percentRemaining = Math.max(0, Math.min(100, Math.round((remaining / total) * 100)));
216
498
  return {
217
499
  success: true,
500
+ mode: "user_quota",
218
501
  used,
219
502
  total,
220
- percentRemaining,
503
+ percentRemaining: computePercentRemainingFromUsed({ used, total }),
221
504
  resetTimeIso: getApproxNextResetIso(),
222
505
  };
223
506
  }
224
- async function exchangeForCopilotToken(oauthToken) {
225
- try {
226
- const response = await fetchWithTimeout(COPILOT_TOKEN_EXCHANGE_URL, {
227
- headers: {
228
- Accept: "application/json",
229
- Authorization: `Bearer ${oauthToken}`,
230
- ...COPILOT_HEADERS,
231
- },
232
- });
233
- if (!response.ok) {
234
- return null;
235
- }
236
- const tokenData = (await response.json());
237
- if (!tokenData || typeof tokenData.token !== "string")
238
- return null;
239
- return tokenData.token;
240
- }
241
- catch {
242
- return null;
243
- }
507
+ function toOrganizationUsageResultFromBilling(params) {
508
+ const premiumItems = getPremiumUsageItems(params.response, { allowEmpty: true });
509
+ return {
510
+ success: true,
511
+ mode: "organization_usage",
512
+ organization: params.organization,
513
+ username: params.username,
514
+ period: getBillingResponsePeriod(params.response, params.billingPeriod),
515
+ used: sumUsedUnits(premiumItems),
516
+ resetTimeIso: getApproxNextResetIso(),
517
+ };
244
518
  }
245
- /**
246
- * Fetch Copilot usage from GitHub internal API.
247
- * Tries multiple authentication methods to handle old/new token formats.
248
- */
249
- async function fetchCopilotUsage(authData) {
250
- const oauthToken = authData.refresh || authData.access;
251
- if (!oauthToken) {
252
- throw new Error("No OAuth token found in auth data");
253
- }
254
- const cachedAccessToken = authData.access;
255
- const tokenExpiry = authData.expires || 0;
256
- // Strategy 1: If we have a valid cached access token (from previous exchange), use it.
257
- if (cachedAccessToken && cachedAccessToken !== oauthToken && tokenExpiry > Date.now()) {
258
- const response = await fetchWithTimeout(COPILOT_INTERNAL_USER_URL, {
259
- headers: buildBearerHeaders(cachedAccessToken),
260
- });
261
- if (response.ok) {
262
- return response.json();
263
- }
264
- }
265
- // Strategy 2: Try direct call with OAuth token (newer tokens generally expect Bearer).
266
- const directBearerResponse = await fetchWithTimeout(COPILOT_INTERNAL_USER_URL, {
267
- headers: buildBearerHeaders(oauthToken),
268
- });
269
- if (directBearerResponse.ok) {
270
- return directBearerResponse.json();
271
- }
272
- // Strategy 2b: Legacy auth format.
273
- const directLegacyResponse = await fetchWithTimeout(COPILOT_INTERNAL_USER_URL, {
274
- headers: buildLegacyTokenHeaders(oauthToken),
275
- });
276
- if (directLegacyResponse.ok) {
277
- return directLegacyResponse.json();
278
- }
279
- // Strategy 3: Exchange OAuth token for Copilot session token (new auth flow).
280
- const copilotToken = await exchangeForCopilotToken(oauthToken);
281
- if (!copilotToken) {
282
- const errorText = await directLegacyResponse.text();
283
- throw new Error(`GitHub Copilot quota unavailable: ${errorText.slice(0, 160)}`);
284
- }
285
- const exchangedResponse = await fetchWithTimeout(COPILOT_INTERNAL_USER_URL, {
286
- headers: buildBearerHeaders(copilotToken),
287
- });
288
- if (!exchangedResponse.ok) {
289
- const errorText = await exchangedResponse.text();
290
- throw new Error(`GitHub API error ${exchangedResponse.status}: ${errorText.slice(0, 160)}`);
291
- }
292
- return exchangedResponse.json();
519
+ function toEnterpriseUsageResultFromBilling(params) {
520
+ const premiumItems = getPremiumUsageItems(params.response, { allowEmpty: true });
521
+ return {
522
+ success: true,
523
+ mode: "enterprise_usage",
524
+ enterprise: params.enterprise,
525
+ organization: params.organization,
526
+ username: params.username,
527
+ period: getBillingResponsePeriod(params.response, params.billingPeriod),
528
+ used: sumUsedUnits(premiumItems),
529
+ resetTimeIso: getApproxNextResetIso(),
530
+ };
531
+ }
532
+ function getOAuthTokenCandidates(auth) {
533
+ return dedupeStrings([auth.access, auth.refresh]);
534
+ }
535
+ function toQuotaError(message) {
536
+ return { success: false, error: message };
293
537
  }
294
- // =============================================================================
295
- // Export
296
- // =============================================================================
297
538
  /**
298
- * Query GitHub Copilot premium requests quota
539
+ * Query GitHub Copilot premium request usage.
299
540
  *
300
- * @returns Quota result, error, or null if not configured
541
+ * PAT configuration wins over OpenCode OAuth auth when both are present.
301
542
  */
302
543
  export async function queryCopilotQuota() {
303
- // Strategy 1: Try public billing API with user's fine-grained PAT.
304
- const quotaConfig = readQuotaConfig();
305
- if (quotaConfig) {
544
+ const pat = readQuotaConfigWithMeta();
545
+ if (pat.state === "invalid") {
546
+ return toQuotaError(`Invalid copilot-quota-token.json: ${pat.error ?? "unknown error"}${pat.selectedPath ? ` (${pat.selectedPath})` : ""}`);
547
+ }
548
+ if (pat.state === "valid" && pat.config) {
549
+ const resolvedTarget = resolvePatBillingTarget(pat.config);
550
+ if (!resolvedTarget.target) {
551
+ return toQuotaError(resolvedTarget.error ?? "Unable to resolve Copilot billing scope.");
552
+ }
553
+ const tokenCompatibilityError = validatePatTargetCompatibility(resolvedTarget.target, pat.tokenKind);
554
+ if (tokenCompatibilityError) {
555
+ return toQuotaError(tokenCompatibilityError);
556
+ }
306
557
  try {
307
- const billing = await fetchPublicBillingUsage(quotaConfig);
308
- return toQuotaResultFromBilling(billing, quotaConfig.tier);
558
+ const { response, billingPeriod } = await fetchPremiumRequestUsage({
559
+ token: pat.config.token,
560
+ target: resolvedTarget.target,
561
+ });
562
+ if (resolvedTarget.target.scope === "organization") {
563
+ return toOrganizationUsageResultFromBilling({
564
+ response,
565
+ organization: resolvedTarget.target.organization,
566
+ username: resolvedTarget.target.username,
567
+ billingPeriod: billingPeriod ?? resolvedTarget.target.billingPeriod,
568
+ });
569
+ }
570
+ if (resolvedTarget.target.scope === "enterprise") {
571
+ return toEnterpriseUsageResultFromBilling({
572
+ response,
573
+ enterprise: resolvedTarget.target.enterprise,
574
+ organization: resolvedTarget.target.organization,
575
+ username: resolvedTarget.target.username,
576
+ billingPeriod: billingPeriod ?? resolvedTarget.target.billingPeriod,
577
+ });
578
+ }
579
+ return toUserQuotaResultFromBilling(response, pat.config.tier);
309
580
  }
310
- catch (err) {
311
- return {
312
- success: false,
313
- error: err instanceof Error ? err.message : String(err),
314
- };
581
+ catch (error) {
582
+ return toQuotaError(error instanceof Error ? error.message : String(error));
315
583
  }
316
584
  }
317
- // Strategy 2: Best-effort internal API using OpenCode auth.
318
- const auth = await readCopilotAuth();
585
+ const authData = await readAuthFile();
586
+ const { auth } = selectCopilotAuth(authData);
319
587
  if (!auth) {
320
- return null; // Not configured
588
+ return null;
321
589
  }
322
- try {
323
- const data = await fetchCopilotUsage(auth);
324
- const premium = data.quota_snapshots.premium_interactions;
325
- if (!premium) {
326
- return {
327
- success: false,
328
- error: "No premium quota data",
329
- };
590
+ const tokenCandidates = getOAuthTokenCandidates(auth);
591
+ if (tokenCandidates.length === 0) {
592
+ return null;
593
+ }
594
+ let lastError = null;
595
+ for (const token of tokenCandidates) {
596
+ try {
597
+ const { response } = await fetchPremiumRequestUsage({
598
+ token,
599
+ target: { scope: "user" },
600
+ });
601
+ return toUserQuotaResultFromBilling(response);
330
602
  }
331
- if (premium.unlimited) {
332
- return {
333
- success: true,
334
- used: 0,
335
- total: -1, // Indicate unlimited
336
- percentRemaining: 100,
337
- resetTimeIso: data.quota_reset_date,
338
- };
603
+ catch (error) {
604
+ lastError = error instanceof Error ? error.message : String(error);
339
605
  }
340
- const total = premium.entitlement;
341
- const used = total - premium.remaining;
342
- const percentRemaining = Math.round(premium.percent_remaining);
343
- return {
344
- success: true,
345
- used,
346
- total,
347
- percentRemaining,
348
- resetTimeIso: data.quota_reset_date,
349
- };
350
- }
351
- catch (err) {
352
- return {
353
- success: false,
354
- error: err instanceof Error ? err.message : String(err),
355
- };
356
606
  }
607
+ return toQuotaError(lastError ??
608
+ "Copilot billing usage could not be fetched from OpenCode auth. Configure copilot-quota-token.json to provide an explicit tier and PAT.");
357
609
  }
358
- /**
359
- * Format Copilot quota for toast display
360
- *
361
- * @param result - Copilot quota result
362
- * @returns Formatted string like "Copilot 229/300 (24%)" or null
363
- */
364
610
  export function formatCopilotQuota(result) {
365
- if (!result) {
611
+ if (!result || !result.success) {
366
612
  return null;
367
613
  }
368
- if (!result.success) {
369
- return null;
614
+ if (result.mode === "organization_usage") {
615
+ const details = [`${result.used} used`, formatBillingPeriod(result.period)];
616
+ if (result.username) {
617
+ details.push(`user=${result.username}`);
618
+ }
619
+ return `Copilot Org (${result.organization}) ${details.join(" | ")}`;
370
620
  }
371
- if (result.total === -1) {
372
- return "Copilot Unlimited";
621
+ if (result.mode === "enterprise_usage") {
622
+ const details = [`${result.used} used`, formatBillingPeriod(result.period)];
623
+ if (result.organization) {
624
+ details.push(`org=${result.organization}`);
625
+ }
626
+ if (result.username) {
627
+ details.push(`user=${result.username}`);
628
+ }
629
+ return `Copilot Enterprise (${result.enterprise}) ${details.join(" | ")}`;
373
630
  }
374
631
  const percentUsed = 100 - result.percentRemaining;
375
632
  return `Copilot ${result.used}/${result.total} (${percentUsed}%)`;