@slkiser/opencode-quota 2.4.1 → 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 +189 -234
- 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 +24 -18
- package/dist/lib/copilot.d.ts.map +1 -1
- package/dist/lib/copilot.js +472 -350
- 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 +30 -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 +53 -37
- 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,119 +1,172 @@
|
|
|
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
|
*/
|
|
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
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
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
|
|
66
|
-
|
|
28
|
+
for (const value of values) {
|
|
29
|
+
const trimmed = value?.trim();
|
|
30
|
+
if (!trimmed || seen.has(trimmed))
|
|
67
31
|
continue;
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
48
|
+
function getCurrentBillingPeriod(now = new Date()) {
|
|
80
49
|
return {
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
return ["token", "bearer"];
|
|
61
|
+
if (options?.organization) {
|
|
62
|
+
searchParams.set("organization", options.organization);
|
|
96
63
|
}
|
|
97
|
-
|
|
64
|
+
if (options?.username) {
|
|
65
|
+
searchParams.set("user", options.username);
|
|
66
|
+
}
|
|
67
|
+
return searchParams;
|
|
98
68
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
210
|
+
enterprise = enterpriseRaw.trim();
|
|
146
211
|
}
|
|
147
212
|
return {
|
|
148
213
|
config: {
|
|
149
214
|
token,
|
|
150
|
-
tier:
|
|
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 (
|
|
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:
|
|
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,
|
|
230
|
-
if (!
|
|
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 (!
|
|
271
|
+
if (!auth.access && !auth.refresh)
|
|
235
272
|
continue;
|
|
236
|
-
return { auth
|
|
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
|
|
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 (
|
|
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:
|
|
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:
|
|
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
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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 (
|
|
311
|
-
|
|
341
|
+
if (message) {
|
|
342
|
+
return message;
|
|
312
343
|
}
|
|
313
344
|
}
|
|
314
|
-
|
|
345
|
+
catch {
|
|
346
|
+
// ignore parse failures
|
|
347
|
+
}
|
|
348
|
+
return text.slice(0, 160);
|
|
315
349
|
}
|
|
316
|
-
function
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
percentRemaining,
|
|
338
|
-
resetTimeIso: getApproxNextResetIso(),
|
|
358
|
+
ok: false,
|
|
359
|
+
status: response.status,
|
|
360
|
+
message: await readGitHubRestErrorMessage(response),
|
|
339
361
|
};
|
|
340
362
|
}
|
|
341
|
-
async function
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
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
|
-
|
|
359
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
379
|
-
|
|
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
|
-
|
|
383
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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 (
|
|
394
|
-
|
|
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
|
-
|
|
397
|
-
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
|
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
|
|
539
|
+
* Query GitHub Copilot premium request usage.
|
|
416
540
|
*
|
|
417
|
-
*
|
|
541
|
+
* PAT configuration wins over OpenCode OAuth auth when both are present.
|
|
418
542
|
*/
|
|
419
543
|
export async function queryCopilotQuota() {
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
|
425
|
-
|
|
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 (
|
|
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
|
-
|
|
435
|
-
const auth =
|
|
585
|
+
const authData = await readAuthFile();
|
|
586
|
+
const { auth } = selectCopilotAuth(authData);
|
|
436
587
|
if (!auth) {
|
|
437
|
-
return null;
|
|
588
|
+
return null;
|
|
438
589
|
}
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
465
|
-
?
|
|
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 (
|
|
504
|
-
|
|
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.
|
|
507
|
-
|
|
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}%)`;
|