mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 05:45:04 +03:00
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
This commit is contained in:
@@ -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<string, UsageQuota> = {};
|
||||
|
||||
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<string, UsageQuota> = {};
|
||||
|
||||
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)
|
||||
*/
|
||||
|
||||
@@ -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")
|
||||
|
||||
92
tests/unit/copilot-usage.test.mjs
Normal file
92
tests/unit/copilot-usage.test.mjs
Normal file
@@ -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;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user