mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-07 15:52:52 +03:00
fix(usage): reuse Gemini CLI project ID for quota checks (#4454)
Integrated into release/v3.8.32
This commit is contained in:
committed by
GitHub
parent
0225a0a02d
commit
84fd18726d
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
@@ -1812,6 +1812,23 @@ function inferGitHubPlanName(data: JsonRecord, premiumQuota: UsageQuota | null):
|
||||
const _geminiCliSubCache = new Map<string, SubscriptionCacheEntry>();
|
||||
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).
|
||||
|
||||
145
tests/unit/gemini-usage-projectid.test.ts
Normal file
145
tests/unit/gemini-usage-projectid.test.ts
Normal file
@@ -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<string, { remainingPercentage?: number }> };
|
||||
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user