Files
OmniRoute/open-sse/services/usage/github.ts
MumuTW f0b08f95c3 chore(usage): decompose services/usage.ts into per-provider usage/* leaves (999 → 253) (#8545)
* chore(usage): extract crof, nanogpt, qoder, opencode, deepseek, bailian, vertex, xiaomi-mimo, xai, github usage fetchers into usage/* leaves

Decompose services/usage.ts (god-file phase 1): move the remaining per-provider
usage fetcher/parser logic into co-located leaves under open-sse/services/usage/
so usage.ts becomes a thin dispatcher (imports + USAGE_FETCHER_PROVIDERS +
getUsageForProvider switch + __testing re-exports).

New leaves (each a pure data transform or independent fetcher, no orchestration):
  - usage/github.ts    getGitHubUsage, formatGitHubQuotaSnapshot, inferGitHubPlanName, shouldDisplayGitHubQuota
  - usage/crof.ts      getCrofUsage
  - usage/nanogpt.ts   getNanoGptUsage
  - usage/qoder.ts     getQoderUsage, parseQoderUserStatusUsage
  - usage/opencode.ts  getOpencodeUsage
  - usage/deepseek.ts  getDeepseekUsage
  - usage/bailian.ts   getBailianCodingPlanUsage
  - usage/vertex.ts    getVertexUsage
  - usage/xiaomi-mimo.ts getXiaomiMimoUsage
  - usage/xai.ts       getXaiUsage

usage.ts re-exports parseQoderUserStatusUsage (named) and threads every helper
the existing __testing contract exposes (usage-utils / usage-service-hardening /
qoder-usage-quota / xiaomi-mimo-selftrack / xai-usage / vertex-spend suites read
them from services/usage). External importers unchanged.

open-sse/services/usage.ts: 1065 -> 256 lines (cap 800).
npm run check:file-size: OK. typecheck:core: OK. eslint: clean. check:cycles: OK.

Added characterization tests (tests/unit/usage-<provider>-split.test.ts) that
import each leaf directly and pin its export surface + key edges, mirroring the
existing usage-quota-core-split / usage-scalars-split pattern.

* docs(changelog): add fragment for this PR
2026-07-26 03:52:19 -03:00

231 lines
8.9 KiB
TypeScript

/**
* usage/github.ts — GitHub Copilot usage fetcher + quota/plan helpers.
*
* Extracted from services/usage.ts (god-file decomposition): the GitHub family —
* the copilot_internal/user fetcher (getGitHubUsage), the paid/limited quota
* snapshot formatter (formatGitHubQuotaSnapshot), the plan-name inference
* (inferGitHubPlanName), and the display gate (shouldDisplayGitHubQuota).
* Depends only on the sibling scalar/quota leaves + the GitHub internal-user
* header profile — no host coupling — so it lives as a co-located provider leaf.
* usage.ts imports getGitHubUsage (dispatcher) + re-exports the helpers via
* __testing (existing usage-utils / usage-service-hardening suites read them
* from there). Behavior-preserving move.
*/
import { getGitHubCopilotInternalUserHeaders } from "../../config/providerHeaderProfiles.ts";
import { toRecord, toNumber, getFieldValue, clampPercentage, toDisplayLabel } from "./scalars.ts";
import { type UsageQuota, parseResetTime } from "./quota.ts";
type JsonRecord = Record<string, unknown>;
export 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;
}
/**
* GitHub Copilot Usage
* Uses GitHub accessToken (not copilotToken) to call copilot_internal/user API
*/
export async function getGitHubUsage(accessToken?: string, providerSpecificData?: JsonRecord) {
try {
if (!accessToken) {
throw new Error("No GitHub access token available. Please re-authorize the connection.");
}
// copilot_internal/user API requires GitHub OAuth token, not copilotToken
const response = await fetch("https://api.github.com/copilot_internal/user", {
headers: getGitHubCopilotInternalUserHeaders(`token ${accessToken}`),
});
if (!response.ok) {
const error = await response.text();
if (response.status === 401 || response.status === 403) {
return {
message: `GitHub token expired or permission denied. Please re-authenticate the connection.`,
};
}
throw new Error(`GitHub API error: ${error}`);
}
const data = await response.json();
const dataRecord = toRecord(data);
// Handle different response formats (paid vs free)
if (dataRecord.quota_snapshots) {
// Paid plan format
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: inferGitHubPlanName(dataRecord, premiumQuota),
resetDate: getFieldValue(dataRecord, "quota_reset_date", "quotaResetDate"),
quotas,
};
} else if (dataRecord.monthly_quotas || dataRecord.limited_user_quotas) {
// Free/limited plan format. NOTE (#2876): the upstream field
// `limited_user_quotas[name]` is the *remaining* count for the month
// (it counts down toward 0 and resets on `limited_user_reset_date`),
// NOT the used count. The pre-3.8.6 implementation inverted this and
// showed "0% when not used / 100% when fully used" on the dashboard.
// Confirmed against three independent upstream parsers:
// - robinebers/openusage docs/providers/copilot.md (Free Tier table)
// - raycast/extensions agent-usage/src/copilot/fetcher.ts (inline comment)
// - looplj/axonhub frontend/src/components/quota-badges.tsx
const monthlyQuotas = toRecord(dataRecord.monthly_quotas);
const remainingQuotas = 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);
if (total <= 0) return null;
const remainingRaw = Math.max(0, toNumber(getFieldValue(remainingQuotas, name, name), 0));
const remaining = Math.min(remainingRaw, total);
const used = Math.max(total - remaining, 0);
quotas[name] = {
used,
total,
remaining,
remainingPercentage: clampPercentage((remaining / total) * 100),
unlimited: false,
resetAt,
};
return quotas[name];
};
const premiumQuota = addLimitedQuota("premium_interactions");
addLimitedQuota("chat");
addLimitedQuota("completions");
return {
plan: inferGitHubPlanName(dataRecord, premiumQuota),
resetDate,
quotas,
};
}
return { message: "GitHub Copilot connected. Unable to parse quota data." };
} catch (error) {
throw new Error(`Failed to fetch GitHub usage: ${error.message}`);
}
}
export function formatGitHubQuotaSnapshot(
quota: unknown,
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: Math.max(0, used ?? 0),
total,
remaining,
remainingPercentage,
resetAt,
unlimited,
};
}
export 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";
}