@slkiser/opencode-quota 2.4.1 → 2.5.1

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 (56) hide show
  1. package/README.md +187 -232
  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 +24 -18
  6. package/dist/lib/copilot.d.ts.map +1 -1
  7. package/dist/lib/copilot.js +472 -350
  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-utils.d.ts +5 -0
  13. package/dist/lib/format-utils.d.ts.map +1 -1
  14. package/dist/lib/format-utils.js +11 -0
  15. package/dist/lib/format-utils.js.map +1 -1
  16. package/dist/lib/format.js.map +1 -1
  17. package/dist/lib/grouped-entry-normalization.d.ts +7 -0
  18. package/dist/lib/grouped-entry-normalization.d.ts.map +1 -0
  19. package/dist/lib/grouped-entry-normalization.js +50 -0
  20. package/dist/lib/grouped-entry-normalization.js.map +1 -0
  21. package/dist/lib/grouped-header-format.d.ts +5 -0
  22. package/dist/lib/grouped-header-format.d.ts.map +1 -0
  23. package/dist/lib/grouped-header-format.js +16 -0
  24. package/dist/lib/grouped-header-format.js.map +1 -0
  25. package/dist/lib/quota-command-format.d.ts +8 -3
  26. package/dist/lib/quota-command-format.d.ts.map +1 -1
  27. package/dist/lib/quota-command-format.js +24 -46
  28. package/dist/lib/quota-command-format.js.map +1 -1
  29. package/dist/lib/quota-stats-format.d.ts +1 -0
  30. package/dist/lib/quota-stats-format.d.ts.map +1 -1
  31. package/dist/lib/quota-stats-format.js +5 -1
  32. package/dist/lib/quota-stats-format.js.map +1 -1
  33. package/dist/lib/quota-status.d.ts +1 -0
  34. package/dist/lib/quota-status.d.ts.map +1 -1
  35. package/dist/lib/quota-status.js +55 -42
  36. package/dist/lib/quota-status.js.map +1 -1
  37. package/dist/lib/toast-format-grouped.d.ts +1 -9
  38. package/dist/lib/toast-format-grouped.d.ts.map +1 -1
  39. package/dist/lib/toast-format-grouped.js +8 -25
  40. package/dist/lib/toast-format-grouped.js.map +1 -1
  41. package/dist/lib/types.d.ts +53 -37
  42. package/dist/lib/types.d.ts.map +1 -1
  43. package/dist/lib/types.js.map +1 -1
  44. package/dist/plugin.d.ts.map +1 -1
  45. package/dist/plugin.js +26 -12
  46. package/dist/plugin.js.map +1 -1
  47. package/dist/providers/copilot.d.ts.map +1 -1
  48. package/dist/providers/copilot.js +69 -8
  49. package/dist/providers/copilot.js.map +1 -1
  50. package/dist/providers/openai.d.ts.map +1 -1
  51. package/dist/providers/openai.js.map +1 -1
  52. package/dist/providers/qwen-code.d.ts.map +1 -1
  53. package/dist/providers/qwen-code.js.map +1 -1
  54. package/dist/providers/zai.d.ts.map +1 -1
  55. package/dist/providers/zai.js.map +1 -1
  56. package/package.json +1 -1
@@ -1,119 +1,172 @@
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
  */
9
+ import { existsSync, readFileSync } from "fs";
10
+ import { join } from "path";
11
11
  import { fetchWithTimeout } from "./http.js";
12
12
  import { readAuthFile } from "./opencode-auth.js";
13
13
  import { getOpencodeRuntimeDirCandidates } from "./opencode-runtime-paths.js";
14
- import { existsSync, readFileSync } from "fs";
15
- import { join } from "path";
16
- // =============================================================================
17
- // Constants
18
- // =============================================================================
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}`;
15
+ const GITHUB_API_VERSION = "2022-11-28";
27
16
  const COPILOT_QUOTA_CONFIG_FILENAME = "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",
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) {
41
- return {
42
- Accept: "application/json",
43
- Authorization: `Bearer ${token}`,
44
- ...COPILOT_HEADERS,
45
- };
46
- }
47
- function buildLegacyTokenHeaders(token) {
48
- return {
49
- Accept: "application/json",
50
- Authorization: `token ${token}`,
51
- ...COPILOT_HEADERS,
52
- };
53
- }
54
- function classifyPatTokenKind(token) {
55
- const trimmed = token.trim();
56
- if (trimmed.startsWith("github_pat_"))
57
- return "github_pat";
58
- if (trimmed.startsWith("ghp_"))
59
- return "ghp";
60
- return "other";
61
- }
62
- function dedupePaths(paths) {
25
+ function dedupeStrings(values) {
63
26
  const out = [];
64
27
  const seen = new Set();
65
- for (const path of paths) {
66
- if (!path)
28
+ for (const value of values) {
29
+ const trimmed = value?.trim();
30
+ if (!trimmed || seen.has(trimmed))
67
31
  continue;
68
- if (seen.has(path))
69
- continue;
70
- seen.add(path);
71
- out.push(path);
32
+ seen.add(trimmed);
33
+ out.push(trimmed);
72
34
  }
73
35
  return out;
74
36
  }
75
- export function getCopilotPatConfigCandidatePaths() {
76
- const candidates = getOpencodeRuntimeDirCandidates();
77
- return dedupePaths(candidates.configDirs.map((configDir) => join(configDir, COPILOT_QUOTA_CONFIG_FILENAME)));
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";
78
47
  }
79
- function buildGitHubRestHeaders(token, scheme) {
48
+ function getCurrentBillingPeriod(now = new Date()) {
80
49
  return {
81
- Accept: "application/vnd.github+json",
82
- Authorization: scheme === "bearer" ? `Bearer ${token}` : `token ${token}`,
83
- "X-GitHub-Api-Version": "2022-11-28",
84
- "User-Agent": USER_AGENT,
50
+ year: now.getFullYear(),
51
+ month: now.getMonth() + 1,
85
52
  };
86
53
  }
87
- function preferredSchemesForToken(token) {
88
- const t = token.trim();
89
- // Fine-grained PATs usually prefer Bearer.
90
- if (t.startsWith("github_pat_")) {
91
- return ["bearer", "token"];
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));
92
60
  }
93
- // Classic PATs historically prefer legacy `token`.
94
- if (t.startsWith("ghp_")) {
95
- return ["token", "bearer"];
61
+ if (options?.organization) {
62
+ searchParams.set("organization", options.organization);
96
63
  }
97
- return ["bearer", "token"];
64
+ if (options?.username) {
65
+ searchParams.set("user", options.username);
66
+ }
67
+ return searchParams;
98
68
  }
99
- async function readGitHubRestErrorMessage(response) {
100
- const text = await response.text();
101
- try {
102
- const parsed = JSON.parse(text);
103
- if (parsed && typeof parsed === "object") {
104
- const obj = parsed;
105
- const msg = typeof obj.message === "string" ? obj.message : null;
106
- const doc = typeof obj.documentation_url === "string" ? obj.documentation_url : null;
107
- if (msg && doc)
108
- return `${msg} (${doc})`;
109
- if (msg)
110
- return msg;
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
+ };
111
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
+ };
112
113
  }
113
- catch {
114
- // ignore
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
+ };
115
140
  }
116
- return text.slice(0, 160);
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
+ }
147
+ return {
148
+ target: {
149
+ scope: "user",
150
+ username: config.username,
151
+ },
152
+ };
153
+ }
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)));
117
170
  }
118
171
  function validateQuotaConfig(raw) {
119
172
  if (!raw || typeof raw !== "object") {
@@ -121,34 +174,48 @@ function validateQuotaConfig(raw) {
121
174
  }
122
175
  const obj = raw;
123
176
  const token = typeof obj.token === "string" ? obj.token.trim() : "";
124
- const tierRaw = typeof obj.tier === "string" ? obj.tier.trim() : "";
125
- const usernameRaw = obj.username;
177
+ const tier = typeof obj.tier === "string" ? obj.tier.trim() : "";
126
178
  if (!token) {
127
179
  return { config: null, error: "Missing required string field: token" };
128
180
  }
129
181
  const validTiers = ["free", "pro", "pro+", "business", "enterprise"];
130
- if (!validTiers.includes(tierRaw)) {
182
+ if (!validTiers.includes(tier)) {
131
183
  return {
132
184
  config: null,
133
185
  error: "Invalid tier; expected one of: free, pro, pro+, business, enterprise",
134
186
  };
135
187
  }
188
+ const usernameRaw = obj.username;
136
189
  let username;
137
190
  if (usernameRaw != null) {
138
- if (typeof usernameRaw !== "string") {
191
+ if (typeof usernameRaw !== "string" || !usernameRaw.trim()) {
139
192
  return { config: null, error: "username must be a non-empty string when provided" };
140
193
  }
141
- const trimmed = usernameRaw.trim();
142
- if (!trimmed) {
143
- return { config: null, error: "username must be a non-empty string when provided" };
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" };
144
209
  }
145
- username = trimmed;
210
+ enterprise = enterpriseRaw.trim();
146
211
  }
147
212
  return {
148
213
  config: {
149
214
  token,
150
- tier: tierRaw,
215
+ tier: tier,
151
216
  username,
217
+ organization,
218
+ enterprise,
152
219
  },
153
220
  };
154
221
  }
@@ -177,46 +244,17 @@ export function readQuotaConfigWithMeta() {
177
244
  tokenKind: classifyPatTokenKind(validated.config.token),
178
245
  };
179
246
  }
180
- catch (err) {
181
- const msg = err instanceof Error ? err.message : String(err);
247
+ catch (error) {
182
248
  return {
183
249
  state: "invalid",
184
250
  checkedPaths,
185
251
  selectedPath: path,
186
- error: msg,
252
+ error: error instanceof Error ? error.message : String(error),
187
253
  };
188
254
  }
189
255
  }
190
- return {
191
- state: "absent",
192
- checkedPaths,
193
- };
194
- }
195
- async function fetchGitHubRestJsonOnce(url, token, scheme) {
196
- const response = await fetchWithTimeout(url, {
197
- headers: buildGitHubRestHeaders(token, scheme),
198
- });
199
- if (response.ok) {
200
- return { ok: true, status: response.status, data: (await response.json()) };
201
- }
202
- return {
203
- ok: false,
204
- status: response.status,
205
- message: await readGitHubRestErrorMessage(response),
206
- };
207
- }
208
- /**
209
- * Read Copilot auth data from auth.json
210
- *
211
- * Tries multiple key names to handle different OpenCode versions/configs.
212
- */
213
- async function readCopilotAuth() {
214
- const authData = await readAuthFile();
215
- return selectCopilotAuth(authData).auth;
256
+ return { state: "absent", checkedPaths };
216
257
  }
217
- /**
218
- * Select Copilot OAuth auth entry from auth.json-shaped data.
219
- */
220
258
  function selectCopilotAuth(authData) {
221
259
  if (!authData) {
222
260
  return { auth: null, keyName: null };
@@ -225,286 +263,370 @@ function selectCopilotAuth(authData) {
225
263
  ["github-copilot", authData["github-copilot"]],
226
264
  ["copilot", authData.copilot],
227
265
  ["copilot-chat", authData["copilot-chat"]],
266
+ ["github-copilot-chat", authData["github-copilot-chat"]],
228
267
  ];
229
- for (const [keyName, candidate] of candidates) {
230
- if (!candidate)
231
- continue;
232
- if (candidate.type !== "oauth")
268
+ for (const [keyName, auth] of candidates) {
269
+ if (!auth || auth.type !== "oauth")
233
270
  continue;
234
- if (!candidate.refresh)
271
+ if (!auth.access && !auth.refresh)
235
272
  continue;
236
- return { auth: candidate, keyName };
273
+ return { auth, keyName };
237
274
  }
238
275
  return { auth: null, keyName: null };
239
276
  }
240
277
  export function getCopilotQuotaAuthDiagnostics(authData) {
241
278
  const pat = readQuotaConfigWithMeta();
242
279
  const { auth, keyName } = selectCopilotAuth(authData);
243
- const oauthConfigured = Boolean(auth);
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";
244
285
  let effectiveSource = "none";
245
- if (pat.state === "valid") {
286
+ if (patBlocksOAuth)
246
287
  effectiveSource = "pat";
247
- }
248
- else if (oauthConfigured) {
288
+ else if (auth)
249
289
  effectiveSource = "oauth";
250
- }
290
+ const billingTarget = pat.state === "valid" ? resolvedPatTarget.target : !patBlocksOAuth && auth ? { scope: "user" } : null;
291
+ const billingMode = getBillingModeForTarget(billingTarget);
251
292
  return {
252
293
  pat,
253
294
  oauth: {
254
- configured: oauthConfigured,
295
+ configured: Boolean(auth),
255
296
  keyName,
256
297
  hasRefreshToken: Boolean(auth?.refresh),
257
298
  hasAccessToken: Boolean(auth?.access),
258
299
  },
259
300
  effectiveSource,
260
- override: pat.state === "valid" && oauthConfigured ? "pat_overrides_oauth" : "none",
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,
261
316
  };
262
317
  }
263
- function computePercentRemainingFromUsed(params) {
264
- const { used, total } = params;
265
- if (!Number.isFinite(total) || total <= 0)
266
- return 0;
267
- if (!Number.isFinite(used) || used <= 0)
268
- return 100;
269
- const usedPct = Math.max(0, Math.min(100, Math.ceil((used / total) * 100)));
270
- return 100 - usedPct;
318
+ function buildGitHubRestHeaders(token, scheme) {
319
+ return {
320
+ Accept: "application/vnd.github+json",
321
+ Authorization: scheme === "bearer" ? `Bearer ${token}` : `token ${token}`,
322
+ "X-GitHub-Api-Version": GITHUB_API_VERSION,
323
+ "User-Agent": USER_AGENT,
324
+ };
271
325
  }
272
- const COPILOT_PLAN_LIMITS = {
273
- free: 50,
274
- pro: 300,
275
- "pro+": 1500,
276
- business: 300,
277
- enterprise: 1000,
278
- };
279
- function getApproxNextResetIso(nowMs = Date.now()) {
280
- const now = new Date(nowMs);
281
- const year = now.getUTCFullYear();
282
- const month = now.getUTCMonth();
283
- return new Date(Date.UTC(year, month + 1, 1, 0, 0, 0, 0)).toISOString();
284
- }
285
- async function fetchPublicBillingUsage(config) {
286
- const token = config.token;
287
- const schemes = preferredSchemesForToken(token);
288
- // Prefer authenticated-user endpoint; fall back to /users/{username} for older behavior.
289
- const urls = [`${GITHUB_API_BASE_URL}/user/settings/billing/premium_request/usage`];
290
- if (config.username) {
291
- urls.push(`${GITHUB_API_BASE_URL}/users/${config.username}/settings/billing/premium_request/usage`);
292
- }
293
- for (const url of urls) {
294
- let lastUnauthorized = null;
295
- for (const scheme of schemes) {
296
- const res = await fetchGitHubRestJsonOnce(url, token, scheme);
297
- if (res.ok) {
298
- return res.data;
299
- }
300
- if (res.status === 401) {
301
- lastUnauthorized = { status: res.status, message: res.message };
302
- continue; // retry with alternate scheme
303
- }
304
- // If /user/... isn't supported for some reason, fall back to /users/... when available.
305
- if (res.status === 404 && url.includes("/user/")) {
306
- break;
307
- }
308
- throw new Error(`GitHub API error ${res.status}: ${res.message}`);
326
+ function preferredSchemesForToken(token) {
327
+ if (token.startsWith("ghp_")) {
328
+ return ["token", "bearer"];
329
+ }
330
+ return ["bearer", "token"];
331
+ }
332
+ async function readGitHubRestErrorMessage(response) {
333
+ const text = await response.text();
334
+ try {
335
+ const parsed = JSON.parse(text);
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})`;
309
340
  }
310
- if (lastUnauthorized) {
311
- throw new Error(`GitHub API error ${lastUnauthorized.status}: ${lastUnauthorized.message} (token rejected; verify PAT and permissions)`);
341
+ if (message) {
342
+ return message;
312
343
  }
313
344
  }
314
- throw new Error("GitHub API error 404: Not Found");
345
+ catch {
346
+ // ignore parse failures
347
+ }
348
+ return text.slice(0, 160);
315
349
  }
316
- function toQuotaResultFromBilling(data, tier) {
317
- const items = Array.isArray(data.usageItems) ? data.usageItems : [];
318
- const premiumItems = items.filter((item) => item &&
319
- typeof item === "object" &&
320
- typeof item.sku === "string" &&
321
- (item.sku === "Copilot Premium Request" || item.sku.includes("Premium")));
322
- const used = premiumItems.reduce((sum, item) => sum + (item.grossQuantity || 0), 0);
323
- const limits = premiumItems
324
- .map((item) => item.limit)
325
- .filter((n) => typeof n === "number" && n > 0);
326
- // Prefer API-provided limits when available (more future-proof than hardcoding).
327
- const total = limits.length ? Math.max(...limits) : COPILOT_PLAN_LIMITS[tier];
328
- if (!total || total <= 0) {
329
- throw new Error(`Unsupported Copilot tier: ${tier}`);
350
+ async function fetchGitHubRestJsonOnce(url, token, scheme) {
351
+ const response = await fetchWithTimeout(url, {
352
+ headers: buildGitHubRestHeaders(token, scheme),
353
+ });
354
+ if (response.ok) {
355
+ return { ok: true, status: response.status, data: (await response.json()) };
330
356
  }
331
- const normalizedUsed = Math.max(0, used);
332
- const percentRemaining = computePercentRemainingFromUsed({ used: normalizedUsed, total });
333
357
  return {
334
- success: true,
335
- used: normalizedUsed,
336
- total,
337
- percentRemaining,
338
- resetTimeIso: getApproxNextResetIso(),
358
+ ok: false,
359
+ status: response.status,
360
+ message: await readGitHubRestErrorMessage(response),
339
361
  };
340
362
  }
341
- async function exchangeForCopilotToken(oauthToken) {
342
- try {
343
- const response = await fetchWithTimeout(COPILOT_TOKEN_EXCHANGE_URL, {
344
- headers: {
345
- Accept: "application/json",
346
- Authorization: `Bearer ${oauthToken}`,
347
- ...COPILOT_HEADERS,
348
- },
349
- });
350
- if (!response.ok) {
351
- 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;
352
377
  }
353
- const tokenData = (await response.json());
354
- if (!tokenData || typeof tokenData.token !== "string")
355
- return null;
356
- return tokenData.token;
378
+ throw new Error(`GitHub API error ${result.status}: ${result.message}`);
357
379
  }
358
- catch {
359
- return null;
380
+ if (unauthorized) {
381
+ throw new Error(`GitHub API error ${unauthorized.status}: ${unauthorized.message} (token rejected while resolving username)`);
360
382
  }
383
+ throw new Error("Unable to resolve GitHub username for Copilot billing request");
361
384
  }
362
- /**
363
- * Fetch Copilot usage from GitHub internal API.
364
- * Tries multiple authentication methods to handle old/new token formats.
365
- */
366
- async function fetchCopilotUsage(authData) {
367
- const oauthToken = authData.refresh || authData.access;
368
- if (!oauthToken) {
369
- throw new Error("No OAuth token found in auth data");
370
- }
371
- const cachedAccessToken = authData.access;
372
- const tokenExpiry = authData.expires || 0;
373
- // Strategy 1: If we have a valid cached access token (from previous exchange), use it.
374
- if (cachedAccessToken && cachedAccessToken !== oauthToken && tokenExpiry > Date.now()) {
375
- const response = await fetchWithTimeout(COPILOT_INTERNAL_USER_URL, {
376
- headers: buildBearerHeaders(cachedAccessToken),
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,
377
398
  });
378
- if (response.ok) {
379
- return response.json();
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)),
380
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
+ };
419
+ }
420
+ if (result.status === 401) {
421
+ unauthorized = { status: result.status, message: result.message };
422
+ continue;
423
+ }
424
+ throw new Error(`GitHub API error ${result.status}: ${result.message}`);
381
425
  }
382
- // Strategy 2: Try direct call with OAuth token (newer tokens generally expect Bearer).
383
- const directBearerResponse = await fetchWithTimeout(COPILOT_INTERNAL_USER_URL, {
384
- headers: buildBearerHeaders(oauthToken),
385
- });
386
- if (directBearerResponse.ok) {
387
- return directBearerResponse.json();
426
+ if (unauthorized) {
427
+ throw new Error(`GitHub API error ${unauthorized.status}: ${unauthorized.message} (token rejected for Copilot premium request usage)`);
388
428
  }
389
- // Strategy 2b: Legacy auth format.
390
- const directLegacyResponse = await fetchWithTimeout(COPILOT_INTERNAL_USER_URL, {
391
- headers: buildLegacyTokenHeaders(oauthToken),
429
+ throw new Error("Unable to fetch Copilot premium request usage");
430
+ }
431
+ function getApproxNextResetIso(nowMs = Date.now()) {
432
+ const now = new Date(nowMs);
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");
392
456
  });
393
- if (directLegacyResponse.ok) {
394
- return directLegacyResponse.json();
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".`);
395
460
  }
396
- // Strategy 3: Exchange OAuth token for Copilot session token (new auth flow).
397
- const copilotToken = await exchangeForCopilotToken(oauthToken);
398
- if (!copilotToken) {
399
- const errorText = await directLegacyResponse.text();
400
- throw new Error(`GitHub Copilot quota unavailable: ${errorText.slice(0, 160)}`);
461
+ if (premiumItems.length === 0 && options?.allowEmpty) {
462
+ return [];
401
463
  }
402
- const exchangedResponse = await fetchWithTimeout(COPILOT_INTERNAL_USER_URL, {
403
- headers: buildBearerHeaders(copilotToken),
404
- });
405
- if (!exchangedResponse.ok) {
406
- const errorText = await exchangedResponse.text();
407
- throw new Error(`GitHub API error ${exchangedResponse.status}: ${errorText.slice(0, 160)}`);
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")}`;
481
+ }
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
492
+ .map((item) => item.limit)
493
+ .filter((limit) => typeof limit === "number" && limit > 0);
494
+ const total = apiLimits.length > 0 ? Math.max(...apiLimits) : fallbackTier ? COPILOT_PLAN_LIMITS[fallbackTier] : undefined;
495
+ if (!total || total <= 0) {
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.");
408
497
  }
409
- return exchangedResponse.json();
498
+ return {
499
+ success: true,
500
+ mode: "user_quota",
501
+ used,
502
+ total,
503
+ percentRemaining: computePercentRemainingFromUsed({ used, total }),
504
+ resetTimeIso: getApproxNextResetIso(),
505
+ };
506
+ }
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
+ };
518
+ }
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 };
410
537
  }
411
- // =============================================================================
412
- // Export
413
- // =============================================================================
414
538
  /**
415
- * Query GitHub Copilot premium requests quota
539
+ * Query GitHub Copilot premium request usage.
416
540
  *
417
- * @returns Quota result, error, or null if not configured
541
+ * PAT configuration wins over OpenCode OAuth auth when both are present.
418
542
  */
419
543
  export async function queryCopilotQuota() {
420
- // Strategy 1: Try public billing API with user's fine-grained PAT.
421
- const quotaConfigRead = readQuotaConfigWithMeta();
422
- if (quotaConfigRead.state === "valid" && quotaConfigRead.config) {
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
+ }
423
557
  try {
424
- const billing = await fetchPublicBillingUsage(quotaConfigRead.config);
425
- return toQuotaResultFromBilling(billing, quotaConfigRead.config.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);
426
580
  }
427
- catch (err) {
428
- return {
429
- success: false,
430
- error: err instanceof Error ? err.message : String(err),
431
- };
581
+ catch (error) {
582
+ return toQuotaError(error instanceof Error ? error.message : String(error));
432
583
  }
433
584
  }
434
- // Strategy 2: Best-effort internal API using OpenCode auth.
435
- const auth = await readCopilotAuth();
585
+ const authData = await readAuthFile();
586
+ const { auth } = selectCopilotAuth(authData);
436
587
  if (!auth) {
437
- return null; // Not configured
588
+ return null;
438
589
  }
439
- try {
440
- const data = await fetchCopilotUsage(auth);
441
- const premium = data.quota_snapshots.premium_interactions;
442
- if (!premium) {
443
- return {
444
- success: false,
445
- error: "No premium quota data",
446
- };
447
- }
448
- if (premium.unlimited) {
449
- return {
450
- success: true,
451
- used: 0,
452
- total: -1, // Indicate unlimited
453
- percentRemaining: 100,
454
- resetTimeIso: data.quota_reset_date,
455
- };
456
- }
457
- const total = premium.entitlement;
458
- if (!Number.isFinite(total) || total <= 0) {
459
- return {
460
- success: false,
461
- error: "Invalid premium quota entitlement",
462
- };
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);
463
602
  }
464
- const remainingRaw = typeof premium.remaining === "number"
465
- ? premium.remaining
466
- : typeof premium.quota_remaining === "number"
467
- ? premium.quota_remaining
468
- : NaN;
469
- if (!Number.isFinite(remainingRaw)) {
470
- return {
471
- success: false,
472
- error: "Invalid premium quota remaining value",
473
- };
603
+ catch (error) {
604
+ lastError = error instanceof Error ? error.message : String(error);
474
605
  }
475
- const remaining = Math.max(0, Math.min(total, remainingRaw));
476
- const used = Math.max(0, total - remaining);
477
- const percentRemaining = computePercentRemainingFromUsed({ used, total });
478
- return {
479
- success: true,
480
- used,
481
- total,
482
- percentRemaining,
483
- resetTimeIso: data.quota_reset_date,
484
- };
485
- }
486
- catch (err) {
487
- return {
488
- success: false,
489
- error: err instanceof Error ? err.message : String(err),
490
- };
491
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.");
492
609
  }
493
- /**
494
- * Format Copilot quota for toast display
495
- *
496
- * @param result - Copilot quota result
497
- * @returns Formatted string like "Copilot 229/300 (24%)" or null
498
- */
499
610
  export function formatCopilotQuota(result) {
500
- if (!result) {
611
+ if (!result || !result.success) {
501
612
  return null;
502
613
  }
503
- if (!result.success) {
504
- 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(" | ")}`;
505
620
  }
506
- if (result.total === -1) {
507
- 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(" | ")}`;
508
630
  }
509
631
  const percentUsed = 100 - result.percentRemaining;
510
632
  return `Copilot ${result.used}/${result.total} (${percentUsed}%)`;