diff --git a/.changeset/fn-7806-gemini-usage.md b/.changeset/fn-7806-gemini-usage.md new file mode 100644 index 0000000000..1a12bd2cde --- /dev/null +++ b/.changeset/fn-7806-gemini-usage.md @@ -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. diff --git a/packages/dashboard/src/__tests__/usage.test.ts b/packages/dashboard/src/__tests__/usage.test.ts index 311974993a..928848c0e0 100644 --- a/packages/dashboard/src/__tests__/usage.test.ts +++ b/packages/dashboard/src/__tests__/usage.test.ts @@ -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"); }); }); diff --git a/packages/dashboard/src/usage.ts b/packages/dashboard/src/usage.ts index 75a5c3051c..33460500d7 100644 --- a/packages/dashboard/src/usage.ts +++ b/packages/dashboard/src/usage.ts @@ -1341,7 +1341,14 @@ async function fetchGeminiUsage(): Promise { 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 { ); 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; }