FN-7806: hide Gemini usage card when unconfigured or unauthenticated

Reclassifies Gemini usage fetch outcomes so unconfigured/unauthenticated Gemini no longer shows a noisy error card in the usage dropdown; transient failures of a configured token still surface as errors.

- fetchGeminiUsage() in packages/dashboard/src/usage.ts now sets status to `no-auth` (instead of `error`) for unsupported auth types (api-key/vertex-ai) and for HTTP 401/403 auth-expired responses, so fetchAllProviderUsage omits Gemini from the aggregate list in those cases
- HTTP 5xx, network, timeout, and parse failures for a configured Gemini token remain `error` and visible, per the existing FN-7798 keep-auth-expired-visible convention for other providers
- Added FNXC:UsageProviders comments documenting why Gemini deliberately diverges from that convention
- Updated packages/dashboard/src/__tests__/usage.test.ts to cover the new no-auth classification
- Added changeset .changeset/fn-7806-gemini-usage.md (patch) documenting the fix for release notes

Files changed:
 .changeset/fn-7806-gemini-usage.md             |   7 +
 packages/dashboard/src/__tests__/usage.test.ts | 211 +++++++++++++++----------
 packages/dashboard/src/usage.ts                |  15 +-
 3 files changed, 148 insertions(+), 85 deletions(-)

Fusion-Task-Id: FN-7806

Fusion-Task-Lineage: e86b23ea-14e9-472d-8b44-3951fa02ae6c

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-10 22:03:15 -07:00
parent 60b8b4e4c5
commit c1b14c2d08
3 changed files with 148 additions and 85 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Usage view now hides Gemini when it isn't configured for metering or its login has expired.
category: fix
dev: fetchGeminiUsage() in packages/dashboard/src/usage.ts reclassifies the unsupported-auth-type (api-key/vertex-ai) and HTTP 401/403 outcomes from error→no-auth so fetchAllProviderUsage omits Gemini; transient failures (HTTP 5xx/network/timeout) of a configured token remain visible as error.

View File

@@ -2614,11 +2614,74 @@ describe("usage", () => {
});
describe("Gemini provider", () => {
const setupGeminiFiles = (options: { selectedType?: string; accessToken?: string | null } = {}) => {
const { selectedType, accessToken = "test-token" } = options;
mockReadFile.mockImplementation((filePath: string) => {
if (filePath.includes("gemini")) {
if (filePath.includes("oauth_creds")) {
return JSON.stringify({
...(accessToken ? { access_token: accessToken } : {}),
id_token: "header.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.signature",
});
}
if (filePath.includes("settings") && selectedType) {
return JSON.stringify({
security: {
auth: {
selectedType,
},
},
});
}
}
return Promise.reject(new Error("File not found"));
});
};
const mockGeminiResponse = (statusCode: number, body: unknown = {}) => {
const mockReq = { on: vi.fn(), write: vi.fn(), end: vi.fn(), destroy: vi.fn() };
mockRequest.mockImplementation((_options: any, callback: any) => {
const mockRes = {
statusCode,
headers: {},
on: vi.fn((event: string, handler: any) => {
if (event === "data") handler(Buffer.from(typeof body === "string" ? body : JSON.stringify(body)));
if (event === "end") handler();
}),
};
callback(mockRes);
return mockReq;
});
};
const mockGeminiNetworkError = (error: Error) => {
const mockReq = {
on: vi.fn((event: string, handler: any) => {
if (event === "error") queueMicrotask(() => handler(error));
}),
write: vi.fn(),
end: vi.fn(),
destroy: vi.fn(),
};
mockRequest.mockReturnValue(mockReq);
};
it("detects no auth when oauth_creds.json doesn't exist", async () => {
mockReadFile.mockImplementation(async () => {
return Promise.reject(new Error("File not found"));
});
clearUsageCache();
const providers = await fetchAllProviderUsage();
const gemini = providers.find((p) => p.name === "Gemini");
expect(gemini).toBeUndefined();
});
it("detects no auth when oauth_creds.json has no access token", async () => {
setupGeminiFiles({ accessToken: null });
clearUsageCache();
const providers = await fetchAllProviderUsage();
const gemini = providers.find((p) => p.name === "Gemini");
@@ -2641,43 +2704,10 @@ describe("usage", () => {
],
};
mockReadFile.mockImplementation((path: string) => {
if (path.includes("gemini")) {
if (path.includes("oauth_creds")) {
return JSON.stringify({
access_token: "test-token",
id_token: "header.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.signature",
});
}
// settings.json doesn't exist (oauth-personal is default)
return Promise.reject(new Error("File not found"));
}
return Promise.reject(new Error("File not found"));
});
const mockReq = {
on: vi.fn(),
write: vi.fn(),
end: vi.fn(),
};
mockRequest.mockImplementation((options: any, callback: any) => {
const mockRes = {
statusCode: 200,
headers: {},
on: vi.fn((event: string, handler: any) => {
if (event === "data") {
handler(Buffer.from(JSON.stringify(mockResponse)));
}
if (event === "end") {
handler();
}
}),
};
callback(mockRes);
return mockReq;
});
setupGeminiFiles();
mockGeminiResponse(200, mockResponse);
clearUsageCache();
const providers = await fetchAllProviderUsage();
const gemini = providers.find((p) => p.name === "Gemini")!;
@@ -2703,33 +2733,10 @@ describe("usage", () => {
],
};
mockReadFile.mockImplementation((path: string) => {
if (path.includes("gemini")) {
if (path.includes("oauth_creds")) {
return JSON.stringify({
access_token: "test-token",
id_token: "header.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.signature",
});
}
return Promise.reject(new Error("File not found"));
}
return Promise.reject(new Error("File not found"));
});
const mockReq = { on: vi.fn(), write: vi.fn(), end: vi.fn() };
mockRequest.mockImplementation((_options: any, callback: any) => {
const mockRes = {
statusCode: 200,
headers: {},
on: vi.fn((event: string, handler: any) => {
if (event === "data") handler(Buffer.from(JSON.stringify(mockResponse)));
if (event === "end") handler();
}),
};
callback(mockRes);
return mockReq;
});
setupGeminiFiles();
mockGeminiResponse(200, mockResponse);
clearUsageCache();
const providers = await fetchAllProviderUsage();
const gemini = providers.find((p) => p.name === "Gemini")!;
@@ -2738,32 +2745,70 @@ describe("usage", () => {
expect(flashWindow.resetAt).toBe(new Date(resetTime).toISOString());
});
it("handles unsupported auth type (api-key)", async () => {
mockReadFile.mockImplementation((path: string) => {
if (path.includes("gemini")) {
if (path.includes("oauth_creds")) {
return JSON.stringify({
access_token: "test-token",
});
}
if (path.includes("settings")) {
return JSON.stringify({
security: {
auth: {
selectedType: "api-key",
},
},
});
}
}
return Promise.reject(new Error("File not found"));
});
it("omits unsupported auth type (api-key)", async () => {
setupGeminiFiles({ selectedType: "api-key" });
clearUsageCache();
const providers = await fetchAllProviderUsage();
const gemini = providers.find((p) => p.name === "Gemini");
expect(gemini).toBeUndefined();
});
it("omits unsupported auth type (vertex-ai)", async () => {
setupGeminiFiles({ selectedType: "vertex-ai" });
clearUsageCache();
const providers = await fetchAllProviderUsage();
const gemini = providers.find((p) => p.name === "Gemini");
expect(gemini).toBeUndefined();
});
it("omits Gemini when OAuth token returns 401", async () => {
setupGeminiFiles();
mockGeminiResponse(401, { error: "unauthorized" });
clearUsageCache();
const providers = await fetchAllProviderUsage();
const gemini = providers.find((p) => p.name === "Gemini");
expect(gemini).toBeUndefined();
});
it("omits Gemini when OAuth token returns 403", async () => {
setupGeminiFiles();
mockGeminiResponse(403, { error: "forbidden" });
clearUsageCache();
const providers = await fetchAllProviderUsage();
const gemini = providers.find((p) => p.name === "Gemini");
expect(gemini).toBeUndefined();
});
it("keeps configured Gemini visible for HTTP 500 failures", async () => {
setupGeminiFiles();
mockGeminiResponse(500, { error: "backend unavailable" });
clearUsageCache();
const providers = await fetchAllProviderUsage();
const gemini = providers.find((p) => p.name === "Gemini")!;
expect(gemini.status).toBe("error");
expect(gemini.error).toContain("Unsupported auth type");
expect(gemini.error).toContain("HTTP 500");
});
it("keeps configured Gemini visible for network failures", async () => {
setupGeminiFiles();
mockGeminiNetworkError(new Error("network error"));
clearUsageCache();
const providers = await fetchAllProviderUsage();
const gemini = providers.find((p) => p.name === "Gemini")!;
expect(gemini.status).toBe("error");
expect(gemini.error).toContain("network error");
});
});

View File

@@ -1341,7 +1341,14 @@ async function fetchGeminiUsage(): Promise<ProviderUsage> {
const settings = JSON.parse(await readFile(settingsPath, "utf-8"));
const authType = settings?.security?.auth?.selectedType;
if (authType === "api-key" || authType === "vertex-ai") {
usage.status = "error";
/*
FNXC:UsageProviders 2026-07-10-12:00:
Gemini appears in usage only when configured for the meterable OAuth path and its token authenticates. Unsupported auth types mean Gemini is not configured for metering, so demote to `no-auth` for the single aggregate filter instead of showing a noisy error card.
FNXC:UsageProviders 2026-07-10-12:00:
Gemini deliberately differs from the general FN-7798 keep-auth-expired-visible rule: stale Gemini CLI logins should not clutter the usage list, while transient failures of a configured OAuth token still stay `error` and visible.
*/
usage.status = "no-auth";
usage.error = `Unsupported auth type: ${authType} (need oauth-personal)`;
return usage;
}
@@ -1363,7 +1370,11 @@ async function fetchGeminiUsage(): Promise<ProviderUsage> {
);
if (res.status === 401 || res.status === 403) {
usage.status = "error";
/*
FNXC:UsageProviders 2026-07-10-12:00:
Gemini auth failures mean the meter cannot authenticate the stored OAuth token, so classify 401/403 as `no-auth` and let `fetchAllProviderUsage` omit Gemini. Keep the diagnostic message for logs; HTTP 5xx, network, timeout, and parse failures remain actionable `error` states for configured Gemini.
*/
usage.status = "no-auth";
usage.error = "Auth expired — run 'gemini' to re-login";
return usage;
}