FN-7798: filter unconfigured/no-entitlement providers from usage view
Only show usage meters for AI providers the user has actually configured, instead of surfacing entries for providers with no meterable data. - fetchGitHubCopilotUsage now demotes GitHub's 404 "No Copilot subscription found" response (both the Fusion-credential HTTP path and the gh-CLI fallback path) to a `no-auth` status instead of `error`, so it is treated as no meterable entitlement. - fetchAllProviderUsage's existing `status !== "no-auth"` filter now also excludes these no-entitlement Copilot results, so they no longer appear in the usage list. - Configured-but-failing providers (expired auth returning 401/403, transient HTTP 5xx, or other errors) keep `status: "error"` and remain visible with their diagnostic message. - Added regression tests covering: Fusion-credential 404 omitted, Fusion-credential 500 surfaced as error, gh-CLI 404 omitted, gh-CLI 401 surfaced as "GitHub auth expired" error. - Added a changeset documenting the usage-view behavior change as a patch/fix. Files changed: .changeset/fn-7798-usage-configured-providers.md | 7 +++ packages/dashboard/src/__tests__/usage.test.ts | 71 ++++++++++++++++++++++-- packages/dashboard/src/usage.ts | 19 ++++++- 3 files changed, 90 insertions(+), 7 deletions(-) Fusion-Task-Id: FN-7798 Fusion-Task-Lineage: 18835b48-68f3-48aa-a03c-cc85772778a9 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7798-usage-configured-providers.md
Normal file
7
.changeset/fn-7798-usage-configured-providers.md
Normal file
@@ -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.
|
||||||
@@ -256,6 +256,28 @@ describe("usage", () => {
|
|||||||
expect(copilot).toBeUndefined();
|
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 () => {
|
it("surfaces Fusion re-login guidance when Fusion-sourced token gets 401", async () => {
|
||||||
coreInteropMocks.readStoredCredentialsFromAuthFile.mockReturnValue({
|
coreInteropMocks.readStoredCredentialsFromAuthFile.mockReturnValue({
|
||||||
"github-copilot": { type: "oauth", access: "fusion-gho", refresh: "r", expires: Date.now() + 60_000 },
|
"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");
|
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 () => {
|
it("falls back to gh CLI when no Fusion credential is present", async () => {
|
||||||
coreInteropMocks.readStoredCredentialsFromAuthFile.mockReturnValue({});
|
coreInteropMocks.readStoredCredentialsFromAuthFile.mockReturnValue({});
|
||||||
mockExecFileSync.mockImplementation((cmd: string, args: string[]) => {
|
mockExecFileSync.mockImplementation((cmd: string, args: string[]) => {
|
||||||
@@ -299,8 +344,9 @@ describe("usage", () => {
|
|||||||
expect(copilot!.windows.some((window) => window.label === "Chat (Monthly)")).toBe(true);
|
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"));
|
mockReadFile.mockRejectedValue(new Error("File not found"));
|
||||||
|
coreInteropMocks.readStoredCredentialsFromAuthFile.mockReturnValue({});
|
||||||
mockExecFileSync.mockImplementation((cmd: string, args: string[]) => {
|
mockExecFileSync.mockImplementation((cmd: string, args: string[]) => {
|
||||||
if (cmd === "gh" && args[0] === "auth") {
|
if (cmd === "gh" && args[0] === "auth") {
|
||||||
return "";
|
return "";
|
||||||
@@ -313,9 +359,26 @@ describe("usage", () => {
|
|||||||
|
|
||||||
const providers = await fetchAllProviderUsage();
|
const providers = await fetchAllProviderUsage();
|
||||||
const copilot = providers.find((p) => p.name === "GitHub Copilot");
|
const copilot = providers.find((p) => p.name === "GitHub Copilot");
|
||||||
expect(copilot).toBeDefined();
|
expect(copilot).toBeUndefined();
|
||||||
expect(copilot!.status).toBe("error");
|
});
|
||||||
expect(copilot!.error).toContain("No Copilot subscription");
|
|
||||||
|
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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1808,12 +1808,18 @@ async function fetchGitHubCopilotUsage(): Promise<ProviderUsage> {
|
|||||||
return usage;
|
return usage;
|
||||||
}
|
}
|
||||||
|
|
||||||
usage.status = "error";
|
|
||||||
if (res.status === 404) {
|
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";
|
usage.error = "No Copilot subscription found";
|
||||||
} else if (res.status === 401 || res.status === 403) {
|
} else if (res.status === 401 || res.status === 403) {
|
||||||
|
usage.status = "error";
|
||||||
usage.error = "Auth expired — re-login from Fusion Settings → Authentication";
|
usage.error = "Auth expired — re-login from Fusion Settings → Authentication";
|
||||||
} else {
|
} else {
|
||||||
|
usage.status = "error";
|
||||||
usage.error = `HTTP ${res.status}: ${res.body.slice(0, 200)}`;
|
usage.error = `HTTP ${res.status}: ${res.body.slice(0, 200)}`;
|
||||||
}
|
}
|
||||||
return usage;
|
return usage;
|
||||||
@@ -1841,7 +1847,11 @@ async function fetchGitHubCopilotUsage(): Promise<ProviderUsage> {
|
|||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const errMsg = e instanceof Error ? e.message : "Failed to fetch";
|
const errMsg = e instanceof Error ? e.message : "Failed to fetch";
|
||||||
if (errMsg.includes("404") || errMsg.includes("Not Found")) {
|
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";
|
usage.error = "No Copilot subscription found";
|
||||||
} else if (errMsg.includes("401") || errMsg.includes("403")) {
|
} else if (errMsg.includes("401") || errMsg.includes("403")) {
|
||||||
usage.status = "error";
|
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");
|
const authenticatedProviders = providers.filter((provider) => provider.status !== "no-auth");
|
||||||
|
|
||||||
// Update cache
|
// Update cache
|
||||||
|
|||||||
Reference in New Issue
Block a user