diff --git a/CHANGELOG.md b/CHANGELOG.md index ed4614ce..ac20501b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# Unreleased + +## Fixes +- Gemini CLI: reuse stored OAuth project IDs for quota checks and show clearer setup guidance when the project is missing (#1271) + # v0.4.59 (2026-05-21) ## Fixes @@ -508,4 +513,4 @@ # v0.3.89 (2026-04-13) ## Improvements -- Improved dashboard access control by blocking tunnel/Tailscale access when disabled \ No newline at end of file +- Improved dashboard access control by blocking tunnel/Tailscale access when disabled diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js index 3144302c..26a10f72 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -58,13 +58,17 @@ const CLAUDE_CONFIG = { * @returns {Object} Usage data with quotas */ export async function getUsageForProvider(connection, proxyOptions = null) { - const { provider, accessToken, apiKey, providerSpecificData } = connection; + const { provider, accessToken, apiKey, providerSpecificData, projectId } = connection; + const providerDataWithProjectId = { + ...(providerSpecificData || {}), + ...(projectId ? { projectId } : {}), + }; switch (provider) { case "github": return await getGitHubUsage(accessToken, providerSpecificData, proxyOptions); case "gemini-cli": - return await getGeminiUsage(accessToken, providerSpecificData, proxyOptions); + return await getGeminiUsage(accessToken, providerDataWithProjectId, proxyOptions); case "antigravity": return await getAntigravityUsage(accessToken, providerSpecificData, proxyOptions); case "claude": @@ -222,18 +226,22 @@ async function getGeminiUsage(accessToken, providerSpecificData, proxyOptions = } try { - // Resolve project id: prefer connection-stored id, else loadCodeAssist lookup - let projectId = providerSpecificData?.projectId || null; + // Resolve project id: prefer connection-stored id, else loadCodeAssist lookup. + // #1271: OAuth save stores projectId on the connection, not providerSpecificData. + let projectId = normalizeCloudCodeProjectId(providerSpecificData?.projectId); let plan = "Free"; if (!projectId) { const subInfo = await getGeminiSubscriptionInfo(accessToken, proxyOptions); - projectId = subInfo?.cloudaicompanionProject || null; + projectId = normalizeCloudCodeProjectId(subInfo?.cloudaicompanionProject); plan = subInfo?.currentTier?.name || plan; } if (!projectId) { - return { plan, message: "Gemini CLI project ID not available." }; + return { + plan, + message: "Gemini CLI project ID not available. Reconnect Gemini CLI, or configure a Google Cloud project with Gemini Code Assist access before checking quota.", + }; } const controller = new AbortController(); @@ -289,6 +297,14 @@ async function getGeminiUsage(accessToken, providerSpecificData, proxyOptions = } } +function normalizeCloudCodeProjectId(project) { + if (typeof project === "string") return project.trim() || null; + if (project && typeof project === "object" && typeof project.id === "string") { + return project.id.trim() || null; + } + return null; +} + /** * Get Gemini CLI subscription info via loadCodeAssist */ diff --git a/tests/unit/gemini-usage-projectid.test.js b/tests/unit/gemini-usage-projectid.test.js new file mode 100644 index 00000000..0d06f2ca --- /dev/null +++ b/tests/unit/gemini-usage-projectid.test.js @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../open-sse/utils/proxyFetch.js", () => ({ + proxyAwareFetch: vi.fn(), +})); + +import { proxyAwareFetch } from "../../open-sse/utils/proxyFetch.js"; +import { getUsageForProvider } from "../../open-sse/services/usage.js"; + +function jsonResponse(body, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("Gemini CLI usage project id resolution", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("uses the projectId stored on the provider connection", async () => { + proxyAwareFetch.mockResolvedValueOnce(jsonResponse({ + buckets: [ + { + modelId: "gemini-3-flash-preview", + remainingFraction: 0.75, + resetTime: "2026-05-25T12:00:00Z", + }, + ], + })); + + const usage = await getUsageForProvider({ + provider: "gemini-cli", + accessToken: "token", + projectId: "cloud-code-project", + }); + + expect(proxyAwareFetch).toHaveBeenCalledTimes(1); + expect(proxyAwareFetch).toHaveBeenCalledWith( + "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota", + expect.objectContaining({ + body: JSON.stringify({ project: "cloud-code-project" }), + }), + null + ); + expect(usage.quotas["gemini-3-flash-preview"]).toMatchObject({ + used: 250, + total: 1000, + remainingPercentage: 75, + }); + }); + + it("normalizes project objects returned by loadCodeAssist", async () => { + proxyAwareFetch + .mockResolvedValueOnce(jsonResponse({ + cloudaicompanionProject: { id: "project-from-load" }, + currentTier: { name: "Free" }, + })) + .mockResolvedValueOnce(jsonResponse({ buckets: [] })); + + await getUsageForProvider({ + provider: "gemini-cli", + accessToken: "token", + }); + + expect(proxyAwareFetch).toHaveBeenLastCalledWith( + "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota", + expect.objectContaining({ + body: JSON.stringify({ project: "project-from-load" }), + }), + null + ); + }); + + it("returns actionable guidance when no project id is available", async () => { + proxyAwareFetch.mockResolvedValueOnce(jsonResponse({})); + + const usage = await getUsageForProvider({ + provider: "gemini-cli", + accessToken: "token", + }); + + expect(usage.message).toContain("Reconnect Gemini CLI"); + expect(usage.message).toContain("Gemini Code Assist"); + }); +});