diff --git a/.changeset/fn-7798-usage-configured-providers.md b/.changeset/fn-7798-usage-configured-providers.md new file mode 100644 index 0000000000..4e24c4e3dc --- /dev/null +++ b/.changeset/fn-7798-usage-configured-providers.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Usage view now shows meters only for AI providers you have configured. +category: fix +dev: fetchAllProviderUsage() in packages/dashboard/src/usage.ts filters providers with no resolved credentials and no meterable entitlement (e.g. GitHub 404 "No Copilot subscription found" reclassified error→no-auth); configured-but-failing providers (auth expired / HTTP 5xx / timeout) remain visible. diff --git a/packages/dashboard/src/__tests__/usage.test.ts b/packages/dashboard/src/__tests__/usage.test.ts index a74ce4a20e..311974993a 100644 --- a/packages/dashboard/src/__tests__/usage.test.ts +++ b/packages/dashboard/src/__tests__/usage.test.ts @@ -256,6 +256,28 @@ describe("usage", () => { expect(copilot).toBeUndefined(); }); + it("omits Fusion-sourced Copilot credentials when GitHub reports no subscription", async () => { + coreInteropMocks.readStoredCredentialsFromAuthFile.mockReturnValue({ + "github-copilot": { type: "oauth", access: "fusion-gho", refresh: "r", expires: Date.now() + 60_000 }, + }); + mockRequest.mockImplementation((_options: any, callback: any) => { + const mockRes = { + statusCode: 404, + headers: {}, + on: vi.fn((event: string, handler: any) => { + if (event === "data") handler(Buffer.from('{"message":"No Copilot subscription found"}')); + if (event === "end") handler(); + }), + }; + callback(mockRes); + return mockReq; + }); + + const providers = await fetchAllProviderUsage(); + const copilot = providers.find((p) => p.name === "GitHub Copilot"); + expect(copilot).toBeUndefined(); + }); + it("surfaces Fusion re-login guidance when Fusion-sourced token gets 401", async () => { coreInteropMocks.readStoredCredentialsFromAuthFile.mockReturnValue({ "github-copilot": { type: "oauth", access: "fusion-gho", refresh: "r", expires: Date.now() + 60_000 }, @@ -279,6 +301,29 @@ describe("usage", () => { expect(copilot?.error).toContain("re-login from Fusion Settings"); }); + it("surfaces Fusion HTTP failures when a configured Copilot provider fails transiently", async () => { + coreInteropMocks.readStoredCredentialsFromAuthFile.mockReturnValue({ + "github-copilot": { type: "oauth", access: "fusion-gho", refresh: "r", expires: Date.now() + 60_000 }, + }); + mockRequest.mockImplementation((_options: any, callback: any) => { + const mockRes = { + statusCode: 500, + headers: {}, + on: vi.fn((event: string, handler: any) => { + if (event === "data") handler(Buffer.from('{"message":"server unavailable"}')); + if (event === "end") handler(); + }), + }; + callback(mockRes); + return mockReq; + }); + + const providers = await fetchAllProviderUsage(); + const copilot = providers.find((p) => p.name === "GitHub Copilot"); + expect(copilot?.status).toBe("error"); + expect(copilot?.error).toContain("HTTP 500"); + }); + it("falls back to gh CLI when no Fusion credential is present", async () => { coreInteropMocks.readStoredCredentialsFromAuthFile.mockReturnValue({}); mockExecFileSync.mockImplementation((cmd: string, args: string[]) => { @@ -299,8 +344,9 @@ describe("usage", () => { expect(copilot!.windows.some((window) => window.label === "Chat (Monthly)")).toBe(true); }); - it("returns error when Copilot subscription not found (404)", async () => { + it("omits gh CLI Copilot when GitHub reports no subscription", async () => { mockReadFile.mockRejectedValue(new Error("File not found")); + coreInteropMocks.readStoredCredentialsFromAuthFile.mockReturnValue({}); mockExecFileSync.mockImplementation((cmd: string, args: string[]) => { if (cmd === "gh" && args[0] === "auth") { return ""; @@ -313,9 +359,26 @@ describe("usage", () => { 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"); + expect(copilot).toBeUndefined(); + }); + + it("surfaces gh CLI auth-expired errors as configured but failing", async () => { + mockReadFile.mockRejectedValue(new Error("File not found")); + coreInteropMocks.readStoredCredentialsFromAuthFile.mockReturnValue({}); + mockExecFileSync.mockImplementation((cmd: string, args: string[]) => { + if (cmd === "gh" && args[0] === "auth") { + return ""; + } + if (cmd === "gh" && args[0] === "api") { + throw new Error("HTTP 401: Bad credentials"); + } + throw new Error("File not found"); + }); + + const providers = await fetchAllProviderUsage(); + const copilot = providers.find((p) => p.name === "GitHub Copilot"); + expect(copilot?.status).toBe("error"); + expect(copilot?.error).toContain("GitHub auth expired"); }); }); diff --git a/packages/dashboard/src/usage.ts b/packages/dashboard/src/usage.ts index 47ca0c5fe0..75a5c3051c 100644 --- a/packages/dashboard/src/usage.ts +++ b/packages/dashboard/src/usage.ts @@ -1808,12 +1808,18 @@ async function fetchGitHubCopilotUsage(): Promise { return usage; } - usage.status = "error"; if (res.status === 404) { + /* + FNXC:UsageProviders 2026-07-10-00:00: + Usage surfaces must only show providers the user configured with meterable data. A GitHub credential without Copilot entitlement is a no-entitlement path, so demote it to `no-auth` for the aggregate filter while keeping diagnostics on the provider result. + */ + usage.status = "no-auth"; usage.error = "No Copilot subscription found"; } else if (res.status === 401 || res.status === 403) { + usage.status = "error"; usage.error = "Auth expired — re-login from Fusion Settings → Authentication"; } else { + usage.status = "error"; usage.error = `HTTP ${res.status}: ${res.body.slice(0, 200)}`; } return usage; @@ -1841,7 +1847,11 @@ async function fetchGitHubCopilotUsage(): Promise { } catch (e: unknown) { const errMsg = e instanceof Error ? e.message : "Failed to fetch"; if (errMsg.includes("404") || errMsg.includes("Not Found")) { - usage.status = "error"; + /* + FNXC:UsageProviders 2026-07-10-00:00: + An authenticated `gh` CLI can exist for ordinary git without Fusion Copilot setup or a Copilot subscription. Treat the 404 no-subscription response as not configured/no meterable entitlement so it is omitted, while 401/403 and transient failures remain visible errors. + */ + usage.status = "no-auth"; usage.error = "No Copilot subscription found"; } else if (errMsg.includes("401") || errMsg.includes("403")) { usage.status = "error"; @@ -1936,7 +1946,10 @@ export async function fetchAllProviderUsage(authStorage?: AuthStorageLike): Prom } } - // Only return providers that have valid auth configured. + /* + FNXC:UsageProviders 2026-07-10-00:00: + This is the single enforcement point for the usage-list invariant: show only providers that are both configured and expose meterable data. Fetchers demote missing credentials and no-entitlement/nothing-to-meter outcomes to `no-auth`; configured providers with actionable failures stay `error` and remain visible. + */ const authenticatedProviders = providers.filter((provider) => provider.status !== "no-auth"); // Update cache