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
32 changes: 22 additions & 10 deletions open-sse/executors/gemini-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,17 @@ function extractProjectId(payload: unknown): string {
return "";
}

function resolveGeminiCliProjectId(value: unknown): string {
if (typeof value !== "string") return "";
const trimmed = value.trim();
if (!trimmed) return "";
const normalized = trimmed.toLowerCase();
if (normalized === DEFAULT_PROJECT_ID || normalized === `projects/${DEFAULT_PROJECT_ID}`) {
return "";
}
return trimmed;
}
Comment on lines +81 to +90

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.

high

The helper resolveGeminiCliProjectId filters out default-project, but in Google Cloud environments, project IDs are often prefixed with projects/ (e.g., projects/default-project). If a default project ID is stored or passed with this prefix, it will bypass this check and be treated as a valid project ID, causing the exact quota/discovery issues this PR aims to prevent.

Update the helper to also filter out projects/default-project.

Suggested change
function resolveGeminiCliProjectId(value: unknown): string {
if (typeof value !== "string") return "";
const trimmed = value.trim();
if (!trimmed) return "";
if (trimmed.toLowerCase() === DEFAULT_PROJECT_ID) return "";
return trimmed;
}
function resolveGeminiCliProjectId(value: unknown): string {
if (typeof value !== "string") return "";
const trimmed = value.trim();
if (!trimmed) return "";
const lower = trimmed.toLowerCase();
if (lower === DEFAULT_PROJECT_ID || lower === "projects/" + DEFAULT_PROJECT_ID) return "";
return trimmed;
}


function extractDefaultTierId(payload: unknown): string {
if (!payload || typeof payload !== "object") return DEFAULT_ONBOARD_TIER;
const tiers = Array.isArray((payload as LoadCodeAssistResponse).allowedTiers)
Expand Down Expand Up @@ -134,8 +145,7 @@ export class GeminiCLIExecutor extends BaseExecutor {
) {
void clientHeaders;

// Fallback to internal tracker if model not explicitly passed (matches older interface calls)
const activeModel = model || this._currentModel || "unknown";
const activeModel = model || "unknown";

const raw = getGeminiCliHeaders(
normalizeGeminiModel(activeModel),
Expand Down Expand Up @@ -182,7 +192,7 @@ export class GeminiCLIExecutor extends BaseExecutor {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), ONBOARD_TIMEOUT_MS);

let response;
let response: Response;
try {
response = await fetch(ONBOARD_USER_URL, {
method: "POST",
Expand All @@ -203,7 +213,7 @@ export class GeminiCLIExecutor extends BaseExecutor {
}

if (payload?.done === true) {
return DEFAULT_PROJECT_ID;
return null;
}
} else {
console.warn(
Expand Down Expand Up @@ -254,7 +264,7 @@ export class GeminiCLIExecutor extends BaseExecutor {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), LOAD_CODE_ASSIST_TIMEOUT_MS);

let response;
let response: Response;
try {
response = await fetch(LOAD_CODE_ASSIST_URL, {
method: "POST",
Expand All @@ -276,7 +286,7 @@ export class GeminiCLIExecutor extends BaseExecutor {
}

const data = (await response.json()) as LoadCodeAssistResponse;
let projectId = extractProjectId(data);
let projectId = resolveGeminiCliProjectId(extractProjectId(data));

if (!projectId) {
console.warn(
Expand Down Expand Up @@ -323,10 +333,12 @@ export class GeminiCLIExecutor extends BaseExecutor {
? cloneGeminiCliRecord(bodyRecord.request as Record<string, any>)
: {};

const providerSpecificData = credentials.providerSpecificData as Record<string, unknown>;
const storedProject =
bodyRecord.project ||
credentials.projectId ||
(credentials.providerSpecificData as Record<string, unknown>)?.projectId;
resolveGeminiCliProjectId(providerSpecificData?.projectId) ||
resolveGeminiCliProjectId(credentials.projectId) ||
resolveGeminiCliProjectId(bodyRecord.project) ||
"";

const envelope: Record<string, any> = {
model: currentModel,
Expand All @@ -351,7 +363,7 @@ export class GeminiCLIExecutor extends BaseExecutor {
// stored project IDs can go stale. Keep the stored value as a fallback.
if (credentials.accessToken) {
const freshProject = await this.refreshProject(credentials.accessToken, currentModel);
if (freshProject) {
if (resolveGeminiCliProjectId(freshProject)) {
envelope.project = freshProject;
}
}
Expand Down
13 changes: 12 additions & 1 deletion src/app/api/providers/[id]/models/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,14 @@ function toNonEmptyString(value: unknown): string | null {
return trimmed.length > 0 ? trimmed : null;
}

function toGeminiCliProjectId(value: unknown): string | null {
const normalized = toNonEmptyString(value);
if (!normalized) return null;
const lower = normalized.toLowerCase();
if (lower === "default-project" || lower === "projects/default-project") return null;
return normalized;
}
Comment on lines +96 to +102

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.

high

Similar to the executor helper, toGeminiCliProjectId should also filter out the fully qualified projects/default-project format to prevent default project fallbacks from being propagated during model discovery.

Suggested change
function toGeminiCliProjectId(value: unknown): string | null {
const normalized = toNonEmptyString(value);
if (!normalized) return null;
return normalized.toLowerCase() === "default-project" ? null : normalized;
}
function toGeminiCliProjectId(value: unknown): string | null {
const normalized = toNonEmptyString(value);
if (!normalized) return null;
const lower = normalized.toLowerCase();
return lower === "default-project" || lower === "projects/default-project" ? null : normalized;
}


function getProviderBaseUrl(providerSpecificData: unknown): string | null {
const data = asRecord(providerSpecificData);
const baseUrl = data.baseUrl;
Expand Down Expand Up @@ -1738,7 +1746,10 @@ export async function GET(
}

const psd = asRecord(connection.providerSpecificData);
const projectId = connection.projectId || psd.projectId || null;
const projectId =
toGeminiCliProjectId(psd.projectId) ||
toGeminiCliProjectId(psd.project) ||
toGeminiCliProjectId(connection.projectId);

if (!projectId) {
return NextResponse.json(
Expand Down
63 changes: 63 additions & 0 deletions tests/unit/executor-gemini-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,69 @@ test("GeminiCLIExecutor.onboardManagedProject retries until completion", async (
}
});

test("GeminiCLIExecutor.onboardManagedProject returns null when done=true has no project", async () => {
const executor = new GeminiCLIExecutor();
const originalFetch = globalThis.fetch;

globalThis.fetch = async (url) => {
assert.match(String(url), /onboardUser$/);
return new Response(JSON.stringify({ done: true, response: {} }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
};

try {
assert.equal(
await executor.onboardManagedProject("access-token-no-project", "free-tier", {
attempts: 1,
delayMs: 0,
}),
null
);
} finally {
globalThis.fetch = originalFetch;
}
});

test("GeminiCLIExecutor.transformRequest prefers DB projectId over default-project body", async () => {
const executor = new GeminiCLIExecutor();

const transformed = await executor.transformRequest(
"gemini-2.5-flash",
{
project: "default-project",
request: { contents: [{ role: "user", parts: [{ text: "Hello" }] }] },
},
true,
{
projectId: "projects/custom-from-db",
providerSpecificData: { projectId: "projects/custom-from-psd" },
}
);

assert.equal(transformed.project, "projects/custom-from-psd");
});

test("GeminiCLIExecutor.transformRequest ignores projects/default-project placeholders", async () => {
const executor = new GeminiCLIExecutor();

const transformed = await executor.transformRequest(
"gemini-2.5-flash",
{
project: "projects/default-project",
request: { contents: [{ role: "user", parts: [{ text: "Hello" }] }] },
},
true,
{
projectId: "default-project",
providerSpecificData: { projectId: "projects/default-project" },
}
);

assert.equal(transformed.project, undefined);
});

test("GeminiCLIExecutor.refreshCredentials exchanges refresh tokens via Google OAuth", async () => {
const executor = new GeminiCLIExecutor();
const originalFetch = globalThis.fetch;
Expand Down
49 changes: 49 additions & 0 deletions tests/unit/provider-models-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -640,6 +640,55 @@ test("provider models route maps Gemini CLI quota buckets into a model list", as
]);
});

test("provider models route prefers providerSpecificData projectId over default-project", async () => {
const connection = await seedConnection("gemini-cli", {
authType: "oauth",
accessToken: "gemini-cli-access",
apiKey: null,
projectId: "default-project",
providerSpecificData: {
projectId: "projects/custom-psd-456",
},
});

globalThis.fetch = async (url, init = {}) => {
assert.equal(String(url), "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota");
assert.equal(init.headers.Authorization, "Bearer gemini-cli-access");
assert.deepEqual(JSON.parse(String(init.body)), { project: "projects/custom-psd-456" });
return Response.json({ buckets: [{ modelId: "gemini-3.1-pro-preview" }] });
};

const response = await callRoute(connection.id);
const body = (await response.json()) as any;

assert.equal(response.status, 200);
assert.deepEqual(body.models, [
{ id: "gemini-3.1-pro-preview", name: "gemini-3.1-pro-preview", owned_by: "google" },
]);
});

test("provider models route rejects projects/default-project placeholders", async () => {
const connection = await seedConnection("gemini-cli", {
authType: "oauth",
accessToken: "gemini-cli-access",
apiKey: null,
projectId: "projects/default-project",
providerSpecificData: {
projectId: "default-project",
},
});

globalThis.fetch = async () => {
throw new Error("retrieveUserQuota should not be called for placeholder project IDs");
};

const response = await callRoute(connection.id);
const body = (await response.json()) as any;

assert.equal(response.status, 400);
assert.equal(body.error, "Gemini CLI project ID not available. Please reconnect OAuth.");
});

test("provider models route retries Antigravity discovery endpoints before returning remote models", async () => {
const connection = await seedConnection("antigravity", {
authType: "oauth",
Expand Down