From 84fd18726d7c77a53491af4df84a2131a618367d Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:25:00 -0300 Subject: [PATCH] fix(usage): reuse Gemini CLI project ID for quota checks (#4454) Integrated into release/v3.8.32 --- CHANGELOG.md | 1 + open-sse/services/usage.ts | 46 +++++-- tests/unit/gemini-usage-projectid.test.ts | 145 ++++++++++++++++++++++ 3 files changed, 183 insertions(+), 9 deletions(-) create mode 100644 tests/unit/gemini-usage-projectid.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 104260da2a..fde446367b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ _In development — bullets added per PR; finalized at release._ - **fix(embeddings):** forward output dimensions to Gemini for consistent embedding dims. (thanks @nguyenha935) - **fix(translator):** sanitize Read tool args from non-Anthropic models to prevent retry loops. (thanks @GodrezJr2) +- **fix(usage):** reuse Gemini CLI project ID for quota checks (avoid re-discovery). (thanks @Delcado19) - **fix(combo): round-robin members fail over faster under concurrency saturation via a configurable queue depth** — when a round-robin combo member was saturated, requests sat in the per-model semaphore's **unbounded** queue and only failed over to the next member after the full `queueTimeoutMs` (default 30s) elapsed — so a burst of agentic requests deep-queued one hot member instead of spilling to healthy ones. The per-model semaphore now accepts a bounded queue depth and emits `SEMAPHORE_QUEUE_FULL` once it is full (the round-robin loop already cascades on that code), so a configured low depth fails over immediately. A new `queueDepth` combo-config knob (global default / provider override / per-combo, default **20** for backward compatibility; **0** = never queue → fail over now) is exposed in Settings → Combo Defaults. ([#3872](https://github.com/diegosouzapw/OmniRoute/issues/3872) — thanks @KooshaPari) --- diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index b8a52aecfe..7286d10d87 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -1812,6 +1812,23 @@ function inferGitHubPlanName(data: JsonRecord, premiumQuota: UsageQuota | null): const _geminiCliSubCache = new Map(); const GEMINI_CLI_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes +/** + * Normalize a Cloud Code project value into a trimmed string (or null). + * The upstream `loadCodeAssist` endpoint returns the project either as a bare + * string or as an object of the form `{ id: "..." }`, and stored connection + * project ids can carry stray whitespace. Centralized here so the Gemini CLI + * usage path matches the executor/oauth normalization already shipped in + * `open-sse/executors/gemini-cli.ts` and `src/lib/oauth/services/gemini.ts`. + */ +function normalizeCloudCodeProjectId(project: unknown): string | null { + if (typeof project === "string") return project.trim() || null; + if (project && typeof project === "object") { + const candidate = (project as { id?: unknown }).id; + if (typeof candidate === "string") return candidate.trim() || null; + } + return null; +} + /** * Gemini CLI Usage — fetch per-model quota from Cloud Code Assist API. * Gemini CLI and Antigravity share the same upstream (cloudcode-pa.googleapis.com), @@ -1827,17 +1844,28 @@ async function getGeminiUsage( } try { - const subscriptionInfo = await getGeminiCliSubscriptionInfoCached(accessToken); - const projectId = - connectionProjectId || - providerSpecificData?.projectId || - toRecord(subscriptionInfo).cloudaicompanionProject || - null; - - const plan = getGeminiCliPlanLabel(subscriptionInfo); + // #1271: the OAuth save path stores `projectId` on the connection (not always in + // `providerSpecificData`), and `loadCodeAssist` may return the project either as a + // bare string or wrapped in `{ id: "..." }`. Normalize both so the quota lookup + // reuses the stored project id and skips a redundant `loadCodeAssist` round-trip + // when it is already known. + let projectId = + normalizeCloudCodeProjectId(connectionProjectId) || + normalizeCloudCodeProjectId(providerSpecificData?.projectId); + let plan = "Free"; if (!projectId) { - return { plan, message: "Gemini CLI project ID not available." }; + const subscriptionInfo = await getGeminiCliSubscriptionInfoCached(accessToken); + projectId = normalizeCloudCodeProjectId(toRecord(subscriptionInfo).cloudaicompanionProject); + plan = getGeminiCliPlanLabel(subscriptionInfo); + } + + if (!projectId) { + 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.", + }; } // Use retrieveUserQuota (same endpoint as Gemini CLI /stats command). diff --git a/tests/unit/gemini-usage-projectid.test.ts b/tests/unit/gemini-usage-projectid.test.ts new file mode 100644 index 0000000000..f35816c549 --- /dev/null +++ b/tests/unit/gemini-usage-projectid.test.ts @@ -0,0 +1,145 @@ +/** + * Ported from decolua/9router#1428 by @Delcado19 — reuses stored Gemini CLI + * project IDs for quota checks and normalizes {id: ...} object shapes that + * loadCodeAssist returns. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const usage = await import("../../open-sse/services/usage.ts"); + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +test("getUsageForProvider(gemini-cli) reuses the projectId stored on the connection", async () => { + const originalFetch = globalThis.fetch; + const calls: { url: string; init?: RequestInit }[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + calls.push({ url, init }); + return jsonResponse({ + buckets: [ + { + modelId: "gemini-3-flash-preview", + remainingFraction: 0.75, + resetTime: "2026-05-25T12:00:00Z", + }, + ], + }); + }) as typeof fetch; + + try { + const result = (await usage.getUsageForProvider({ + id: "gemini-cli-stored", + provider: "gemini-cli", + accessToken: "token", + projectId: "cloud-code-project", + })) as { quotas?: Record }; + + // Only the retrieveUserQuota call — no loadCodeAssist round-trip, + // because the stored projectId short-circuits it. + assert.equal(calls.length, 1, "must skip loadCodeAssist when projectId is stored"); + assert.equal( + calls[0].url, + "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota" + ); + assert.equal( + JSON.parse(String(calls[0].init?.body)).project, + "cloud-code-project", + "must pass the stored projectId into retrieveUserQuota" + ); + assert.equal(result.quotas?.["gemini-3-flash-preview"]?.remainingPercentage, 75); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("getUsageForProvider(gemini-cli) normalizes project objects returned by loadCodeAssist", async () => { + const originalFetch = globalThis.fetch; + const calls: { url: string; init?: RequestInit }[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + calls.push({ url, init }); + if (url.endsWith("loadCodeAssist")) { + return jsonResponse({ + cloudaicompanionProject: { id: "project-from-load" }, + currentTier: { name: "Free" }, + }); + } + return jsonResponse({ buckets: [] }); + }) as typeof fetch; + + try { + await usage.getUsageForProvider({ + id: "gemini-cli-obj-shape", + provider: "gemini-cli", + accessToken: "token-obj-shape", + }); + + const quotaCall = calls.find((c) => c.url.endsWith("retrieveUserQuota")); + assert.ok(quotaCall, "quota lookup must occur after loadCodeAssist resolves"); + assert.equal( + JSON.parse(String(quotaCall!.init?.body)).project, + "project-from-load", + "must unwrap {id: ...} into the bare project id" + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("getUsageForProvider(gemini-cli) trims whitespace-padded stored project ids", async () => { + const originalFetch = globalThis.fetch; + const calls: { url: string; init?: RequestInit }[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + calls.push({ url, init }); + return jsonResponse({ buckets: [] }); + }) as typeof fetch; + + try { + await usage.getUsageForProvider({ + id: "gemini-cli-padded", + provider: "gemini-cli", + accessToken: "token-padded", + projectId: " padded-project ", + }); + + const quotaCall = calls.find((c) => c.url.endsWith("retrieveUserQuota")); + assert.ok(quotaCall, "quota lookup must run with the trimmed project id"); + assert.equal(JSON.parse(String(quotaCall!.init?.body)).project, "padded-project"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("getUsageForProvider(gemini-cli) returns actionable guidance when no project id is available", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => jsonResponse({})) as typeof fetch; + + try { + const result = (await usage.getUsageForProvider({ + id: "gemini-cli-no-project", + provider: "gemini-cli", + accessToken: "token-no-project", + })) as { message?: string }; + + assert.ok(result.message, "must surface a message when no projectId is resolvable"); + assert.match( + result.message!, + /Reconnect Gemini CLI/i, + "error must guide the operator to reconnect" + ); + assert.match( + result.message!, + /Gemini Code Assist/i, + "error must mention the Code Assist project requirement" + ); + } finally { + globalThis.fetch = originalFetch; + } +});