mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 14:22:09 +03:00
Merge pull request #445 from hijak/fix/copilot-account-type-limits
fix: correct GitHub Copilot account type and limits
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