From 5a777bd59842497de65ff3ccbd378e7fee0c8791 Mon Sep 17 00:00:00 2001 From: Jack Cowey Date: Wed, 18 Mar 2026 12:25:17 +0000 Subject: [PATCH] fix(github): correct copilot plan and quota mapping Normalize GitHub Copilot account tiers from the usage payload and hide misleading unlimited buckets so account type and limits render correctly in the dashboard. Made-with: Cursor --- open-sse/services/usage.ts | 205 ++++++++++++++---- .../usage/components/ProviderLimits/utils.tsx | 17 +- tests/unit/copilot-usage.test.mjs | 92 ++++++++ 3 files changed, 274 insertions(+), 40 deletions(-) create mode 100644 tests/unit/copilot-usage.test.mjs diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index 7a36566eef..e641553d7e 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -75,6 +75,30 @@ function getFieldValue(source: unknown, snakeKey: string, camelKey: string): unk return obj[snakeKey] ?? obj[camelKey] ?? null; } +function clampPercentage(value: number): number { + return Math.max(0, Math.min(100, value)); +} + +function toDisplayLabel(value: string): string { + return value + .replace(/^copilot[_\s-]*/i, "") + .split(/[\s_-]+/) + .filter(Boolean) + .map((part) => { + if (/^pro\+$/i.test(part)) return "Pro+"; + if (/^[a-z]{2,}$/.test(part)) return part.charAt(0).toUpperCase() + part.slice(1).toLowerCase(); + return part; + }) + .join(" ") + .trim(); +} + +function shouldDisplayGitHubQuota(quota: UsageQuota | null): quota is UsageQuota { + if (!quota) return false; + if (quota.unlimited && quota.total <= 0) return false; + return quota.total > 0 || quota.remainingPercentage !== undefined; +} + /** * Get usage data for a provider connection * @param {Object} connection - Provider connection with accessToken @@ -170,48 +194,65 @@ async function getGitHubUsage(accessToken, providerSpecificData) { } const data = await response.json(); + const dataRecord = toRecord(data); // Handle different response formats (paid vs free) - if (data.quota_snapshots) { + if (dataRecord.quota_snapshots) { // Paid plan format - const snapshots = data.quota_snapshots; - const resetAt = parseResetTime(data.quota_reset_date); + const snapshots = toRecord(dataRecord.quota_snapshots); + const resetAt = parseResetTime(getFieldValue(dataRecord, "quota_reset_date", "quotaResetDate")); + const premiumQuota = formatGitHubQuotaSnapshot(snapshots.premium_interactions, resetAt); + const chatQuota = formatGitHubQuotaSnapshot(snapshots.chat, resetAt); + const completionsQuota = formatGitHubQuotaSnapshot(snapshots.completions, resetAt); + const quotas: Record = {}; + + if (shouldDisplayGitHubQuota(premiumQuota)) { + quotas.premium_interactions = premiumQuota; + } + if (shouldDisplayGitHubQuota(chatQuota)) { + quotas.chat = chatQuota; + } + if (shouldDisplayGitHubQuota(completionsQuota)) { + quotas.completions = completionsQuota; + } return { - plan: data.copilot_plan, - resetDate: data.quota_reset_date, - quotas: { - chat: { ...formatGitHubQuotaSnapshot(snapshots.chat), resetAt }, - completions: { ...formatGitHubQuotaSnapshot(snapshots.completions), resetAt }, - premium_interactions: { - ...formatGitHubQuotaSnapshot(snapshots.premium_interactions), - resetAt, - }, - }, + plan: inferGitHubPlanName(dataRecord, premiumQuota), + resetDate: getFieldValue(dataRecord, "quota_reset_date", "quotaResetDate"), + quotas, }; - } else if (data.monthly_quotas || data.limited_user_quotas) { + } else if (dataRecord.monthly_quotas || dataRecord.limited_user_quotas) { // Free/limited plan format - const monthlyQuotas = data.monthly_quotas || {}; - const usedQuotas = data.limited_user_quotas || {}; - const resetAt = parseResetTime(data.limited_user_reset_date); + const monthlyQuotas = toRecord(dataRecord.monthly_quotas); + const usedQuotas = toRecord(dataRecord.limited_user_quotas); + const resetDate = getFieldValue(dataRecord, "limited_user_reset_date", "limitedUserResetDate"); + const resetAt = parseResetTime(resetDate); + const quotas: Record = {}; + + const addLimitedQuota = (name: string) => { + const total = toNumber(getFieldValue(monthlyQuotas, name, name), 0); + const used = Math.max(0, toNumber(getFieldValue(usedQuotas, name, name), 0)); + if (total <= 0) return null; + const clampedUsed = Math.min(used, total); + quotas[name] = { + used: clampedUsed, + total, + remaining: Math.max(total - clampedUsed, 0), + remainingPercentage: clampPercentage(((total - clampedUsed) / total) * 100), + unlimited: false, + resetAt, + }; + return quotas[name]; + }; + + const premiumQuota = addLimitedQuota("premium_interactions"); + addLimitedQuota("chat"); + addLimitedQuota("completions"); return { - plan: data.copilot_plan || data.access_type_sku, - resetDate: data.limited_user_reset_date, - quotas: { - chat: { - used: usedQuotas.chat || 0, - total: monthlyQuotas.chat || 0, - unlimited: false, - resetAt, - }, - completions: { - used: usedQuotas.completions || 0, - total: monthlyQuotas.completions || 0, - unlimited: false, - resetAt, - }, - }, + plan: inferGitHubPlanName(dataRecord, premiumQuota), + resetDate, + quotas, }; } @@ -221,17 +262,103 @@ async function getGitHubUsage(accessToken, providerSpecificData) { } } -function formatGitHubQuotaSnapshot(quota) { - if (!quota) return { used: 0, total: 0, unlimited: true }; +function formatGitHubQuotaSnapshot(quota, resetAt: string | null = null): UsageQuota | null { + const source = toRecord(quota); + if (Object.keys(source).length === 0) return null; + + const unlimited = source.unlimited === true; + const entitlement = toNumber(source.entitlement, Number.NaN); + const totalValue = toNumber(source.total, Number.NaN); + const remainingValue = toNumber(source.remaining, Number.NaN); + const usedValue = toNumber(source.used, Number.NaN); + const percentRemainingValue = toNumber( + getFieldValue(source, "percent_remaining", "percentRemaining"), + Number.NaN + ); + + let total = Number.isFinite(totalValue) + ? Math.max(0, totalValue) + : Number.isFinite(entitlement) + ? Math.max(0, entitlement) + : 0; + let remaining = Number.isFinite(remainingValue) ? Math.max(0, remainingValue) : undefined; + let used = Number.isFinite(usedValue) ? Math.max(0, usedValue) : undefined; + let remainingPercentage = Number.isFinite(percentRemainingValue) + ? clampPercentage(percentRemainingValue) + : undefined; + + if (used === undefined && total > 0 && remaining !== undefined) { + used = Math.max(total - remaining, 0); + } + + if (remaining === undefined && total > 0 && used !== undefined) { + remaining = Math.max(total - used, 0); + } + + if (remainingPercentage === undefined && total > 0 && remaining !== undefined) { + remainingPercentage = clampPercentage((remaining / total) * 100); + } + + if (total <= 0 && remainingPercentage !== undefined) { + total = 100; + used = 100 - remainingPercentage; + remaining = remainingPercentage; + } return { - used: quota.entitlement - quota.remaining, - total: quota.entitlement, - remaining: quota.remaining, - unlimited: quota.unlimited || false, + used: Math.max(0, used ?? 0), + total, + remaining, + remainingPercentage, + resetAt, + unlimited, }; } +function inferGitHubPlanName(data: JsonRecord, premiumQuota: UsageQuota | null): string { + const rawPlan = getFieldValue(data, "copilot_plan", "copilotPlan"); + const rawSku = getFieldValue(data, "access_type_sku", "accessTypeSku"); + const planText = typeof rawPlan === "string" ? rawPlan.trim() : ""; + const skuText = typeof rawSku === "string" ? rawSku.trim() : ""; + const combined = `${skuText} ${planText}`.trim().toUpperCase(); + const monthlyQuotas = toRecord(getFieldValue(data, "monthly_quotas", "monthlyQuotas")); + const premiumTotal = + premiumQuota?.total || + toNumber(getFieldValue(monthlyQuotas, "premium_interactions", "premiumInteractions"), 0); + const chatTotal = toNumber(getFieldValue(monthlyQuotas, "chat", "chat"), 0); + + if ( + combined.includes("PRO+") || + combined.includes("PRO_PLUS") || + combined.includes("PROPLUS") + ) { + return "Copilot Pro+"; + } + if (combined.includes("ENTERPRISE")) return "Copilot Enterprise"; + if (combined.includes("BUSINESS")) return "Copilot Business"; + if (combined.includes("STUDENT")) return "Copilot Student"; + if (combined.includes("FREE")) return "Copilot Free"; + if (combined.includes("PRO")) return "Copilot Pro"; + + if (premiumTotal >= 1400) return "Copilot Pro+"; + if (premiumTotal >= 900) return "Copilot Enterprise"; + if (premiumTotal >= 250) { + if (combined.includes("INDIVIDUAL")) return "Copilot Pro"; + return "Copilot Business"; + } + if (premiumTotal > 0 || chatTotal === 50) return "Copilot Free"; + + if (skuText) { + const label = toDisplayLabel(skuText); + return label ? `Copilot ${label}` : "GitHub Copilot"; + } + if (planText) { + const label = toDisplayLabel(planText); + return label ? `Copilot ${label}` : "GitHub Copilot"; + } + return "GitHub Copilot"; +} + /** * Gemini CLI Usage (Google Cloud) */ diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx index 7a6abd5ff4..7adf8c4d0e 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx @@ -92,11 +92,15 @@ export function parseQuotaData(provider, data) { case "github": if (data.quotas) { Object.entries(data.quotas).forEach(([name, quota]: [string, any]) => { + if (quota?.unlimited && (!quota?.total || quota.total <= 0)) { + return; + } normalizedQuotas.push({ name, used: quota.used || 0, total: quota.total || 0, resetAt: quota.resetAt || null, + remainingPercentage: safePercentage(quota.remainingPercentage), }); }); } @@ -214,6 +218,14 @@ export function normalizePlanTier(plan) { const upper = raw.toUpperCase(); + if ( + upper.includes("PRO+") || + upper.includes("PRO PLUS") || + upper.includes("PROPLUS") + ) { + return { key: "plus", label: "Pro+", variant: "secondary", rank: 4, raw }; + } + if (upper.includes("ENTERPRISE") || upper.includes("CORP") || upper.includes("ORG")) { return { key: "enterprise", label: "Enterprise", variant: "info", rank: 7, raw }; } @@ -227,6 +239,10 @@ export function normalizePlanTier(plan) { return { key: "business", label: "Business", variant: "warning", rank: 5, raw }; } + if (upper.includes("STUDENT")) { + return { key: "pro", label: "Student", variant: "primary", rank: 3, raw }; + } + if (upper.includes("ULTRA")) { return { key: "ultra", label: "Ultra", variant: "success", rank: 4, raw }; } @@ -241,7 +257,6 @@ export function normalizePlanTier(plan) { if ( upper.includes("FREE") || - upper.includes("INDIVIDUAL") || upper.includes("BASIC") || upper.includes("TRIAL") || upper.includes("LEGACY") diff --git a/tests/unit/copilot-usage.test.mjs b/tests/unit/copilot-usage.test.mjs new file mode 100644 index 0000000000..0e1ad62464 --- /dev/null +++ b/tests/unit/copilot-usage.test.mjs @@ -0,0 +1,92 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const usageService = await import("../../open-sse/services/usage.ts"); +const providerLimitUtils = await import( + "../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx" +); + +test("github copilot business seats infer business plan and hide unlimited buckets", async () => { + const originalFetch = globalThis.fetch; + + globalThis.fetch = async () => + new Response( + JSON.stringify({ + access_type_sku: "copilot_business_seat", + quota_reset_date: "2026-04-01T00:00:00Z", + quota_snapshots: { + chat: { unlimited: true }, + completions: { unlimited: true }, + premium_interactions: { + entitlement: 300, + remaining: 180, + unlimited: false, + }, + }, + }), + { + status: 200, + headers: { "content-type": "application/json" }, + } + ); + + try { + const usage = await usageService.getUsageForProvider({ + provider: "github", + accessToken: "gho_test", + providerSpecificData: {}, + }); + + assert.equal(usage.plan, "Copilot Business"); + assert.deepEqual(Object.keys(usage.quotas), ["premium_interactions"]); + assert.equal(usage.quotas.premium_interactions.total, 300); + assert.equal(usage.quotas.premium_interactions.used, 120); + assert.equal(usage.quotas.premium_interactions.remaining, 180); + assert.equal(usage.quotas.premium_interactions.remainingPercentage, 60); + + const parsed = providerLimitUtils.parseQuotaData("github", usage); + assert.equal(parsed.length, 1); + assert.equal(parsed[0].name, "premium_interactions"); + assert.equal(parsed[0].remainingPercentage, 60); + assert.equal(providerLimitUtils.normalizePlanTier(usage.plan).key, "business"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("github copilot individual paid plans no longer normalize as free", async () => { + const originalFetch = globalThis.fetch; + + globalThis.fetch = async () => + new Response( + JSON.stringify({ + copilot_plan: "individual", + quota_reset_date: "2026-04-01T00:00:00Z", + quota_snapshots: { + premium_interactions: { + entitlement: 300, + remaining: 120, + unlimited: false, + }, + }, + }), + { + status: 200, + headers: { "content-type": "application/json" }, + } + ); + + try { + const usage = await usageService.getUsageForProvider({ + provider: "github", + accessToken: "gho_test", + providerSpecificData: {}, + }); + + assert.equal(usage.plan, "Copilot Pro"); + assert.equal(providerLimitUtils.normalizePlanTier(usage.plan).key, "pro"); + assert.equal(providerLimitUtils.normalizePlanTier("individual").key, "unknown"); + } finally { + globalThis.fetch = originalFetch; + } +});