Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

---
Expand Down
44 changes: 36 additions & 8 deletions open-sse/services/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -1827,17 +1844,28 @@ async function getGeminiUsage(
}

try {
const subscriptionInfo = await getGeminiCliSubscriptionInfoCached(accessToken);
const projectId =
connectionProjectId ||
providerSpecificData?.projectId ||
toRecord(subscriptionInfo).cloudaicompanionProject ||
null;
// #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";

const plan = getGeminiCliPlanLabel(subscriptionInfo);
if (!projectId) {
const subscriptionInfo = await getGeminiCliSubscriptionInfoCached(accessToken);
projectId = normalizeCloudCodeProjectId(toRecord(subscriptionInfo).cloudaicompanionProject);
plan = getGeminiCliPlanLabel(subscriptionInfo);
}

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.",
};
}

// Use retrieveUserQuota (same endpoint as Gemini CLI /stats command).
Expand Down
145 changes: 145 additions & 0 deletions tests/unit/gemini-usage-projectid.test.ts
Original file line number Diff line number Diff line change
@@ -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;
}
});
Loading