Skip to content

Commit d31c9e9

Browse files
committed
feat(api): adapt to token-based billing and pooled exhaustion
Auto-detects GitHub's UBB (credits) mode via `token_based_billing` and exposes new fields on UsageData: `tokenBasedBilling`, `hasQuota`, `exhausted`, plus `resetDate: Date | undefined`. - Plan map: add `individual_max` (Max) and `individual_edu` (Student). - Entitlement parsing: handle string values (`'300'`) emitted under UBB; `entitlement: '0'` (not unlimited) routes to noData, matching upstream parseQuotas. Missing entitlement is preserved as undefined. - Free CFI carve-out: when entitlement is absent and percent_remaining is 0, route to noData so a single-value status bar doesn't render "100% red" for users without a premium allowance. percent_remaining is coerced via parseNonNegativeNumber to also catch string "0". - quota_remaining used for precise `used` calculation when present; legacy entitlement * percent fallback when absent or non-finite. - resetDate priority mirrors upstream: UBB quota_reset_at (Unix s, finite & >0) -> quota_reset_date_utc -> quota_reset_date -> limited_user_reset_date. Returns undefined when no source is valid. - Pooled exhaustion: derive `exhausted = unlimited && !hasQuota && !overageEnabled` so consumers can distinguish drained-pool from plain unlimited.
1 parent 114cf7b commit d31c9e9

2 files changed

Lines changed: 493 additions & 46 deletions

File tree

src/api.js

Lines changed: 111 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ const PLAN_MAP = {
77
free: "Free",
88
individual: "Pro",
99
individual_pro: "Pro+",
10+
individual_max: "Max",
11+
individual_edu: "Student",
1012
business: "Business",
1113
enterprise: "Enterprise",
1214
};
@@ -17,11 +19,14 @@ const PLAN_MAP = {
1719
* @property {number} quota
1820
* @property {number} usedPct - already-used percentage (0–100+)
1921
* @property {boolean} unlimited
22+
* @property {boolean} hasQuota - false signals pooled-entitlement exhaustion (enterprise unlimited)
23+
* @property {boolean} exhausted - derived: unlimited pool drained with no overage; consumers should signal a hard error state
2024
* @property {boolean} noData - true when the plan has no premium interactions quota
2125
* @property {boolean} overageEnabled
2226
* @property {number} overageUsed
2327
* @property {string} plan
24-
* @property {Date} resetDate
28+
* @property {Date | undefined} resetDate - undefined when the server provided no reset source; consumers should hide the row
29+
* @property {boolean} tokenBasedBilling - true = UBB (credits) mode, false = legacy premium-requests mode
2530
*/
2631

2732
/**
@@ -80,59 +85,130 @@ async function fetchUsage(token) {
8085
const plan = PLAN_MAP[data.copilot_plan] ?? data.copilot_plan ?? "Unknown";
8186

8287
const pi = data?.quota_snapshots?.premium_interactions;
83-
if (!pi || pi.percent_remaining == null) {
84-
const unlimited = !!pi?.unlimited;
85-
return {
86-
used: 0,
87-
quota: pi?.entitlement ?? 0,
88-
usedPct: 0,
89-
unlimited,
90-
noData: !unlimited,
91-
overageEnabled: !!pi?.overage_permitted,
92-
overageUsed: pi?.overage_count ?? 0,
93-
plan,
94-
resetDate: getNextMonthReset(),
95-
};
88+
const tokenBasedBilling = !!data.token_based_billing;
89+
90+
const parsedEntitlement = parseNonNegativeNumber(pi?.entitlement);
91+
const entitlement = parsedEntitlement ?? 0;
92+
93+
// Two cases route to noData (no usable premium counter):
94+
// 1. Explicit zero entitlement (`entitlement: '0'` or `0`) — matches upstream parseQuotas
95+
// `parsedEntitlement === 0` skip rule.
96+
// 2. Free CFI shape: entitlement field absent AND percent_remaining === 0. Upstream
97+
// dashboard keeps this snapshot because chat/completions indicators give context;
98+
// our single-value status bar would otherwise show "100% red" and falsely imply
99+
// exhaustion to a user who never had a premium allowance.
100+
// percent_remaining is coerced via parseNonNegativeNumber so a string "0" payload
101+
// doesn't bypass this carve-out (the downstream Number(...) path is already lenient).
102+
const noEntitlement =
103+
!pi?.unlimited &&
104+
(parsedEntitlement === 0 ||
105+
(parsedEntitlement === undefined && parseNonNegativeNumber(pi?.percent_remaining) === 0));
106+
107+
const resetDate = parseResetDate(data, pi, tokenBasedBilling);
108+
const unlimited = !!pi?.unlimited;
109+
const hasQuota = Boolean(pi?.has_quota ?? true);
110+
const overageEnabled = !!pi?.overage_permitted;
111+
const exhausted = unlimited && !hasQuota && !overageEnabled;
112+
const overageUsed = pi?.overage_count ?? 0;
113+
114+
// Fields shared by both return shapes — keeps the per-branch returns focused
115+
// on the state-dependent fields (used, usedPct, noData).
116+
const shared = {
117+
quota: entitlement,
118+
unlimited,
119+
hasQuota,
120+
exhausted,
121+
overageEnabled,
122+
overageUsed,
123+
plan,
124+
resetDate,
125+
tokenBasedBilling,
126+
};
127+
128+
if (!pi || pi.percent_remaining == null || noEntitlement) {
129+
return { ...shared, used: 0, usedPct: 0, noData: !unlimited };
96130
}
97131

98-
const entitlement = pi.entitlement ?? 0;
99132
const percentRemaining = Number(pi.percent_remaining);
100133
if (!Number.isFinite(percentRemaining)) {
101134
throw makeError("API_ERROR", "Invalid percent_remaining from GitHub API");
102135
}
103136
const usedPct = Math.max(0, Math.round((100 - percentRemaining) * 10) / 10);
104-
const used =
105-
entitlement > 0 ? Math.max(0, Math.round((entitlement * (100 - percentRemaining)) / 100)) : 0;
106137

107-
const rawResetDate = data.quota_reset_date ? new Date(data.quota_reset_date) : null;
108-
const resetDate =
109-
rawResetDate && !isNaN(rawResetDate.getTime()) ? rawResetDate : getNextMonthReset();
138+
const quotaRemaining = parseNonNegativeNumber(pi.quota_remaining);
139+
const used =
140+
quotaRemaining !== undefined
141+
? Math.max(0, entitlement - quotaRemaining)
142+
: entitlement > 0
143+
? Math.max(0, Math.round((entitlement * (100 - percentRemaining)) / 100))
144+
: 0;
110145

111-
return {
112-
used,
113-
quota: entitlement,
114-
usedPct,
115-
unlimited: !!pi.unlimited,
116-
noData: false,
117-
overageEnabled: !!pi.overage_permitted,
118-
overageUsed: pi.overage_count ?? 0,
119-
plan,
120-
resetDate,
121-
};
146+
return { ...shared, used, usedPct, noData: false };
122147
} finally {
123148
clearTimeout(timeout);
124149
}
125150
}
126151

127-
function getNextMonthReset() {
128-
const now = new Date();
129-
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1));
152+
/**
153+
* Coerce a raw value to a finite non-negative number, or undefined if it
154+
* cannot be. Matches upstream parseQuotas behavior for `entitlement` and
155+
* `quota_remaining` (string or number both acceptable).
156+
*
157+
* @param {unknown} raw
158+
* @returns {number | undefined}
159+
*/
160+
function parseNonNegativeNumber(raw) {
161+
if (raw == null) return undefined;
162+
const n = Number(raw);
163+
return Number.isFinite(n) && n >= 0 ? n : undefined;
164+
}
165+
166+
/**
167+
* Resolve resetDate by priority, returning the first source that yields a
168+
* valid Date — or `undefined` if none does. Mirrors upstream parseQuotas:
169+
* 1. UBB only: per-snapshot quota_reset_at (Unix seconds)
170+
* 2. Top-level quota_reset_date_utc
171+
* 3. Top-level quota_reset_date (local)
172+
* 4. Top-level limited_user_reset_date (Free SKU)
173+
*
174+
* Upstream returns undefined when no source is available; the dashboard hides
175+
* the "Resets …" line entirely. We follow the same shape and let consumers
176+
* decide what to render — simpler than synthesizing a misleading next-month date.
177+
*
178+
* Note: upstream `parseQuotas` keeps `resetAt` regardless of billing mode and
179+
* only narrows to UBB at the consumer (chatStatusDashboard). We narrow here
180+
* because this module's only consumer (the status bar) treats UBB as the sole
181+
* legitimate source of per-snapshot reset times. Behavior equivalent to upstream.
182+
*
183+
* @param {any} data - top-level API response body
184+
* @param {any} pi - quota_snapshots.premium_interactions (may be null/undefined)
185+
* @param {boolean} tokenBasedBilling
186+
* @returns {Date | undefined}
187+
*/
188+
function parseResetDate(data, pi, tokenBasedBilling) {
189+
if (tokenBasedBilling) {
190+
const parsedResetAt = Number(pi?.quota_reset_at);
191+
if (Number.isFinite(parsedResetAt) && parsedResetAt > 0) {
192+
return new Date(parsedResetAt * 1000);
193+
}
194+
}
195+
// Walk fallbacks: a malformed entry must not block the next one. limited_user_reset_date
196+
// is the Free-SKU-only field upstream uses last (see parseQuotas).
197+
for (const raw of [
198+
data?.quota_reset_date_utc,
199+
data?.quota_reset_date,
200+
data?.limited_user_reset_date,
201+
]) {
202+
if (!raw) continue;
203+
const d = new Date(raw);
204+
if (!isNaN(d.getTime())) return d;
205+
}
206+
return undefined;
130207
}
131208

132209
/** @param {string} code @param {string} message */
133210
function makeError(code, message) {
134211
const err = new Error(message);
135-
// @ts-ignore
136212
err.code = code;
137213
return err;
138214
}

0 commit comments

Comments
 (0)