@slkiser/opencode-quota 3.11.1 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/README.md +103 -57
  2. package/dist/bin/opencode-quota.d.ts.map +1 -1
  3. package/dist/bin/opencode-quota.js +24 -7
  4. package/dist/bin/opencode-quota.js.map +1 -1
  5. package/dist/index.d.ts +2 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +2 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/lib/anthropic.d.ts.map +1 -1
  10. package/dist/lib/anthropic.js +106 -3
  11. package/dist/lib/anthropic.js.map +1 -1
  12. package/dist/lib/atomic-json.d.ts +1 -0
  13. package/dist/lib/atomic-json.d.ts.map +1 -1
  14. package/dist/lib/atomic-json.js +5 -2
  15. package/dist/lib/atomic-json.js.map +1 -1
  16. package/dist/lib/cli-show.d.ts.map +1 -1
  17. package/dist/lib/cli-show.js +11 -7
  18. package/dist/lib/cli-show.js.map +1 -1
  19. package/dist/lib/command-surfaces.d.ts +10 -0
  20. package/dist/lib/command-surfaces.d.ts.map +1 -0
  21. package/dist/lib/command-surfaces.js +9 -0
  22. package/dist/lib/command-surfaces.js.map +1 -0
  23. package/dist/lib/config-file-utils.d.ts +5 -0
  24. package/dist/lib/config-file-utils.d.ts.map +1 -1
  25. package/dist/lib/config-file-utils.js +20 -3
  26. package/dist/lib/config-file-utils.js.map +1 -1
  27. package/dist/lib/config.d.ts +5 -3
  28. package/dist/lib/config.d.ts.map +1 -1
  29. package/dist/lib/config.js +124 -32
  30. package/dist/lib/config.js.map +1 -1
  31. package/dist/lib/copilot.d.ts +19 -12
  32. package/dist/lib/copilot.d.ts.map +1 -1
  33. package/dist/lib/copilot.js +462 -566
  34. package/dist/lib/copilot.js.map +1 -1
  35. package/dist/lib/display-sanitize.d.ts.map +1 -1
  36. package/dist/lib/display-sanitize.js +13 -0
  37. package/dist/lib/display-sanitize.js.map +1 -1
  38. package/dist/lib/entries.d.ts +53 -4
  39. package/dist/lib/entries.d.ts.map +1 -1
  40. package/dist/lib/entries.js.map +1 -1
  41. package/dist/lib/init-installer.d.ts +11 -1
  42. package/dist/lib/init-installer.d.ts.map +1 -1
  43. package/dist/lib/init-installer.js +542 -154
  44. package/dist/lib/init-installer.js.map +1 -1
  45. package/dist/lib/openai.d.ts +9 -12
  46. package/dist/lib/openai.d.ts.map +1 -1
  47. package/dist/lib/openai.js +69 -40
  48. package/dist/lib/openai.js.map +1 -1
  49. package/dist/lib/opencode-config-editor.d.ts +53 -0
  50. package/dist/lib/opencode-config-editor.d.ts.map +1 -0
  51. package/dist/lib/opencode-config-editor.js +274 -0
  52. package/dist/lib/opencode-config-editor.js.map +1 -0
  53. package/dist/lib/opencode-config-providers.d.ts +18 -0
  54. package/dist/lib/opencode-config-providers.d.ts.map +1 -1
  55. package/dist/lib/opencode-config-providers.js +75 -4
  56. package/dist/lib/opencode-config-providers.js.map +1 -1
  57. package/dist/lib/opencode-runtime-paths.d.ts.map +1 -1
  58. package/dist/lib/opencode-runtime-paths.js +10 -3
  59. package/dist/lib/opencode-runtime-paths.js.map +1 -1
  60. package/dist/lib/opencode-storage.d.ts +10 -0
  61. package/dist/lib/opencode-storage.d.ts.map +1 -1
  62. package/dist/lib/opencode-storage.js +75 -0
  63. package/dist/lib/opencode-storage.js.map +1 -1
  64. package/dist/lib/provider-add-command.d.ts +35 -0
  65. package/dist/lib/provider-add-command.d.ts.map +1 -0
  66. package/dist/lib/provider-add-command.js +185 -0
  67. package/dist/lib/provider-add-command.js.map +1 -0
  68. package/dist/lib/provider-add.d.ts +21 -0
  69. package/dist/lib/provider-add.d.ts.map +1 -0
  70. package/dist/lib/provider-add.js +210 -0
  71. package/dist/lib/provider-add.js.map +1 -0
  72. package/dist/lib/provider-metadata.d.ts +1 -1
  73. package/dist/lib/provider-metadata.d.ts.map +1 -1
  74. package/dist/lib/provider-metadata.js +11 -5
  75. package/dist/lib/provider-metadata.js.map +1 -1
  76. package/dist/lib/quota-command-format.d.ts +2 -0
  77. package/dist/lib/quota-command-format.d.ts.map +1 -1
  78. package/dist/lib/quota-command-format.js +94 -26
  79. package/dist/lib/quota-command-format.js.map +1 -1
  80. package/dist/lib/quota-dialog-commands.d.ts.map +1 -1
  81. package/dist/lib/quota-dialog-commands.js +8 -4
  82. package/dist/lib/quota-dialog-commands.js.map +1 -1
  83. package/dist/lib/quota-export-types.d.ts +48 -27
  84. package/dist/lib/quota-export-types.d.ts.map +1 -1
  85. package/dist/lib/quota-export-types.js +0 -7
  86. package/dist/lib/quota-export-types.js.map +1 -1
  87. package/dist/lib/quota-export.d.ts.map +1 -1
  88. package/dist/lib/quota-export.js +63 -16
  89. package/dist/lib/quota-export.js.map +1 -1
  90. package/dist/lib/quota-providers-local.d.ts +55 -0
  91. package/dist/lib/quota-providers-local.d.ts.map +1 -0
  92. package/dist/lib/quota-providers-local.js +313 -0
  93. package/dist/lib/quota-providers-local.js.map +1 -0
  94. package/dist/lib/quota-providers-remote.d.ts +23 -0
  95. package/dist/lib/quota-providers-remote.d.ts.map +1 -0
  96. package/dist/lib/quota-providers-remote.js +318 -0
  97. package/dist/lib/quota-providers-remote.js.map +1 -0
  98. package/dist/lib/quota-providers.d.ts +77 -0
  99. package/dist/lib/quota-providers.d.ts.map +1 -0
  100. package/dist/lib/quota-providers.js +630 -0
  101. package/dist/lib/quota-providers.js.map +1 -0
  102. package/dist/lib/quota-render-data.d.ts +1 -0
  103. package/dist/lib/quota-render-data.d.ts.map +1 -1
  104. package/dist/lib/quota-render-data.js +35 -16
  105. package/dist/lib/quota-render-data.js.map +1 -1
  106. package/dist/lib/quota-runtime-context.d.ts.map +1 -1
  107. package/dist/lib/quota-runtime-context.js +3 -4
  108. package/dist/lib/quota-runtime-context.js.map +1 -1
  109. package/dist/lib/quota-state.d.ts +5 -2
  110. package/dist/lib/quota-state.d.ts.map +1 -1
  111. package/dist/lib/quota-state.js +268 -51
  112. package/dist/lib/quota-state.js.map +1 -1
  113. package/dist/lib/quota-status.d.ts +2 -1
  114. package/dist/lib/quota-status.d.ts.map +1 -1
  115. package/dist/lib/quota-status.js +247 -69
  116. package/dist/lib/quota-status.js.map +1 -1
  117. package/dist/lib/qwen-local-quota.d.ts +12 -11
  118. package/dist/lib/qwen-local-quota.d.ts.map +1 -1
  119. package/dist/lib/qwen-local-quota.js +82 -87
  120. package/dist/lib/qwen-local-quota.js.map +1 -1
  121. package/dist/lib/scoped-update.d.ts +63 -0
  122. package/dist/lib/scoped-update.d.ts.map +1 -0
  123. package/dist/lib/scoped-update.js +311 -0
  124. package/dist/lib/scoped-update.js.map +1 -0
  125. package/dist/lib/tui-runtime.d.ts +2 -0
  126. package/dist/lib/tui-runtime.d.ts.map +1 -1
  127. package/dist/lib/tui-runtime.js +33 -9
  128. package/dist/lib/tui-runtime.js.map +1 -1
  129. package/dist/lib/types.d.ts +51 -15
  130. package/dist/lib/types.d.ts.map +1 -1
  131. package/dist/lib/types.js +2 -1
  132. package/dist/lib/types.js.map +1 -1
  133. package/dist/plugin.d.ts.map +1 -1
  134. package/dist/plugin.js +75 -100
  135. package/dist/plugin.js.map +1 -1
  136. package/dist/providers/alibaba-coding-plan.d.ts.map +1 -1
  137. package/dist/providers/alibaba-coding-plan.js +35 -4
  138. package/dist/providers/alibaba-coding-plan.js.map +1 -1
  139. package/dist/providers/anthropic.d.ts.map +1 -1
  140. package/dist/providers/anthropic.js +13 -1
  141. package/dist/providers/anthropic.js.map +1 -1
  142. package/dist/providers/chutes.d.ts.map +1 -1
  143. package/dist/providers/chutes.js +6 -0
  144. package/dist/providers/chutes.js.map +1 -1
  145. package/dist/providers/copilot.d.ts +2 -1
  146. package/dist/providers/copilot.d.ts.map +1 -1
  147. package/dist/providers/copilot.js +143 -60
  148. package/dist/providers/copilot.js.map +1 -1
  149. package/dist/providers/cursor.d.ts.map +1 -1
  150. package/dist/providers/cursor.js +30 -1
  151. package/dist/providers/cursor.js.map +1 -1
  152. package/dist/providers/deepseek.d.ts.map +1 -1
  153. package/dist/providers/deepseek.js +13 -1
  154. package/dist/providers/deepseek.js.map +1 -1
  155. package/dist/providers/google-agy.d.ts.map +1 -1
  156. package/dist/providers/google-agy.js +7 -1
  157. package/dist/providers/google-agy.js.map +1 -1
  158. package/dist/providers/google-antigravity.d.ts.map +1 -1
  159. package/dist/providers/google-antigravity.js +10 -2
  160. package/dist/providers/google-antigravity.js.map +1 -1
  161. package/dist/providers/google-gemini-cli.d.ts.map +1 -1
  162. package/dist/providers/google-gemini-cli.js +8 -2
  163. package/dist/providers/google-gemini-cli.js.map +1 -1
  164. package/dist/providers/kimi-code.d.ts.map +1 -1
  165. package/dist/providers/kimi-code.js +6 -0
  166. package/dist/providers/kimi-code.js.map +1 -1
  167. package/dist/providers/minimax-coding-plan.d.ts.map +1 -1
  168. package/dist/providers/minimax-coding-plan.js +12 -4
  169. package/dist/providers/minimax-coding-plan.js.map +1 -1
  170. package/dist/providers/nanogpt.d.ts.map +1 -1
  171. package/dist/providers/nanogpt.js +19 -1
  172. package/dist/providers/nanogpt.js.map +1 -1
  173. package/dist/providers/ollama-cloud.d.ts.map +1 -1
  174. package/dist/providers/ollama-cloud.js +12 -0
  175. package/dist/providers/ollama-cloud.js.map +1 -1
  176. package/dist/providers/openai.d.ts.map +1 -1
  177. package/dist/providers/openai.js +7 -0
  178. package/dist/providers/openai.js.map +1 -1
  179. package/dist/providers/opencode-go.d.ts.map +1 -1
  180. package/dist/providers/opencode-go.js +9 -1
  181. package/dist/providers/opencode-go.js.map +1 -1
  182. package/dist/providers/quota-providers.d.ts +6 -0
  183. package/dist/providers/quota-providers.d.ts.map +1 -0
  184. package/dist/providers/quota-providers.js +276 -0
  185. package/dist/providers/quota-providers.js.map +1 -0
  186. package/dist/providers/qwen-code.d.ts.map +1 -1
  187. package/dist/providers/qwen-code.js +29 -3
  188. package/dist/providers/qwen-code.js.map +1 -1
  189. package/dist/providers/registry.d.ts.map +1 -1
  190. package/dist/providers/registry.js +2 -0
  191. package/dist/providers/registry.js.map +1 -1
  192. package/dist/providers/result-helpers.d.ts +2 -1
  193. package/dist/providers/result-helpers.d.ts.map +1 -1
  194. package/dist/providers/result-helpers.js +6 -1
  195. package/dist/providers/result-helpers.js.map +1 -1
  196. package/dist/providers/synthetic.d.ts.map +1 -1
  197. package/dist/providers/synthetic.js +7 -1
  198. package/dist/providers/synthetic.js.map +1 -1
  199. package/dist/providers/zai.d.ts.map +1 -1
  200. package/dist/providers/zai.js +7 -1
  201. package/dist/providers/zai.js.map +1 -1
  202. package/dist/providers/zhipu.d.ts.map +1 -1
  203. package/dist/providers/zhipu.js +7 -1
  204. package/dist/providers/zhipu.js.map +1 -1
  205. package/dist/tui.d.ts.map +1 -1
  206. package/dist/tui.js +148 -122
  207. package/dist/tui.tsx +122 -114
  208. package/package.json +29 -21
@@ -1,8 +1,9 @@
1
1
  /**
2
- * GitHub Copilot premium request usage fetcher.
2
+ * GitHub Copilot accounting fetcher.
3
3
  *
4
- * The plugin uses GitHub billing APIs for PAT-backed usage checks and
5
- * `GET /copilot_internal/user` for OAuth-backed personal quota checks.
4
+ * Current usage is read from GitHub's public AI Credit billing reports.
5
+ * Legacy premium-request reports are available only when explicitly selected
6
+ * for an eligible Copilot Pro or Pro+ annual plan.
6
7
  */
7
8
  import { existsSync, readFileSync } from "fs";
8
9
  import { join } from "path";
@@ -11,89 +12,17 @@ import { fetchWithTimeout } from "./http.js";
11
12
  import { readAuthFile } from "./opencode-auth.js";
12
13
  import { getOpencodeRuntimeDirCandidates } from "./opencode-runtime-paths.js";
13
14
  const GITHUB_API_BASE_URL = "https://api.github.com";
14
- const COPILOT_INTERNAL_USER_URL = `${GITHUB_API_BASE_URL}/copilot_internal/user`;
15
- const GITHUB_API_VERSION = "2022-11-28";
15
+ const GITHUB_API_VERSION = "2026-03-10";
16
16
  const COPILOT_QUOTA_CONFIG_FILENAME = "copilot-quota-token.json";
17
17
  const USER_AGENT = "opencode-quota/copilot-billing";
18
- function getNestedValue(source, path) {
19
- let current = source;
20
- for (const key of path) {
21
- if (!current || typeof current !== "object" || !(key in current)) {
22
- return undefined;
23
- }
24
- current = current[key];
25
- }
26
- return current;
27
- }
28
- function getFirstNestedValue(source, paths) {
29
- for (const path of paths) {
30
- const value = getNestedValue(source, path);
31
- if (value !== undefined) {
32
- return value;
33
- }
34
- }
35
- return undefined;
36
- }
37
- function coerceFiniteNumber(value) {
38
- if (typeof value === "number" && Number.isFinite(value)) {
39
- return value;
40
- }
41
- if (typeof value === "string") {
42
- const trimmed = value.trim();
43
- if (!trimmed)
44
- return undefined;
45
- const parsed = Number(trimmed);
46
- if (Number.isFinite(parsed)) {
47
- return parsed;
48
- }
49
- }
50
- return undefined;
51
- }
52
- function coerceNonEmptyString(value) {
53
- if (typeof value !== "string") {
54
- return undefined;
55
- }
56
- const trimmed = value.trim();
57
- return trimmed || undefined;
58
- }
59
- function getFirstNestedNumber(source, paths) {
60
- return coerceFiniteNumber(getFirstNestedValue(source, paths));
61
- }
62
- function getFirstNestedString(source, paths) {
63
- return coerceNonEmptyString(getFirstNestedValue(source, paths));
64
- }
65
- function getFirstNestedBoolean(source, paths) {
66
- const value = getFirstNestedValue(source, paths);
67
- return typeof value === "boolean" ? value : undefined;
68
- }
69
- function normalizeExplicitPercentRemaining(value) {
70
- if (value === undefined || !Number.isFinite(value))
71
- return undefined;
72
- return Math.min(100, Math.floor(value));
73
- }
74
- function normalizeCopilotTier(value) {
75
- const normalized = value?.trim().toLowerCase();
76
- if (!normalized)
77
- return undefined;
78
- if (normalized === "free")
79
- return "free";
80
- if (normalized === "pro")
81
- return "pro";
82
- if (normalized === "pro+" || normalized === "pro_plus" || normalized === "pro-plus") {
83
- return "pro+";
84
- }
85
- if (normalized === "business")
86
- return "business";
87
- if (normalized === "enterprise")
88
- return "enterprise";
89
- return undefined;
90
- }
91
- const COPILOT_PLAN_LIMITS = {
92
- free: 50,
18
+ const PERSONAL_AI_CREDIT_TOTALS = {
19
+ pro: 1500,
20
+ "pro+": 7000,
21
+ max: 20000,
22
+ };
23
+ const LEGACY_PREMIUM_REQUEST_TOTALS = {
93
24
  pro: 300,
94
25
  "pro+": 1500,
95
- business: 300,
96
- enterprise: 1000,
97
26
  };
98
27
  function dedupeStrings(values) {
99
28
  const out = [];
@@ -120,59 +49,44 @@ function classifyPatTokenKind(token) {
120
49
  }
121
50
  function getCurrentBillingPeriod(now = new Date()) {
122
51
  return {
123
- year: now.getFullYear(),
124
- month: now.getMonth() + 1,
52
+ year: now.getUTCFullYear(),
53
+ month: now.getUTCMonth() + 1,
125
54
  };
126
55
  }
127
- function buildBillingPeriodQueryParams(period, options) {
128
- const searchParams = new URLSearchParams();
129
- searchParams.set("year", String(period.year));
130
- searchParams.set("month", String(period.month));
131
- if (options?.includeDay && typeof period.day === "number") {
132
- searchParams.set("day", String(period.day));
133
- }
134
- if (options?.organization) {
135
- searchParams.set("organization", options.organization);
136
- }
137
- if (options?.username) {
138
- searchParams.set("user", options.username);
139
- }
140
- return searchParams;
141
- }
142
- function getBillingModeForTarget(target) {
143
- if (!target)
144
- return "none";
145
- if (target.scope === "organization")
146
- return "organization_usage";
147
- if (target.scope === "enterprise")
148
- return "enterprise_usage";
149
- return "user_quota";
56
+ function getApproxNextResetIso(nowMs = Date.now()) {
57
+ const now = new Date(nowMs);
58
+ return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)).toISOString();
150
59
  }
151
- function getBillingScopeForTarget(target) {
152
- return target?.scope ?? "none";
60
+ function formatBillingPeriod(period) {
61
+ return `${period.year}-${String(period.month).padStart(2, "0")}`;
153
62
  }
154
- function getRemainingTotalsStateForTarget(target) {
155
- if (!target)
156
- return "unavailable";
157
- if (target.scope === "organization")
158
- return "not_available_from_org_usage";
159
- if (target.scope === "enterprise")
160
- return "not_available_from_enterprise_usage";
161
- return "available";
63
+ function computePercentRemainingFromUsed(used, total) {
64
+ if (!Number.isFinite(used) || used < 0 || !Number.isFinite(total) || total <= 0) {
65
+ return undefined;
66
+ }
67
+ return Math.max(0, Math.min(100, Math.floor(((total - used) * 100) / total)));
162
68
  }
163
69
  function resolvePatBillingTarget(config) {
164
70
  const billingPeriod = getCurrentBillingPeriod();
165
- if (config.tier === "business") {
166
- if (config.enterprise) {
71
+ if (config.billingModel === "legacy_premium_requests") {
72
+ if (config.tier !== "pro" && config.tier !== "pro+") {
167
73
  return {
168
74
  target: null,
169
- error: 'Copilot business usage is organization-scoped. Remove "enterprise" and keep "organization" in copilot-quota-token.json.',
75
+ error: "Legacy premium-request billing is only available to Copilot Pro or Pro+ subscribers on an existing annual plan that remained on legacy billing after June 1, 2026.",
170
76
  };
171
77
  }
172
- if (!config.organization) {
78
+ if (config.organization || config.enterprise) {
79
+ return {
80
+ target: null,
81
+ error: "Legacy premium-request billing is personal-only. Remove organization and enterprise from copilot-quota-token.json.",
82
+ };
83
+ }
84
+ }
85
+ if (config.tier === "business") {
86
+ if (!config.organization || config.enterprise) {
173
87
  return {
174
88
  target: null,
175
- error: 'Copilot business usage requires an organization-scoped billing report. Add "organization": "your-org-slug" to copilot-quota-token.json.',
89
+ error: 'Copilot Business AI Credit usage requires "organization" and does not accept "enterprise" in copilot-quota-token.json.',
176
90
  };
177
91
  }
178
92
  return {
@@ -208,32 +122,34 @@ function resolvePatBillingTarget(config) {
208
122
  }
209
123
  return {
210
124
  target: null,
211
- 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.',
125
+ error: 'Copilot Enterprise AI Credit usage requires "enterprise" or "organization" in copilot-quota-token.json.',
212
126
  };
213
127
  }
214
128
  if (config.organization || config.enterprise) {
215
129
  return {
216
130
  target: null,
217
- error: `Copilot ${config.tier} usage is user-scoped. Remove "organization"/"enterprise" from copilot-quota-token.json or switch to a managed tier.`,
131
+ error: `Copilot ${config.tier} AI Credit usage is personal. Remove organization and enterprise from copilot-quota-token.json.`,
218
132
  };
219
133
  }
220
134
  return {
221
135
  target: {
222
136
  scope: "user",
223
137
  username: config.username,
138
+ billingPeriod,
224
139
  },
225
140
  };
226
141
  }
227
142
  function validatePatTargetCompatibility(target, tokenKind) {
228
- if (target.scope !== "enterprise" || !tokenKind) {
143
+ if (!tokenKind)
229
144
  return null;
145
+ if (target.scope === "user" && tokenKind === "ghs") {
146
+ return ("GitHub's personal AI Credit report supports GitHub App user access tokens, " +
147
+ "but not GitHub App installation access tokens. Use a GitHub App user token, fine-grained PAT with Plan (read), or supported classic credential.");
230
148
  }
231
- if (tokenKind === "github_pat") {
232
- return ("GitHub's enterprise premium usage endpoint does not support fine-grained personal access tokens. " +
233
- "Use a classic PAT or another supported non-fine-grained token for enterprise billing.");
234
- }
235
- if (tokenKind === "ghu" || tokenKind === "ghs") {
236
- return ("GitHub's enterprise premium usage endpoint does not support GitHub App user or installation access tokens.");
149
+ if (target.scope === "enterprise" &&
150
+ (tokenKind === "github_pat" || tokenKind === "ghu" || tokenKind === "ghs")) {
151
+ return ("GitHub's enterprise billing reports do not support fine-grained PATs or GitHub App access tokens. " +
152
+ "Use a classic PAT held by an enterprise admin or billing manager.");
237
153
  }
238
154
  return null;
239
155
  }
@@ -247,50 +163,63 @@ function validateQuotaConfig(raw) {
247
163
  }
248
164
  const obj = raw;
249
165
  const token = typeof obj.token === "string" ? obj.token.trim() : "";
250
- const tier = typeof obj.tier === "string" ? obj.tier.trim() : "";
166
+ const tier = typeof obj.tier === "string" ? obj.tier.trim().toLowerCase() : "";
167
+ const billingModel = obj.billingModel === undefined
168
+ ? "ai_credits"
169
+ : typeof obj.billingModel === "string"
170
+ ? obj.billingModel.trim()
171
+ : "";
251
172
  if (!token) {
252
173
  return { config: null, error: "Missing required string field: token" };
253
174
  }
254
- const validTiers = ["free", "pro", "pro+", "business", "enterprise"];
175
+ const validTiers = [
176
+ "free",
177
+ "student",
178
+ "pro",
179
+ "pro+",
180
+ "max",
181
+ "business",
182
+ "enterprise",
183
+ ];
255
184
  if (!validTiers.includes(tier)) {
256
185
  return {
257
186
  config: null,
258
- error: "Invalid tier; expected one of: free, pro, pro+, business, enterprise",
187
+ error: "Invalid tier; expected one of: free, student, pro, pro+, max, business, enterprise",
259
188
  };
260
189
  }
261
- const usernameRaw = obj.username;
262
- let username;
263
- if (usernameRaw != null) {
264
- if (typeof usernameRaw !== "string" || !usernameRaw.trim()) {
265
- return { config: null, error: "username must be a non-empty string when provided" };
266
- }
267
- username = usernameRaw.trim();
268
- }
269
- const organizationRaw = obj.organization;
270
- let organization;
271
- if (organizationRaw != null) {
272
- if (typeof organizationRaw !== "string" || !organizationRaw.trim()) {
273
- return { config: null, error: "organization must be a non-empty string when provided" };
274
- }
275
- organization = organizationRaw.trim();
190
+ if (billingModel !== "ai_credits" && billingModel !== "legacy_premium_requests") {
191
+ return {
192
+ config: null,
193
+ error: "Invalid billingModel; expected ai_credits or legacy_premium_requests",
194
+ };
276
195
  }
277
- const enterpriseRaw = obj.enterprise;
278
- let enterprise;
279
- if (enterpriseRaw != null) {
280
- if (typeof enterpriseRaw !== "string" || !enterpriseRaw.trim()) {
281
- return { config: null, error: "enterprise must be a non-empty string when provided" };
196
+ const readOptionalString = (key) => {
197
+ const value = obj[key];
198
+ if (value == null)
199
+ return undefined;
200
+ if (typeof value !== "string" || !value.trim()) {
201
+ throw new Error(`${key} must be a non-empty string when provided`);
282
202
  }
283
- enterprise = enterpriseRaw.trim();
284
- }
285
- return {
286
- config: {
203
+ return value.trim();
204
+ };
205
+ try {
206
+ const config = {
287
207
  token,
288
208
  tier: tier,
289
- username,
290
- organization,
291
- enterprise,
292
- },
293
- };
209
+ billingModel,
210
+ username: readOptionalString("username"),
211
+ organization: readOptionalString("organization"),
212
+ enterprise: readOptionalString("enterprise"),
213
+ };
214
+ const resolved = resolvePatBillingTarget(config);
215
+ if (!resolved.target) {
216
+ return { config: null, error: resolved.error };
217
+ }
218
+ return { config };
219
+ }
220
+ catch (error) {
221
+ return { config: null, error: error instanceof Error ? error.message : String(error) };
222
+ }
294
223
  }
295
224
  export function readQuotaConfigWithMeta() {
296
225
  const checkedPaths = getCopilotPatConfigCandidatePaths();
@@ -298,8 +227,7 @@ export function readQuotaConfigWithMeta() {
298
227
  if (!existsSync(path))
299
228
  continue;
300
229
  try {
301
- const content = readFileSync(path, "utf-8");
302
- const parsed = JSON.parse(content);
230
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
303
231
  const validated = validateQuotaConfig(parsed);
304
232
  if (!validated.config) {
305
233
  return {
@@ -329,9 +257,8 @@ export function readQuotaConfigWithMeta() {
329
257
  return { state: "absent", checkedPaths };
330
258
  }
331
259
  function selectCopilotAuth(authData) {
332
- if (!authData) {
260
+ if (!authData)
333
261
  return { auth: null, keyName: null };
334
- }
335
262
  const candidates = [
336
263
  ["github-copilot", authData["github-copilot"]],
337
264
  ["copilot", authData.copilot],
@@ -347,72 +274,83 @@ function selectCopilotAuth(authData) {
347
274
  }
348
275
  return { auth: null, keyName: null };
349
276
  }
277
+ function getRemainingTotalsState(target, config) {
278
+ if (!target || !config)
279
+ return "unavailable";
280
+ if (target.scope === "organization")
281
+ return "not_available_from_org_usage";
282
+ if (target.scope === "enterprise")
283
+ return "not_available_from_enterprise_usage";
284
+ const total = config.billingModel === "legacy_premium_requests"
285
+ ? LEGACY_PREMIUM_REQUEST_TOTALS[config.tier]
286
+ : PERSONAL_AI_CREDIT_TOTALS[config.tier];
287
+ return total ? "available" : "value_only_without_denominator";
288
+ }
350
289
  export function getCopilotQuotaAuthDiagnostics(authData) {
351
290
  const pat = readQuotaConfigWithMeta();
352
291
  const { auth, keyName } = selectCopilotAuth(authData);
353
- const resolvedPatTarget = pat.state === "valid" && pat.config ? resolvePatBillingTarget(pat.config) : { target: null };
354
- const tokenCompatibilityError = pat.state === "valid" && resolvedPatTarget.target
355
- ? validatePatTargetCompatibility(resolvedPatTarget.target, pat.tokenKind)
292
+ const resolved = pat.state === "valid" && pat.config
293
+ ? resolvePatBillingTarget(pat.config)
294
+ : { target: null };
295
+ const compatibilityError = resolved.target && pat.state === "valid"
296
+ ? validatePatTargetCompatibility(resolved.target, pat.tokenKind)
356
297
  : null;
357
298
  const patBlocksOAuth = pat.state !== "absent";
358
- let effectiveSource = "none";
359
- if (patBlocksOAuth)
360
- effectiveSource = "pat";
361
- else if (auth)
362
- effectiveSource = "oauth";
363
- const billingTarget = pat.state === "valid"
364
- ? resolvedPatTarget.target
365
- : !patBlocksOAuth && auth
366
- ? { scope: "user" }
367
- : null;
368
- const billingMode = getBillingModeForTarget(billingTarget);
369
- const oauthHasAccessToken = Boolean(auth?.access?.trim());
370
- const quotaApi = effectiveSource === "pat"
371
- ? pat.state === "valid"
372
- ? "github_billing_api"
373
- : "none"
374
- : effectiveSource === "oauth" && oauthHasAccessToken
375
- ? "copilot_internal_user"
299
+ const effectiveSource = patBlocksOAuth
300
+ ? "pat"
301
+ : auth
302
+ ? "oauth"
376
303
  : "none";
304
+ const billingModel = pat.config?.billingModel ?? "ai_credits";
305
+ const quotaApi = pat.state !== "valid"
306
+ ? "none"
307
+ : billingModel === "legacy_premium_requests"
308
+ ? "github_legacy_premium_request_api"
309
+ : "github_ai_credit_api";
377
310
  return {
378
311
  pat,
379
312
  oauth: {
380
313
  configured: Boolean(auth),
381
314
  keyName,
382
315
  hasRefreshToken: Boolean(auth?.refresh),
383
- hasAccessToken: oauthHasAccessToken,
316
+ hasAccessToken: Boolean(auth?.access?.trim()),
384
317
  },
385
318
  effectiveSource,
386
319
  override: patBlocksOAuth && auth ? "pat_overrides_oauth" : "none",
387
320
  quotaApi,
388
- billingMode,
389
- billingScope: getBillingScopeForTarget(billingTarget),
390
- billingApiAccessLikely: effectiveSource === "pat"
391
- ? Boolean(billingTarget) && !resolvedPatTarget.error && !tokenCompatibilityError
392
- : quotaApi === "copilot_internal_user",
393
- remainingTotalsState: getRemainingTotalsStateForTarget(billingTarget),
394
- queryPeriod: billingTarget && billingTarget.scope !== "user"
395
- ? billingTarget.billingPeriod
396
- : undefined,
397
- usernameFilter: pat.state === "valid" ? pat.config?.username : undefined,
398
- billingTargetError: pat.state === "valid" ? resolvedPatTarget.error : undefined,
399
- tokenCompatibilityError: tokenCompatibilityError ?? undefined,
321
+ billingMode: resolved.target?.scope === "organization"
322
+ ? "organization_usage"
323
+ : resolved.target?.scope === "enterprise"
324
+ ? "enterprise_usage"
325
+ : resolved.target?.scope === "user"
326
+ ? "user_quota"
327
+ : "none",
328
+ billingScope: resolved.target?.scope ?? "none",
329
+ billingApiAccessLikely: pat.state === "valid" && Boolean(resolved.target) && !resolved.error && !compatibilityError,
330
+ remainingTotalsState: getRemainingTotalsState(resolved.target, pat.config),
331
+ queryPeriod: resolved.target?.billingPeriod,
332
+ usernameFilter: pat.config?.username,
333
+ billingTargetError: resolved.error,
334
+ tokenCompatibilityError: compatibilityError ?? undefined,
335
+ billingModel,
336
+ budgetApi: billingModel !== "ai_credits"
337
+ ? "not_available"
338
+ : resolved.target?.scope === "organization"
339
+ ? "organization_budgets"
340
+ : resolved.target?.scope === "enterprise"
341
+ ? "enterprise_budgets"
342
+ : "not_available",
343
+ oauthAccountingState: auth ? "not_supported_by_public_billing_api" : "not_configured",
400
344
  };
401
345
  }
402
- function buildGitHubRestHeaders(token, scheme) {
346
+ function buildGitHubRestHeaders(token) {
403
347
  return {
404
348
  Accept: "application/vnd.github+json",
405
- Authorization: scheme === "bearer" ? `Bearer ${token}` : `token ${token}`,
349
+ Authorization: `Bearer ${token}`,
406
350
  "X-GitHub-Api-Version": GITHUB_API_VERSION,
407
351
  "User-Agent": USER_AGENT,
408
352
  };
409
353
  }
410
- function preferredSchemesForToken(token) {
411
- if (token.startsWith("ghp_")) {
412
- return ["token", "bearer"];
413
- }
414
- return ["bearer", "token"];
415
- }
416
354
  async function readGitHubRestErrorMessage(response) {
417
355
  const text = await response.text();
418
356
  try {
@@ -422,336 +360,325 @@ async function readGitHubRestErrorMessage(response) {
422
360
  if (message && documentationUrl) {
423
361
  return sanitizeDisplayText(`${message} (${documentationUrl})`);
424
362
  }
425
- if (message) {
363
+ if (message)
426
364
  return sanitizeDisplayText(message);
427
- }
428
365
  }
429
366
  catch {
430
- // ignore parse failures
367
+ // Fall through to a bounded plain-text snippet.
431
368
  }
432
369
  return sanitizeDisplaySnippet(text, 160);
433
370
  }
434
- async function fetchGitHubRestJsonOnce(url, token, scheme, requestTimeoutMs) {
435
- const response = await fetchWithTimeout(url, {
436
- headers: buildGitHubRestHeaders(token, scheme),
437
- }, requestTimeoutMs);
438
- if (response.ok) {
439
- return { ok: true, status: response.status, data: (await response.json()) };
371
+ async function fetchGitHubRestJson(url, token, requestTimeoutMs) {
372
+ const response = await fetchWithTimeout(url, { headers: buildGitHubRestHeaders(token) }, requestTimeoutMs);
373
+ if (!response.ok) {
374
+ const message = await readGitHubRestErrorMessage(response);
375
+ const rateLimit = response.status === 403 && response.headers.get("x-ratelimit-remaining") === "0"
376
+ ? " (GitHub API rate limit exhausted)"
377
+ : "";
378
+ throw new Error(`GitHub API error ${response.status}: ${message}${rateLimit}`);
379
+ }
380
+ try {
381
+ return (await response.json());
382
+ }
383
+ catch {
384
+ throw new Error("GitHub API returned malformed JSON");
440
385
  }
441
- return {
442
- ok: false,
443
- status: response.status,
444
- message: await readGitHubRestErrorMessage(response),
445
- };
446
386
  }
447
387
  async function resolveGitHubUsername(token, requestTimeoutMs) {
448
- const url = `${GITHUB_API_BASE_URL}/user`;
449
- let unauthorized = null;
450
- for (const scheme of preferredSchemesForToken(token)) {
451
- const result = await fetchGitHubRestJsonOnce(url, token, scheme, requestTimeoutMs);
452
- if (result.ok) {
453
- const login = result.data.login?.trim();
454
- if (login)
455
- return login;
456
- throw new Error("GitHub /user response did not include a login");
457
- }
458
- if (result.status === 401) {
459
- unauthorized = { status: result.status, message: result.message };
460
- continue;
461
- }
462
- throw new Error(`GitHub API error ${result.status}: ${result.message}`);
388
+ const response = await fetchGitHubRestJson(`${GITHUB_API_BASE_URL}/user`, token, requestTimeoutMs);
389
+ const login = response.login?.trim();
390
+ if (!login) {
391
+ throw new Error("GitHub /user response did not include a login");
463
392
  }
464
- if (unauthorized) {
465
- throw new Error(`GitHub API error ${unauthorized.status}: ${unauthorized.message} (token rejected while resolving username)`);
393
+ return login;
394
+ }
395
+ function buildBillingQuery(target) {
396
+ const query = new URLSearchParams({
397
+ year: String(target.billingPeriod.year),
398
+ month: String(target.billingPeriod.month),
399
+ });
400
+ if (target.scope !== "user" && target.username)
401
+ query.set("user", target.username);
402
+ if (target.scope === "enterprise" && target.organization) {
403
+ query.set("organization", target.organization);
466
404
  }
467
- throw new Error("Unable to resolve GitHub username for Copilot billing request");
405
+ return query;
468
406
  }
469
- function getBillingRequestUrl(target) {
407
+ function getBillingUsageUrl(target, billingModel) {
408
+ const report = billingModel === "legacy_premium_requests" ? "premium_request" : "ai_credit";
409
+ const query = buildBillingQuery(target);
470
410
  if (target.scope === "enterprise") {
471
- const base = `${GITHUB_API_BASE_URL}/enterprises/${encodeURIComponent(target.enterprise)}/settings/billing/premium_request/usage`;
472
- const searchParams = buildBillingPeriodQueryParams(target.billingPeriod, {
473
- organization: target.organization,
474
- username: target.username,
475
- });
476
- return `${base}?${searchParams.toString()}`;
411
+ return `${GITHUB_API_BASE_URL}/enterprises/${encodeURIComponent(target.enterprise)}/settings/billing/${report}/usage?${query}`;
477
412
  }
478
413
  if (target.scope === "organization") {
479
- const base = `${GITHUB_API_BASE_URL}/organizations/${encodeURIComponent(target.organization)}/settings/billing/premium_request/usage`;
480
- const searchParams = buildBillingPeriodQueryParams(target.billingPeriod, {
481
- username: target.username,
482
- });
483
- return `${base}?${searchParams.toString()}`;
414
+ return `${GITHUB_API_BASE_URL}/organizations/${encodeURIComponent(target.organization)}/settings/billing/${report}/usage?${query}`;
484
415
  }
485
- return `${GITHUB_API_BASE_URL}/users/${encodeURIComponent(target.username)}/settings/billing/premium_request/usage`;
416
+ return `${GITHUB_API_BASE_URL}/users/${encodeURIComponent(target.username)}/settings/billing/${report}/usage?${query}`;
486
417
  }
487
- async function fetchPremiumRequestUsage(params) {
488
- const requestTarget = params.target.scope === "user"
418
+ async function fetchBillingUsage(params) {
419
+ const target = params.target.scope === "user"
489
420
  ? {
490
- scope: "user",
491
- username: params.target.username ?? (await resolveGitHubUsername(params.token, params.requestTimeoutMs)),
421
+ ...params.target,
422
+ username: params.target.username ??
423
+ (await resolveGitHubUsername(params.token, params.requestTimeoutMs)),
492
424
  }
493
425
  : params.target;
494
- const url = getBillingRequestUrl(requestTarget);
495
- let unauthorized = null;
496
- for (const scheme of preferredSchemesForToken(params.token)) {
497
- const result = await fetchGitHubRestJsonOnce(url, params.token, scheme, params.requestTimeoutMs);
498
- if (result.ok) {
499
- return {
500
- response: result.data,
501
- billingPeriod: requestTarget.scope === "user" ? undefined : requestTarget.billingPeriod,
502
- };
426
+ return {
427
+ response: await fetchGitHubRestJson(getBillingUsageUrl(target, params.billingModel), params.token, params.requestTimeoutMs),
428
+ target,
429
+ };
430
+ }
431
+ function getResponsePeriod(response, fallback) {
432
+ const period = response.timePeriod ?? response.time_period;
433
+ return {
434
+ year: typeof period?.year === "number" ? period.year : fallback.year,
435
+ month: typeof period?.month === "number" ? period.month : fallback.month,
436
+ };
437
+ }
438
+ function getUsageItems(response) {
439
+ if (Array.isArray(response.usageItems))
440
+ return response.usageItems;
441
+ if (Array.isArray(response.usage_items))
442
+ return response.usage_items;
443
+ throw new Error("GitHub billing response did not include a usageItems array");
444
+ }
445
+ function readFiniteNumber(value) {
446
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
447
+ }
448
+ function isAiCreditItem(item) {
449
+ const text = [item.product, item.sku, item.unitType, item.unit_type]
450
+ .filter((value) => typeof value === "string")
451
+ .join(" ")
452
+ .toLowerCase();
453
+ return text.includes("ai credit") || text.includes("ai-credit");
454
+ }
455
+ function isPremiumRequestItem(item) {
456
+ const text = [item.product, item.sku, item.unitType, item.unit_type]
457
+ .filter((value) => typeof value === "string")
458
+ .join(" ")
459
+ .toLowerCase();
460
+ return text.includes("premium request");
461
+ }
462
+ function parseAiCreditTotals(response) {
463
+ const items = getUsageItems(response);
464
+ const matches = items.filter(isAiCreditItem);
465
+ if (matches.length === 0 && items.length > 0) {
466
+ throw new Error("GitHub billing response did not contain an AI Credit usage item");
467
+ }
468
+ let used = 0;
469
+ let includedUsed = 0;
470
+ let billedUsed = 0;
471
+ let billedAmountUsd = 0;
472
+ let hasBilledAmount = false;
473
+ for (const item of matches) {
474
+ const gross = readFiniteNumber(item.grossQuantity ?? item.gross_quantity);
475
+ const discount = readFiniteNumber(item.discountQuantity ?? item.discount_quantity);
476
+ const net = readFiniteNumber(item.netQuantity ?? item.net_quantity);
477
+ if (gross === undefined && discount === undefined && net === undefined) {
478
+ throw new Error("GitHub AI Credit usage item did not include quantity fields");
503
479
  }
504
- if (result.status === 401) {
505
- unauthorized = { status: result.status, message: result.message };
506
- continue;
480
+ const normalizedIncluded = Math.max(0, discount ?? 0);
481
+ const normalizedBilled = Math.max(0, net ?? Math.max(0, (gross ?? 0) - normalizedIncluded));
482
+ used += Math.max(0, gross ?? normalizedIncluded + normalizedBilled);
483
+ includedUsed += normalizedIncluded;
484
+ billedUsed += normalizedBilled;
485
+ const netAmount = readFiniteNumber(item.netAmount ?? item.net_amount);
486
+ if (netAmount !== undefined) {
487
+ billedAmountUsd += Math.max(0, netAmount);
488
+ hasBilledAmount = true;
507
489
  }
508
- throw new Error(`GitHub API error ${result.status}: ${result.message}`);
509
- }
510
- if (unauthorized) {
511
- throw new Error(`GitHub API error ${unauthorized.status}: ${unauthorized.message} (token rejected for Copilot premium request usage)`);
512
- }
513
- throw new Error("Unable to fetch Copilot premium request usage");
514
- }
515
- async function fetchCopilotInternalUser(token, requestTimeoutMs) {
516
- const result = await fetchGitHubRestJsonOnce(COPILOT_INTERNAL_USER_URL, token, "bearer", requestTimeoutMs);
517
- if (result.ok) {
518
- return result.data;
519
- }
520
- throw new Error(`GitHub API error ${result.status}: ${result.message}`);
521
- }
522
- function toUserQuotaResultFromCopilotInternal(response) {
523
- const totalPaths = [
524
- ["quota", "limit"],
525
- ["quota", "total"],
526
- ["monthly_quota", "limit"],
527
- ["monthly_quota", "total"],
528
- ["monthly_premium_requests", "limit"],
529
- ["monthly_premium_requests", "total"],
530
- ["premium_requests", "limit"],
531
- ["premium_requests", "total"],
532
- ["quota_snapshots", "premium_interactions", "entitlement"],
533
- ["limit"],
534
- ["total"],
535
- ["quota_limit"],
536
- ["monthly_limit"],
537
- ["included_premium_requests"],
538
- // Copilot Free tier: `monthly_quotas.chat` and `monthly_quotas.completions`
539
- ["monthly_quotas", "chat"],
540
- ["monthly_quotas", "completions"],
541
- ];
542
- const usedPaths = [
543
- ["quota", "used"],
544
- ["monthly_quota", "used"],
545
- ["monthly_premium_requests", "used"],
546
- ["premium_requests", "used"],
547
- ["used"],
548
- ["quota_used"],
549
- ["monthly_used"],
550
- ["premium_requests_used"],
551
- ];
552
- const remainingPaths = [
553
- ["quota", "remaining"],
554
- ["monthly_quota", "remaining"],
555
- ["monthly_premium_requests", "remaining"],
556
- ["premium_requests", "remaining"],
557
- ["quota_snapshots", "premium_interactions", "remaining"],
558
- ["quota_snapshots", "premium_interactions", "quota_remaining"],
559
- ["remaining"],
560
- ["quota_remaining"],
561
- ["monthly_remaining"],
562
- ["premium_requests_remaining"],
563
- // Copilot Free tier: `limited_user_quotas` tracks remaining
564
- ["limited_user_quotas", "chat"],
565
- ["limited_user_quotas", "completions"],
566
- ];
567
- const resetPaths = [
568
- ["quota", "reset_at"],
569
- ["monthly_quota", "reset_at"],
570
- ["monthly_premium_requests", "reset_at"],
571
- ["premium_requests", "reset_at"],
572
- ["reset_at"],
573
- ["quota_reset_date_utc"],
574
- ["quota_reset_date"],
575
- ["quota_reset_at"],
576
- // Copilot Free tier
577
- ["limited_user_reset_date"],
578
- ];
579
- const percentRemainingPaths = [
580
- ["quota", "percent_remaining"],
581
- ["monthly_quota", "percent_remaining"],
582
- ["monthly_premium_requests", "percent_remaining"],
583
- ["premium_requests", "percent_remaining"],
584
- ["quota_snapshots", "premium_interactions", "percent_remaining"],
585
- ["percent_remaining"],
586
- ];
587
- const unlimitedPaths = [
588
- ["quota", "unlimited"],
589
- ["monthly_quota", "unlimited"],
590
- ["monthly_premium_requests", "unlimited"],
591
- ["premium_requests", "unlimited"],
592
- ["quota_snapshots", "premium_interactions", "unlimited"],
593
- ["unlimited"],
594
- ];
595
- const tierPaths = [
596
- ["plan", "type"],
597
- ["plan", "name"],
598
- ["plan"],
599
- ["copilot_plan"],
600
- ["subscription_plan"],
601
- ["sku"],
602
- ];
603
- let total = getFirstNestedNumber(response, totalPaths);
604
- let used = getFirstNestedNumber(response, usedPaths);
605
- const remaining = getFirstNestedNumber(response, remainingPaths);
606
- const unlimited = getFirstNestedBoolean(response, unlimitedPaths) === true;
607
- const explicitPercentRemaining = normalizeExplicitPercentRemaining(getFirstNestedNumber(response, percentRemainingPaths));
608
- const resetTimeIso = normalizeResetTimeIso(getFirstNestedString(response, resetPaths)) ?? getApproxNextResetIso();
609
- const tier = normalizeCopilotTier(getFirstNestedString(response, tierPaths));
610
- if (total === undefined && used !== undefined && remaining !== undefined) {
611
- total = used + remaining;
612
- }
613
- if (used === undefined && total !== undefined && remaining !== undefined) {
614
- used = Math.max(0, total - remaining);
615
- }
616
- if (total === undefined && tier) {
617
- total = COPILOT_PLAN_LIMITS[tier];
618
- }
619
- if (unlimited) {
490
+ }
491
+ return {
492
+ used,
493
+ includedUsed,
494
+ billedUsed,
495
+ billedAmountUsd: hasBilledAmount ? billedAmountUsd : undefined,
496
+ };
497
+ }
498
+ function parseLegacyPremiumRequestUsage(response) {
499
+ const items = getUsageItems(response);
500
+ const matches = items.filter(isPremiumRequestItem);
501
+ if (matches.length === 0 && items.length > 0) {
502
+ throw new Error("GitHub billing response did not contain a premium-request usage item");
503
+ }
504
+ return matches.reduce((sum, item) => {
505
+ const gross = readFiniteNumber(item.grossQuantity ?? item.gross_quantity);
506
+ const net = readFiniteNumber(item.netQuantity ?? item.net_quantity);
507
+ if (gross === undefined && net === undefined) {
508
+ throw new Error("GitHub premium-request usage item did not include quantity fields");
509
+ }
510
+ return sum + Math.max(0, gross ?? net ?? 0);
511
+ }, 0);
512
+ }
513
+ function isAiCreditBudget(budget) {
514
+ const skus = [
515
+ budget.budget_product_sku,
516
+ ...(Array.isArray(budget.budget_product_skus) ? budget.budget_product_skus : []),
517
+ ]
518
+ .filter((value) => typeof value === "string")
519
+ .map((value) => value.toLowerCase());
520
+ return budget.budget_type === "BundlePricing" && skus.includes("ai_credits");
521
+ }
522
+ function budgetMatchesTarget(budget, target) {
523
+ const scope = budget.budget_scope;
524
+ const entity = budget.budget_entity_name?.toLowerCase();
525
+ const user = (budget.user ?? budget.budget_entity_name)?.toLowerCase();
526
+ if (target.scope === "organization") {
527
+ if (target.username && scope === "user") {
528
+ return !user || user === target.username.toLowerCase();
529
+ }
530
+ if (target.username && scope === "multi_user_customer")
531
+ return true;
532
+ return scope === "organization";
533
+ }
534
+ if (target.scope === "enterprise") {
535
+ if (target.username && scope === "user") {
536
+ return !user || user === target.username.toLowerCase();
537
+ }
538
+ if (target.username &&
539
+ (scope === "multi_user_customer" || scope === "multi_user_cost_center")) {
540
+ return true;
541
+ }
542
+ if (target.organization && scope === "organization") {
543
+ return !entity || entity === target.organization.toLowerCase();
544
+ }
545
+ return scope === "enterprise";
546
+ }
547
+ return false;
548
+ }
549
+ function budgetSpecificity(budget) {
550
+ switch (budget.budget_scope) {
551
+ case "user":
552
+ return 5;
553
+ case "multi_user_cost_center":
554
+ return 4;
555
+ case "multi_user_customer":
556
+ return 3;
557
+ case "organization":
558
+ return 2;
559
+ case "enterprise":
560
+ return 1;
561
+ default:
562
+ return 0;
563
+ }
564
+ }
565
+ function getBudgetsUrl(target, page) {
566
+ const base = target.scope === "organization"
567
+ ? `${GITHUB_API_BASE_URL}/organizations/${encodeURIComponent(target.organization)}/settings/billing/budgets`
568
+ : `${GITHUB_API_BASE_URL}/enterprises/${encodeURIComponent(target.enterprise)}/settings/billing/budgets`;
569
+ const query = new URLSearchParams({ page: String(page), per_page: "100" });
570
+ if (target.username)
571
+ query.set("user", target.username);
572
+ return `${base}?${query}`;
573
+ }
574
+ async function fetchApplicableBudget(params) {
575
+ const budgets = [];
576
+ for (let page = 1; page <= 100; page += 1) {
577
+ const response = await fetchGitHubRestJson(getBudgetsUrl(params.target, page), params.token, params.requestTimeoutMs);
578
+ if (!Array.isArray(response.budgets)) {
579
+ throw new Error("GitHub budgets response did not include a budgets array");
580
+ }
581
+ budgets.push(...response.budgets);
582
+ if (!response.has_next_page)
583
+ break;
584
+ if (page === 100) {
585
+ throw new Error("GitHub budgets response exceeded 100 pages");
586
+ }
587
+ }
588
+ const selected = budgets
589
+ .filter((budget) => isAiCreditBudget(budget) &&
590
+ budgetMatchesTarget(budget, params.target) &&
591
+ typeof budget.budget_amount === "number" &&
592
+ Number.isFinite(budget.budget_amount) &&
593
+ budget.budget_amount >= 0)
594
+ .sort((a, b) => budgetSpecificity(b) - budgetSpecificity(a))[0];
595
+ if (!selected || selected.budget_amount === undefined)
596
+ return undefined;
597
+ const percentRemaining = params.spentUsd === undefined
598
+ ? undefined
599
+ : computePercentRemainingFromUsed(params.spentUsd, selected.budget_amount);
600
+ return {
601
+ amountUsd: selected.budget_amount,
602
+ spentUsd: params.spentUsd,
603
+ scope: selected.budget_scope ?? "unknown",
604
+ percentRemaining,
605
+ };
606
+ }
607
+ function makeBudgetWarning(error) {
608
+ const message = error instanceof Error ? error.message : String(error);
609
+ return `AI Credit usage loaded, but the budget report failed: ${message}`;
610
+ }
611
+ async function toAiCreditResult(params) {
612
+ const totals = parseAiCreditTotals(params.response);
613
+ const resetTimeIso = getApproxNextResetIso();
614
+ const period = getResponsePeriod(params.response, params.target.billingPeriod);
615
+ let budget;
616
+ const warnings = [];
617
+ if (params.target.scope !== "user") {
618
+ try {
619
+ budget = await fetchApplicableBudget({
620
+ token: params.token,
621
+ target: params.target,
622
+ spentUsd: totals.billedAmountUsd,
623
+ requestTimeoutMs: params.requestTimeoutMs,
624
+ });
625
+ }
626
+ catch (error) {
627
+ warnings.push(makeBudgetWarning(error));
628
+ }
629
+ }
630
+ if (params.target.scope === "organization") {
620
631
  return {
621
632
  success: true,
622
- mode: "user_quota",
623
- used: Math.max(0, used ?? 0),
624
- total: Math.max(1, total ?? 1),
625
- percentRemaining: explicitPercentRemaining ?? 100,
626
- unlimited: true,
633
+ mode: "organization_usage",
634
+ organization: params.target.organization,
635
+ username: params.target.username,
636
+ period,
637
+ unit: "ai_credits",
638
+ ...totals,
639
+ budget,
640
+ warnings: warnings.length ? warnings : undefined,
627
641
  resetTimeIso,
628
642
  };
629
643
  }
630
- if (!Number.isFinite(total) || total === undefined || total <= 0 || used === undefined || used < 0) {
631
- throw new Error("GitHub /copilot_internal/user response did not include usable personal quota fields.");
644
+ if (params.target.scope === "enterprise") {
645
+ return {
646
+ success: true,
647
+ mode: "enterprise_usage",
648
+ enterprise: params.target.enterprise,
649
+ organization: params.target.organization,
650
+ username: params.target.username,
651
+ period,
652
+ unit: "ai_credits",
653
+ ...totals,
654
+ budget,
655
+ warnings: warnings.length ? warnings : undefined,
656
+ resetTimeIso,
657
+ };
632
658
  }
659
+ const total = PERSONAL_AI_CREDIT_TOTALS[params.config.tier];
633
660
  return {
634
661
  success: true,
635
662
  mode: "user_quota",
636
- used,
663
+ unit: "ai_credits",
664
+ ...totals,
637
665
  total,
638
- percentRemaining: explicitPercentRemaining ?? computePercentRemainingFromUsed({ used, total }),
666
+ percentRemaining: total ? computePercentRemainingFromUsed(totals.used, total) : undefined,
667
+ plan: params.config.tier,
639
668
  resetTimeIso,
640
669
  };
641
670
  }
642
- function normalizeResetTimeIso(value) {
643
- if (!value)
644
- return undefined;
645
- const trimmed = value.trim();
646
- if (!trimmed)
647
- return undefined;
648
- if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
649
- return `${trimmed}T00:00:00.000Z`;
650
- }
651
- const timestamp = Date.parse(trimmed);
652
- if (Number.isNaN(timestamp))
653
- return undefined;
654
- return new Date(timestamp).toISOString();
655
- }
656
- function getApproxNextResetIso(nowMs = Date.now()) {
657
- const now = new Date(nowMs);
658
- return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)).toISOString();
659
- }
660
- function computePercentRemainingFromUsed(params) {
661
- const { used, total } = params;
662
- if (!Number.isFinite(total) || total <= 0)
663
- return 0;
664
- if (!Number.isFinite(used) || used <= 0)
665
- return 100;
666
- const normalizedUsed = Math.max(0, used);
667
- const remaining = total - normalizedUsed;
668
- return Math.min(100, Math.floor((remaining * 100) / total));
669
- }
670
- function getPremiumUsageItems(response, options) {
671
- const items = Array.isArray(response.usageItems)
672
- ? response.usageItems
673
- : Array.isArray(response.usage_items)
674
- ? response.usage_items
675
- : [];
676
- const premiumItems = items.filter((item) => {
677
- if (!item || typeof item !== "object")
678
- return false;
679
- if (typeof item.sku !== "string")
680
- return false;
681
- return item.sku === "Copilot Premium Request" || item.sku.includes("Premium");
682
- });
683
- if (premiumItems.length === 0 && items.length > 0) {
684
- const skus = items.map((item) => (typeof item?.sku === "string" ? item.sku : "?")).join(", ");
685
- throw new Error(`No premium-request items found in billing response (${items.length} items, SKUs: ${skus}). Expected an item with SKU containing "Premium".`);
686
- }
687
- if (premiumItems.length === 0 && options?.allowEmpty) {
688
- return [];
689
- }
690
- if (premiumItems.length === 0) {
691
- throw new Error("Billing API returned empty usageItems array for Copilot premium requests.");
692
- }
693
- return premiumItems;
694
- }
695
- function sumUsedUnits(items) {
696
- return items.reduce((sum, item) => {
697
- const used = item.grossQuantity ??
698
- item.gross_quantity ??
699
- item.netQuantity ??
700
- item.net_quantity ??
701
- 0;
702
- return sum + (typeof used === "number" ? used : 0);
703
- }, 0);
704
- }
705
- function formatBillingPeriod(period) {
706
- return `${period.year}-${String(period.month).padStart(2, "0")}`;
707
- }
708
- function getBillingResponsePeriod(response, fallbackPeriod) {
709
- const timePeriod = response.timePeriod ?? response.time_period;
710
- const year = typeof timePeriod?.year === "number" ? timePeriod.year : fallbackPeriod.year;
711
- const month = typeof timePeriod?.month === "number" ? timePeriod.month : fallbackPeriod.month;
712
- return { year, month };
713
- }
714
- function toUserQuotaResultFromBilling(response, fallbackTier) {
715
- const premiumItems = getPremiumUsageItems(response);
716
- const used = sumUsedUnits(premiumItems);
717
- const apiLimits = premiumItems
718
- .map((item) => item.limit)
719
- .filter((limit) => typeof limit === "number" && limit > 0);
720
- const total = apiLimits.length > 0 ? Math.max(...apiLimits) : fallbackTier ? COPILOT_PLAN_LIMITS[fallbackTier] : undefined;
721
- if (!total || total <= 0) {
722
- 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.");
723
- }
671
+ function toLegacyResult(response, config) {
672
+ const used = parseLegacyPremiumRequestUsage(response);
673
+ const total = LEGACY_PREMIUM_REQUEST_TOTALS[config.tier];
724
674
  return {
725
675
  success: true,
726
676
  mode: "user_quota",
677
+ unit: "premium_requests",
727
678
  used,
728
679
  total,
729
- percentRemaining: computePercentRemainingFromUsed({ used, total }),
730
- resetTimeIso: getApproxNextResetIso(),
731
- };
732
- }
733
- function toOrganizationUsageResultFromBilling(params) {
734
- const premiumItems = getPremiumUsageItems(params.response, { allowEmpty: true });
735
- return {
736
- success: true,
737
- mode: "organization_usage",
738
- organization: params.organization,
739
- username: params.username,
740
- period: getBillingResponsePeriod(params.response, params.billingPeriod),
741
- used: sumUsedUnits(premiumItems),
742
- resetTimeIso: getApproxNextResetIso(),
743
- };
744
- }
745
- function toEnterpriseUsageResultFromBilling(params) {
746
- const premiumItems = getPremiumUsageItems(params.response, { allowEmpty: true });
747
- return {
748
- success: true,
749
- mode: "enterprise_usage",
750
- enterprise: params.enterprise,
751
- organization: params.organization,
752
- username: params.username,
753
- period: getBillingResponsePeriod(params.response, params.billingPeriod),
754
- used: sumUsedUnits(premiumItems),
680
+ percentRemaining: total ? computePercentRemainingFromUsed(used, total) : undefined,
681
+ plan: config.tier,
755
682
  resetTimeIso: getApproxNextResetIso(),
756
683
  };
757
684
  }
@@ -759,65 +686,44 @@ function toQuotaError(message) {
759
686
  return { success: false, error: message };
760
687
  }
761
688
  /**
762
- * Query GitHub Copilot premium request usage.
689
+ * Query GitHub Copilot accounting.
763
690
  *
764
- * PAT configuration wins over OpenCode OAuth auth when both are present.
691
+ * A valid local billing token config is required. OpenCode OAuth remains
692
+ * visible in diagnostics but is not sent to GitHub's public billing API
693
+ * because its billing permissions are not part of the documented contract.
765
694
  */
766
695
  export async function queryCopilotQuota(options = {}) {
767
696
  const pat = readQuotaConfigWithMeta();
768
697
  if (pat.state === "invalid") {
769
698
  return toQuotaError(`Invalid copilot-quota-token.json: ${pat.error ?? "unknown error"}${pat.selectedPath ? ` (${pat.selectedPath})` : ""}`);
770
699
  }
771
- if (pat.state === "valid" && pat.config) {
772
- const resolvedTarget = resolvePatBillingTarget(pat.config);
773
- if (!resolvedTarget.target) {
774
- return toQuotaError(resolvedTarget.error ?? "Unable to resolve Copilot billing scope.");
775
- }
776
- const tokenCompatibilityError = validatePatTargetCompatibility(resolvedTarget.target, pat.tokenKind);
777
- if (tokenCompatibilityError) {
778
- return toQuotaError(tokenCompatibilityError);
779
- }
780
- try {
781
- const { response, billingPeriod } = await fetchPremiumRequestUsage({
782
- token: pat.config.token,
783
- target: resolvedTarget.target,
784
- requestTimeoutMs: options.requestTimeoutMs,
785
- });
786
- if (resolvedTarget.target.scope === "organization") {
787
- return toOrganizationUsageResultFromBilling({
788
- response,
789
- organization: resolvedTarget.target.organization,
790
- username: resolvedTarget.target.username,
791
- billingPeriod: billingPeriod ?? resolvedTarget.target.billingPeriod,
792
- });
793
- }
794
- if (resolvedTarget.target.scope === "enterprise") {
795
- return toEnterpriseUsageResultFromBilling({
796
- response,
797
- enterprise: resolvedTarget.target.enterprise,
798
- organization: resolvedTarget.target.organization,
799
- username: resolvedTarget.target.username,
800
- billingPeriod: billingPeriod ?? resolvedTarget.target.billingPeriod,
801
- });
802
- }
803
- return toUserQuotaResultFromBilling(response, pat.config.tier);
804
- }
805
- catch (error) {
806
- return toQuotaError(error instanceof Error ? error.message : String(error));
807
- }
808
- }
809
- const authData = await readAuthFile();
810
- const { auth } = selectCopilotAuth(authData);
811
- if (!auth) {
700
+ if (pat.state === "absent" || !pat.config) {
812
701
  return null;
813
702
  }
814
- const accessToken = auth.access?.trim();
815
- if (!accessToken) {
816
- return toQuotaError("Copilot OAuth auth is configured but missing an access token required for GitHub /copilot_internal/user.");
703
+ const resolved = resolvePatBillingTarget(pat.config);
704
+ if (!resolved.target) {
705
+ return toQuotaError(resolved.error ?? "Unable to resolve Copilot billing scope.");
817
706
  }
707
+ const compatibilityError = validatePatTargetCompatibility(resolved.target, pat.tokenKind);
708
+ if (compatibilityError)
709
+ return toQuotaError(compatibilityError);
818
710
  try {
819
- const response = await fetchCopilotInternalUser(accessToken, options.requestTimeoutMs);
820
- return toUserQuotaResultFromCopilotInternal(response);
711
+ const billingModel = pat.config.billingModel ?? "ai_credits";
712
+ const { response, target } = await fetchBillingUsage({
713
+ token: pat.config.token,
714
+ target: resolved.target,
715
+ billingModel,
716
+ requestTimeoutMs: options.requestTimeoutMs,
717
+ });
718
+ return billingModel === "legacy_premium_requests"
719
+ ? toLegacyResult(response, pat.config)
720
+ : await toAiCreditResult({
721
+ response,
722
+ target,
723
+ config: pat.config,
724
+ token: pat.config.token,
725
+ requestTimeoutMs: options.requestTimeoutMs,
726
+ });
821
727
  }
822
728
  catch (error) {
823
729
  return toQuotaError(error instanceof Error ? error.message : String(error));
@@ -828,30 +734,20 @@ export async function hasCopilotQuotaRuntimeAvailable() {
828
734
  return diagnostics.billingApiAccessLikely;
829
735
  }
830
736
  export function formatCopilotQuota(result) {
831
- if (!result || !result.success) {
737
+ if (!result || !result.success)
832
738
  return null;
833
- }
739
+ const unit = result.unit === "ai_credits" ? "AI Credits" : "Premium Requests";
834
740
  if (result.mode === "organization_usage") {
835
- const details = [`${result.used} used`, formatBillingPeriod(result.period)];
836
- if (result.username) {
837
- details.push(`user=${result.username}`);
838
- }
839
- return `Copilot Org (${result.organization}) ${details.join(" | ")}`;
741
+ return `Copilot Org (${result.organization}) ${result.used} ${unit} | ${formatBillingPeriod(result.period)}`;
840
742
  }
841
743
  if (result.mode === "enterprise_usage") {
842
- const details = [`${result.used} used`, formatBillingPeriod(result.period)];
843
- if (result.organization) {
844
- details.push(`org=${result.organization}`);
845
- }
846
- if (result.username) {
847
- details.push(`user=${result.username}`);
848
- }
849
- return `Copilot Enterprise (${result.enterprise}) ${details.join(" | ")}`;
744
+ return `Copilot Enterprise (${result.enterprise}) ${result.used} ${unit} | ${formatBillingPeriod(result.period)}`;
850
745
  }
851
- if (result.unlimited) {
852
- return "Copilot Unlimited";
746
+ if (result.unlimited)
747
+ return `Copilot ${unit} Unlimited`;
748
+ if (result.total !== undefined) {
749
+ return `Copilot ${unit} ${result.used}/${result.total}`;
853
750
  }
854
- const percentUsed = 100 - result.percentRemaining;
855
- return `Copilot ${result.used}/${result.total} (${percentUsed}%)`;
751
+ return `Copilot ${unit} ${result.used} used`;
856
752
  }
857
753
  //# sourceMappingURL=copilot.js.map