@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.
- package/README.md +192 -226
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/copilot.d.ts +48 -16
- package/dist/lib/copilot.d.ts.map +1 -1
- package/dist/lib/copilot.js +540 -283
- package/dist/lib/copilot.js.map +1 -1
- package/dist/lib/entries.d.ts +11 -3
- package/dist/lib/entries.d.ts.map +1 -1
- package/dist/lib/entries.js.map +1 -1
- package/dist/lib/format.js.map +1 -1
- package/dist/lib/grouped-entry-normalization.d.ts +7 -0
- package/dist/lib/grouped-entry-normalization.d.ts.map +1 -0
- package/dist/lib/grouped-entry-normalization.js +50 -0
- package/dist/lib/grouped-entry-normalization.js.map +1 -0
- package/dist/lib/grouped-header-format.d.ts +5 -0
- package/dist/lib/grouped-header-format.d.ts.map +1 -0
- package/dist/lib/grouped-header-format.js +16 -0
- package/dist/lib/grouped-header-format.js.map +1 -0
- package/dist/lib/quota-command-format.d.ts +2 -3
- package/dist/lib/quota-command-format.d.ts.map +1 -1
- package/dist/lib/quota-command-format.js +14 -43
- package/dist/lib/quota-command-format.js.map +1 -1
- package/dist/lib/quota-status.d.ts.map +1 -1
- package/dist/lib/quota-status.js +51 -0
- package/dist/lib/quota-status.js.map +1 -1
- package/dist/lib/toast-format-grouped.d.ts +1 -9
- package/dist/lib/toast-format-grouped.d.ts.map +1 -1
- package/dist/lib/toast-format-grouped.js +8 -25
- package/dist/lib/toast-format-grouped.js.map +1 -1
- package/dist/lib/types.d.ts +56 -39
- package/dist/lib/types.d.ts.map +1 -1
- package/dist/lib/types.js.map +1 -1
- package/dist/providers/copilot.d.ts.map +1 -1
- package/dist/providers/copilot.js +69 -8
- package/dist/providers/copilot.js.map +1 -1
- package/dist/providers/openai.d.ts.map +1 -1
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/qwen-code.d.ts.map +1 -1
- package/dist/providers/qwen-code.js.map +1 -1
- package/dist/providers/zai.d.ts.map +1 -1
- package/dist/providers/zai.js.map +1 -1
- package/package.json +1 -1
package/dist/lib/copilot.js
CHANGED
|
@@ -1,72 +1,330 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* GitHub Copilot
|
|
2
|
+
* GitHub Copilot premium request usage fetcher.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
|
|
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
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
148
|
+
target: {
|
|
149
|
+
scope: "user",
|
|
150
|
+
username: config.username,
|
|
151
|
+
},
|
|
45
152
|
};
|
|
46
153
|
}
|
|
47
|
-
function
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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":
|
|
322
|
+
"X-GitHub-Api-Version": GITHUB_API_VERSION,
|
|
59
323
|
"User-Agent": USER_AGENT,
|
|
60
324
|
};
|
|
61
325
|
}
|
|
62
326
|
function preferredSchemesForToken(token) {
|
|
63
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
383
|
+
throw new Error("Unable to resolve GitHub username for Copilot billing request");
|
|
123
384
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
-
|
|
152
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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
|
|
200
|
-
const
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
const
|
|
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((
|
|
209
|
-
|
|
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(
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
|
539
|
+
* Query GitHub Copilot premium request usage.
|
|
299
540
|
*
|
|
300
|
-
*
|
|
541
|
+
* PAT configuration wins over OpenCode OAuth auth when both are present.
|
|
301
542
|
*/
|
|
302
543
|
export async function queryCopilotQuota() {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
|
308
|
-
|
|
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 (
|
|
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
|
-
|
|
318
|
-
const auth =
|
|
585
|
+
const authData = await readAuthFile();
|
|
586
|
+
const { auth } = selectCopilotAuth(authData);
|
|
319
587
|
if (!auth) {
|
|
320
|
-
return null;
|
|
588
|
+
return null;
|
|
321
589
|
}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
332
|
-
|
|
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 (
|
|
369
|
-
|
|
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.
|
|
372
|
-
|
|
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}%)`;
|