diff --git a/open-sse/executors/gemini-cli.ts b/open-sse/executors/gemini-cli.ts index a12b1d2af9c1..e8caba72ea02 100644 --- a/open-sse/executors/gemini-cli.ts +++ b/open-sse/executors/gemini-cli.ts @@ -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; +} + function extractDefaultTierId(payload: unknown): string { if (!payload || typeof payload !== "object") return DEFAULT_ONBOARD_TIER; const tiers = Array.isArray((payload as LoadCodeAssistResponse).allowedTiers) @@ -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), @@ -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", @@ -203,7 +213,7 @@ export class GeminiCLIExecutor extends BaseExecutor { } if (payload?.done === true) { - return DEFAULT_PROJECT_ID; + return null; } } else { console.warn( @@ -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", @@ -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( @@ -323,10 +333,12 @@ export class GeminiCLIExecutor extends BaseExecutor { ? cloneGeminiCliRecord(bodyRecord.request as Record) : {}; + const providerSpecificData = credentials.providerSpecificData as Record; const storedProject = - bodyRecord.project || - credentials.projectId || - (credentials.providerSpecificData as Record)?.projectId; + resolveGeminiCliProjectId(providerSpecificData?.projectId) || + resolveGeminiCliProjectId(credentials.projectId) || + resolveGeminiCliProjectId(bodyRecord.project) || + ""; const envelope: Record = { model: currentModel, @@ -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; } } diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 3d48b428811f..e51d355092a5 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -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; +} + function getProviderBaseUrl(providerSpecificData: unknown): string | null { const data = asRecord(providerSpecificData); const baseUrl = data.baseUrl; @@ -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( diff --git a/tests/unit/executor-gemini-cli.test.ts b/tests/unit/executor-gemini-cli.test.ts index 6165d5fa75b3..9a122bf33ea8 100644 --- a/tests/unit/executor-gemini-cli.test.ts +++ b/tests/unit/executor-gemini-cli.test.ts @@ -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; diff --git a/tests/unit/provider-models-route.test.ts b/tests/unit/provider-models-route.test.ts index 83624da625df..e5dc623c0ed6 100644 --- a/tests/unit/provider-models-route.test.ts +++ b/tests/unit/provider-models-route.test.ts @@ -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",