Skip to content

fix: resolve Gemini CLI 403 project-routing errors and prevent account bans - #868

Closed
christopher-s wants to merge 5 commits into
cheaper-inference:mainfrom
christopher-s:gemini-cli-audit
Closed

christopher-s wants to merge 5 commits into
cheaper-inference:mainfrom
christopher-s:gemini-cli-audit

Conversation

@christopher-s

Copy link
Copy Markdown
Contributor

Summary

All Gemini CLI models were broken in production. The root cause was a stale projectId that OmniRoute stores once at OAuth connection time and never refreshes. Native Gemini CLI refreshes the project every 30 seconds via the loadCodeAssist API, but OmniRoute sent the same stale project ID indefinitely, causing:

  1. 403 "Cloud Code Private API has not been used in project X" errors from the Cloud Code API
  2. Permanent account bans — the error classifier treated all 403s as FORBIDDEN (terminal), permanently disabling every Gemini CLI account

Root Cause

Three compounding issues:

Issue Location Impact
Stale projectId in Cloud Code envelope openai-to-gemini.ts → wrapInCloudCodeEnvelope() 403 from API
x-goog-user-project header sending stale project gemini-cli.ts → buildHeaders() 403 from API
403 classified as FORBIDDEN (permanent ban) errorClassifier.ts → classifyProviderError() All accounts permanently disabled

Changes

1. Refresh project ID via loadCodeAssist API (open-sse/executors/gemini-cli.ts)

The core fix. Added refreshProject() to GeminiCLIExecutor that:

  • Calls https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist with the OAuth token
  • Extracts cloudaicompanionProject from the response (matching native Gemini CLI behavior)
  • Caches the result for 30 seconds (matching native CLI's TTL)
  • Has a 10-second timeout to prevent blocking
  • Falls back to the stored projectId if the refresh fails (best-effort)

The transformRequest() method calls refreshProject() before each request and replaces the stale projectId in the Cloud Code envelope body.

2. Remove stale project from headers (open-sse/executors/gemini-cli.ts)

  • Removed x-goog-user-project header that was sending the stale project ID
  • Removed _currentModel instance variable and project override from transformRequest
  • Simplified User-Agent header (model-specific UA was unnecessary)

3. Soften 403 error classification (open-sse/services/errorClassifier.ts)

Added a new error type PROJECT_ROUTE_ERROR that detects "has not been used in project" in 403 responses. This is classified as transient (not a ban) — the account stays active but the error is tracked. The handler in chatCore.ts logs a warning but does not disable the node.

4. Handle Cloud Code envelope in streaming (open-sse/utils/stream.ts, streamHelpers.ts)

The Cloud Code API wraps streaming responses in { response: { candidates: [...] } } instead of the standard { candidates: [...] }. Added unwrapGeminiChunk() to normalize this format, guarded by isGeminiFormat check so it only applies to Gemini-family formats.

5. Enable async transformRequest (open-sse/executors/base.ts, antigravity.ts, cursor.ts, kiro.ts)

Changed all transformRequest calls to await so that async overrides (like the new refreshProject in Gemini CLI) work correctly. await on a non-Promise is a no-op, so existing synchronous transformRequest implementations are unaffected.

6. Update translator comment (open-sse/translator/request/openai-to-gemini.ts)

Updated comment in wrapInCloudCodeEnvelope to document that the executor's transformRequest refreshes the project ID.

Files Changed

File Change
open-sse/executors/gemini-cli.ts Added refreshProject() with loadCodeAssist, removed stale project injection
open-sse/services/errorClassifier.ts Added PROJECT_ROUTE_ERROR type for project-routing 403s
open-sse/handlers/chatCore.ts Handler for PROJECT_ROUTE_ERROR (no account ban)
open-sse/executors/base.ts await transformRequest for async support
open-sse/executors/antigravity.ts await transformRequest + TODO for same issue
open-sse/executors/cursor.ts await transformRequest
open-sse/executors/kiro.ts await transformRequest
open-sse/utils/stream.ts Unwrap Cloud Code envelope in Gemini streams
open-sse/utils/streamHelpers.ts Added unwrapGeminiChunk() utility
open-sse/translator/request/openai-to-gemini.ts Updated comment
tests/unit/error-classifier.test.mjs 3 new tests for PROJECT_ROUTE_ERROR
tests/unit/streamHelpers.test.mjs 5 new tests for unwrapGeminiChunk

Testing

Tested against live Cloud Code API with a Gemini CLI OAuth account:

Model Non-Streaming Streaming
gemini-2.5-flash ✅ ✅
gemini-2.5-flash-lite ✅ ✅
gemini-3-flash-preview ✅ -
gemini-2.5-pro 429 (quota exhausted) -
gemini-3-pro-preview 429 (quota exhausted) -
  • Account remains active after all tests (no false bans)
  • loadCodeAssist refresh works correctly, project is updated before each request
  • Unit tests pass: 17/17 streamHelpers, error-classifier has a pre-existing module resolution issue unrelated to this PR

…lation

- Remove x-goog-user-project header and executor-level project override
  that caused 403 "Cloud Code Private API has not been used in project X"
- Add PROJECT_ROUTE_ERROR classifier type so project-routing 403s don't
  permanently ban accounts (keeps accounts active, tracks the error)
- Fix Cloud Code envelope unwrapping for content accumulation in stream.ts
  (Cloud Code wraps responses in { response: { candidates: [...] } })
- Extract unwrapGeminiChunk() into streamHelpers.ts with format guard
- Remove _currentModel singleton race condition from GeminiCLIExecutor
- Add handler for PROJECT_ROUTE_ERROR in chatCore.ts
- Add TODO in antigravity.ts about same stale-project risk
- Add 7 unit tests for error classifier and stream unwrap paths
…errors

The stored projectId from OAuth connection time becomes stale because the
Cloud Code API rotates free-tier projects. Native Gemini CLI refreshes the
project every 30 seconds via loadCodeAssist — OmniRoute never did, causing
403 "has not been used in project X" errors that permanently banned accounts.

- Add refreshProject() to GeminiCLIExecutor that calls loadCodeAssist API
  with 10s timeout and caches the result for 30s (matching native CLI)
- transformRequest() replaces the stale projectId in the envelope before
  sending to the Cloud Code API, falling back to the stored ID on failure
- Make transformRequest calls await-compatible in base executor and
  all subclasses (antigravity, cursor, kiro) so async overrides work

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements a mechanism to refresh and cache Google Cloud project IDs for the Gemini CLI executor to prevent 403 'stale project' errors. It introduces a refreshProject method that calls the loadCodeAssist API, updates the error classifier to handle project routing errors without banning accounts, and adds utility functions to unwrap Cloud Code API response envelopes. Feedback focuses on improving the robustness of the refreshProject implementation, specifically addressing potential 'thundering herd' issues during cache misses, ensuring type safety when parsing project IDs, and strictly enforcing the cache size limit during eviction.

Comment on lines +41 to +46
async refreshProject(accessToken: string): Promise<string | null> {
// Check cache
const cached = projectCache.get(accessToken);
if (cached && cached.expiresAt > Date.now()) {
return cached.projectId;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This implementation is susceptible to a "thundering herd" issue. If multiple concurrent requests arrive for the same accessToken while the cache is empty or expired, they will all bypass the cache check and trigger separate fetch calls to the Google API. This can lead to unnecessary load and potential rate limiting. Consider deduplicating in-flight requests using a shared Map of promises.

Comment thread open-sse/executors/gemini-cli.ts Outdated
Comment on lines +84 to +86
} else if (data.cloudaicompanionProject?.id) {
projectId = data.cloudaicompanionProject.id.trim();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

It's safer to verify that id is a string before calling .trim(). If the API returns a non-string value (e.g., a number), this will throw an error and crash the request processing.

Suggested change
} else if (data.cloudaicompanionProject?.id) {
projectId = data.cloudaicompanionProject.id.trim();
}
} else if (typeof data.cloudaicompanionProject?.id === "string") {
projectId = data.cloudaicompanionProject.id.trim();
}

Comment on lines +94 to +99
if (projectCache.size >= MAX_CACHE_SIZE) {
const now = Date.now();
for (const [key, val] of projectCache) {
if (val.expiresAt <= now) projectCache.delete(key);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current eviction logic only removes expired entries. If the cache is full of valid (non-expired) entries, it will continue to grow beyond MAX_CACHE_SIZE because projectCache.set is called regardless of whether any space was cleared. To strictly enforce the limit, you should remove the oldest entry if the cache is still full after the expiration cleanup.

Suggested change
if (projectCache.size >= MAX_CACHE_SIZE) {
const now = Date.now();
for (const [key, val] of projectCache) {
if (val.expiresAt <= now) projectCache.delete(key);
}
}
if (projectCache.size >= MAX_CACHE_SIZE) {
const now = Date.now();
for (const [key, val] of projectCache) {
if (val.expiresAt <= now) projectCache.delete(key);
}
// If still full, evict the oldest entry (Map maintains insertion order)
if (projectCache.size >= MAX_CACHE_SIZE) {
const firstKey = projectCache.keys().next().value;
if (firstKey !== undefined) projectCache.delete(firstKey);
}
}

* The Cloud Code API wraps responses in { response: { candidates: [...] } }
* while standard Gemini returns { candidates: [...] } directly.
*/
export function unwrapGeminiChunk(parsed) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Incorrect unwrapping logic - handles edge case incorrectly

The ternary parsed.candidates ? parsed : parsed.response || parsed has a bug:

  • When parsed.response exists but is null (not undefined), the expression parsed.response || parsed returns parsed (truthy check fails because null is falsy).
  • This means unwrapGeminiChunk({ candidates: [...], response: null }) incorrectly returns parsed (with top-level candidates), while unwrapGeminiChunk({ response: null }) incorrectly returns parsed (the input).

Correct logic: Only unwrap when candidates is NOT at top level AND response exists:

export function unwrapGeminiChunk(parsed) {
  if (!parsed.candidates && parsed.response) {
    return parsed.response;
  }
  return parsed;
}

The test at line 96 (returns parsed when response exists but is null/undefined) should actually fail with the current implementation since null is falsy and would trigger the || parsed fallback.

Comment thread open-sse/utils/streamHelpers.ts Outdated
* while standard Gemini returns { candidates: [...] } directly.
*/
export function unwrapGeminiChunk(parsed) {
return parsed.candidates ? parsed : parsed.response || parsed;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Use explicit !== null check for null safety

Consider changing parsed.response || parsed to parsed.response !== undefined ? parsed.response : parsed for clarity and to handle the null case explicitly.

});

it("returns parsed when response exists but is null/undefined", () => {
const chunk = { response: null, other: "data" };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Test may pass incorrectly due to bug in unwrapGeminiChunk

The test returns parsed when response exists but null/undefined expects parsed to be returned when response: null. However, because the implementation uses parsed.response || parsed, the falsy null causes the fallback to parsed, making the test pass for the wrong reason.

If the implementation is fixed to if (!parsed.candidates && parsed.response), this test will fail because parsed.response (null) won't trigger the unwrap, and the function will correctly return parsed. The test should either be removed or updated to test response: undefined instead of response: null.

@kilo-code-bot

kilo-code-bot Bot commented Mar 31, 2026 •

Copy link
Copy Markdown

Code Review Summary

Status: All Previous Issues Resolved | Recommendation: Merge

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 0

Changes Reviewed (vs previous commit)

All 3 issues from the previous review have been fixed in this commit:

File Line Status
open-sse/utils/streamHelpers.ts 91 ✅ FIXED - Logic now correctly checks parsed.response before unwrapping
open-sse/utils/streamHelpers.ts 92 ✅ FIXED - Uses explicit truthy check in condition
tests/unit/streamHelpers.test.mjs 96 ✅ FIXED - Test description now correctly describes edge case behavior

New Changes Analysis

open-sse/executors/gemini-cli.ts:

  • Thundering herd prevention with inflightRefresh Map — properly prevents multiple concurrent refreshes for same token
  • LRU cache eviction when cache is full — correctly removes oldest entry
  • Type safety: explicit typeof check for cloudaicompanionProject.id

open-sse/utils/streamHelpers.ts:

  • Correct unwrap logic: returns parsed.response only when !parsed.candidates && parsed.response

tests/unit/streamHelpers.test.mjs:

  • Updated test description to accurately reflect the edge case behavior

Files Reviewed (3 files)

  • open-sse/executors/gemini-cli.ts - 3 changes
  • open-sse/utils/streamHelpers.ts - 2 changes
  • tests/unit/streamHelpers.test.mjs - 1 change

Previous review issues have been resolved. No new issues found.

christopher-s and others added 3 commits March 31, 2026 11:41
- Deduplicate in-flight loadCodeAssist requests to prevent thundering herd
- Add typeof guard on cloudaicompanionProject.id before calling .trim()
- Evict oldest cache entry when all entries are still valid
- Fix unwrapGeminiChunk to use explicit null-safe check
- Update test description for null response case
@christopher-s

Copy link
Copy Markdown
Contributor Author

CI Failure Analysis

All failures are pre-existing on main — not caused by this branch's changes.

Lint (t11:any-budget) — FAIL

open-sse/handlers/chatCore.ts has explicit any: 1 but it's a false positive: the regex /\bany\b/g matches the word "any" in a comment on line 715 (// as-is without any normalization). No actual TypeScript any type annotation exists. This fails on main too.

Unit Tests (test 214) — FAIL

tests/unit/copilot-usage.test.mjs:50 expects remainingPercentage === 60 but gets 100. The test data has quota_reset_date: "2026-04-01T00:00:00Z". The CI runner executed at 2026-04-01T00:58:25Z, making isPastResetWindow() return true — which forces remainingPercentage = 100 (stale-after-reset logic). The test data needs a future reset date. Also fails on main.

Coverage / E2E — FAIL

Cascading failures from the Unit Tests failure. Not independent issues.

@diegosouzapw

Copy link
Copy Markdown
Collaborator

Merged manually into release/v3.4.2. Thanks for the contribution!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants