feat(FN-2588): add GitHub Copilot usage to provider metrics

- Add a GitHub Copilot usage fetcher to usage collection flow
- Include copilot in provider aggregation so totals include Copilot consumption
- Map the copilot provider to the correct icon key in UsageIndicator
- Expand usage tests to cover Copilot provider parsing and aggregation behavior
This commit is contained in:
Fusion
2026-04-26 10:53:19 -07:00
committed by gsxdsm
parent b94ce526b5
commit 5ac61d8b58
3 changed files with 148 additions and 3 deletions

View File

@@ -56,6 +56,9 @@ describe("usage", () => {
mockRequest.mockClear();
mockReadFile.mockClear();
mockExecFileSync.mockClear();
mockExecFileSync.mockImplementation(() => {
throw new Error("File not found");
});
vi.stubEnv("HOME", "/home/testuser");
});
@@ -72,12 +75,13 @@ describe("usage", () => {
const providers = await fetchAllProviderUsage();
expect(providers).toHaveLength(5);
expect(providers).toHaveLength(6);
expect(providers.map((p) => p.name)).toContain("Claude");
expect(providers.map((p) => p.name)).toContain("Codex");
expect(providers.map((p) => p.name)).toContain("Gemini");
expect(providers.map((p) => p.name)).toContain("Minimax");
expect(providers.map((p) => p.name)).toContain("Zai");
expect(providers.map((p) => p.name)).toContain("GitHub Copilot");
expect(providers.map((p) => p.name)).not.toContain("Kimi");
// All should be no-auth status
@@ -113,7 +117,63 @@ describe("usage", () => {
// Should be different array reference
expect(second).not.toBe(first);
expect(second).toHaveLength(5);
expect(second).toHaveLength(6);
});
});
describe("fetchGitHubCopilotUsage (via fetchAllProviderUsage)", () => {
it("returns no-auth when gh auth status fails", async () => {
mockReadFile.mockRejectedValue(new Error("File not found"));
mockExecFileSync.mockImplementation((cmd: string, args: string[]) => {
if (cmd === "gh" && args[0] === "auth") {
throw new Error("not logged in");
}
throw new Error("File not found");
});
const providers = await fetchAllProviderUsage();
const copilot = providers.find((p) => p.name === "GitHub Copilot");
expect(copilot).toBeDefined();
expect(copilot!.status).toBe("no-auth");
expect(copilot!.error).toContain("not authenticated");
});
it("returns ok with plan when gh api succeeds", async () => {
mockReadFile.mockRejectedValue(new Error("File not found"));
mockExecFileSync.mockImplementation((cmd: string, args: string[]) => {
if (cmd === "gh" && args[0] === "auth") {
return "";
}
if (cmd === "gh" && args[0] === "api" && args[1] === "/user/copilot") {
return JSON.stringify({ copilot_plan_type: "individual" });
}
throw new Error("File not found");
});
const providers = await fetchAllProviderUsage();
const copilot = providers.find((p) => p.name === "GitHub Copilot");
expect(copilot).toBeDefined();
expect(copilot!.status).toBe("ok");
expect(copilot!.plan).toBe("Individual");
});
it("returns error when Copilot subscription not found (404)", async () => {
mockReadFile.mockRejectedValue(new Error("File not found"));
mockExecFileSync.mockImplementation((cmd: string, args: string[]) => {
if (cmd === "gh" && args[0] === "auth") {
return "";
}
if (cmd === "gh" && args[0] === "api") {
throw new Error("HTTP 404: Not Found");
}
throw new Error("File not found");
});
const providers = await fetchAllProviderUsage();
const copilot = providers.find((p) => p.name === "GitHub Copilot");
expect(copilot).toBeDefined();
expect(copilot!.status).toBe("error");
expect(copilot!.error).toContain("No Copilot subscription");
});
});

View File

@@ -1537,6 +1537,87 @@ async function fetchZaiUsage(authStorage?: AuthStorageLike): Promise<ProviderUsa
return usage;
}
// ── GitHub Copilot fetcher ──────────────────────────────────────────────────
async function fetchGitHubCopilotUsage(): Promise<ProviderUsage> {
const usage: ProviderUsage = {
name: "GitHub Copilot",
icon: "⚫",
status: "no-auth",
windows: [],
};
try {
await execFileAsync("gh", ["auth", "status"], { encoding: "utf-8", timeout: 5000 });
} catch {
usage.error = "GitHub CLI not authenticated — run 'gh auth login'";
return usage;
}
try {
const { stdout } = await execFileAsync("gh", ["api", "/user/copilot", "--jq", "."], {
encoding: "utf-8",
timeout: 10000,
});
const data = JSON.parse(stdout.trim());
usage.status = "ok";
if (data.seat_management_setting) {
usage.plan = data.seat_management_setting;
}
const planType: string | undefined = data.copilot_plan_type || data.plan_type;
if (planType) {
usage.plan = planType.charAt(0).toUpperCase() + planType.slice(1);
}
if (data.copilot_plan_type === "free" || data.plan_type === "free") {
if (data.chat_messages_used !== undefined && data.chat_messages_limit !== undefined) {
const chatPct =
data.chat_messages_limit > 0
? (data.chat_messages_used / data.chat_messages_limit) * 100
: 0;
usage.windows.push({
label: "Chat (Monthly)",
percentUsed: Math.min(100, Math.max(0, chatPct)),
percentLeft: Math.min(100, Math.max(0, 100 - chatPct)),
resetText: null,
windowDurationMs: 30 * 24 * 60 * 60 * 1000,
});
}
if (data.completions_used !== undefined && data.completions_limit !== undefined) {
const completionPct =
data.completions_limit > 0
? (data.completions_used / data.completions_limit) * 100
: 0;
usage.windows.push({
label: "Completions (Monthly)",
percentUsed: Math.min(100, Math.max(0, completionPct)),
percentLeft: Math.min(100, Math.max(0, 100 - completionPct)),
resetText: null,
windowDurationMs: 30 * 24 * 60 * 60 * 1000,
});
}
}
} catch (e: unknown) {
const errMsg = e instanceof Error ? e.message : "Failed to fetch";
if (errMsg.includes("404") || errMsg.includes("Not Found")) {
usage.status = "error";
usage.error = "No Copilot subscription found";
} else if (errMsg.includes("401") || errMsg.includes("403")) {
usage.status = "error";
usage.error = "GitHub auth expired — run 'gh auth login'";
} else {
usage.status = "error";
usage.error = errMsg;
}
}
return usage;
}
// ── Main export ────────────────────────────────────────────────────────────
/**
@@ -1598,13 +1679,14 @@ export async function fetchAllProviderUsage(authStorage?: AuthStorageLike): Prom
}
// Fetch all providers in parallel with per-provider timeout
// Currently includes: Claude, Codex, Gemini, Minimax, Zai
// Currently includes: Claude, Codex, Gemini, Minimax, Zai, GitHub Copilot
const results = await Promise.allSettled([
withTimeout(fetchClaudeUsage(), "Claude", CLAUDE_FETCH_TIMEOUT_MS),
withTimeout(fetchCodexUsage(), "Codex"),
withTimeout(fetchGeminiUsage(), "Gemini"),
withTimeout(fetchMinimaxUsage(authStorage), "Minimax"),
withTimeout(fetchZaiUsage(authStorage), "Zai"),
withTimeout(fetchGitHubCopilotUsage(), "GitHub Copilot"),
]);
const providers: ProviderUsage[] = [];