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; + } +});