diff --git a/open-sse/executors/gemini-cli.ts b/open-sse/executors/gemini-cli.ts index daaace78ba..d97fcc504e 100644 --- a/open-sse/executors/gemini-cli.ts +++ b/open-sse/executors/gemini-cli.ts @@ -126,7 +126,12 @@ export class GeminiCLIExecutor extends BaseExecutor { return `${this.config.baseUrl}:${action}`; } - buildHeaders(credentials, stream = true, clientHeaders?: Record | null, model?: string) { + buildHeaders( + credentials, + stream = true, + clientHeaders?: Record | null, + model?: string + ) { void clientHeaders; const raw = getGeminiCliHeaders( normalizeGeminiModel(model || "unknown"), @@ -264,7 +269,12 @@ export class GeminiCLIExecutor extends BaseExecutor { console.warn( "[OmniRoute] loadCodeAssist returned no project — attempting managed project onboarding" ); - projectId = await this.onboardManagedProject(accessToken, extractDefaultTierId(data), {}, currentModel); + projectId = await this.onboardManagedProject( + accessToken, + extractDefaultTierId(data), + {}, + currentModel + ); } if (!projectId) { @@ -287,9 +297,7 @@ export class GeminiCLIExecutor extends BaseExecutor { async transformRequest(model, body, stream, credentials) { const currentModel = normalizeGeminiModel(model); const normalizedBody = - shouldStripCloudCodeThinking(this.provider, currentModel) && - body && - typeof body === "object" + shouldStripCloudCodeThinking(this.provider, currentModel) && body && typeof body === "object" ? stripCloudCodeThinkingConfig(body) : body; @@ -304,7 +312,11 @@ export class GeminiCLIExecutor extends BaseExecutor { const envelope: Record = { model: currentModel, - project: bodyRecord.project || credentials.projectId || "", + project: + bodyRecord.project || + credentials.projectId || + (credentials.providerSpecificData as Record)?.projectId || + "", user_prompt_id: bodyRecord.user_prompt_id || generateGeminiCliRequestId(), request: { ...requestRecord, @@ -318,8 +330,9 @@ export class GeminiCLIExecutor extends BaseExecutor { } } - // Refresh the project ID via loadCodeAssist (cached for 30s). - if (credentials.accessToken) { + // Refresh the project ID via loadCodeAssist (cached for 30s) only when project not provided + // and credentials have an access token + if (!envelope.project && credentials.accessToken) { const freshProject = await this.refreshProject(credentials.accessToken, currentModel); if (freshProject) { envelope.project = freshProject; diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index 583c66d8ce..e802936647 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -5995,6 +5995,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec codexOpenaiStoreEnabled: false, consoleApiKey: "", ccCompatibleContext1m: false, + geminiProjectId: "", blockExtraUsage: connection?.provider === "claude" ? isClaudeExtraUsageBlockEnabled(connection?.provider, connection?.providerSpecificData) @@ -6020,6 +6021,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec const isCloudflare = connection?.provider === "cloudflare-ai"; const isCodex = connection?.provider === "codex"; const isClaude = connection?.provider === "claude"; + const isGeminiCli = connection?.provider === "gemini-cli"; const localProviderMetadata = getLocalProviderMetadata(connection?.provider); const isLocalSelfHostedProvider = !!localProviderMetadata; const isSearxng = connection?.provider === "searxng-search"; @@ -6082,6 +6084,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec codexOpenaiStoreEnabled: connection.providerSpecificData?.openaiStoreEnabled === true, consoleApiKey: existingConsoleApiKey, ccCompatibleContext1m: ccRequestDefaults.context1m, + geminiProjectId: (connection.providerSpecificData?.projectId as string) || "", blockExtraUsage: isClaudeExtraUsageBlockEnabled( connection.provider, connection.providerSpecificData @@ -6188,6 +6191,10 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec healthCheckInterval: formData.healthCheckInterval, }; + if (isGeminiCli) { + updates.projectId = formData.geminiProjectId.trim() || null; + } + if (isGooglePse && !formData.cx.trim()) { setSaveError(t("searchEngineIdRequired")); return; @@ -6309,6 +6316,9 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec updates.providerSpecificData.openaiStoreEnabled = formData.codexOpenaiStoreEnabled === true; } + if (isGeminiCli) { + updates.providerSpecificData.projectId = formData.geminiProjectId.trim() || undefined; + } } const error = (await onSave(updates)) as void | unknown; if (error) { @@ -6403,6 +6413,18 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec /> )} + {isGeminiCli && ( +
+ setFormData({ ...formData, geminiProjectId: e.target.value })} + placeholder={t("geminiCliProjectIdPlaceholder")} + hint={t("geminiCliProjectIdHint")} + className="font-mono text-xs" + /> +
+ )} {isOAuth && connection.email && (

{t("email")}

diff --git a/src/app/api/providers/[id]/route.ts b/src/app/api/providers/[id]/route.ts index f4fe213a9b..3925f88c11 100644 --- a/src/app/api/providers/[id]/route.ts +++ b/src/app/api/providers/[id]/route.ts @@ -126,6 +126,7 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: healthCheckInterval, group, maxConcurrent, + projectId, providerSpecificData: incomingPsd, } = body; @@ -152,6 +153,7 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: if (healthCheckInterval !== undefined) updateData.healthCheckInterval = healthCheckInterval; if (group !== undefined) updateData.group = group; if (maxConcurrent !== undefined) updateData.maxConcurrent = maxConcurrent; + if (projectId !== undefined) updateData.projectId = projectId; // Merge providerSpecificData (partial update — preserve existing keys not sent by caller) if (incomingPsd !== undefined && incomingPsd !== null && typeof incomingPsd === "object") { diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index b2f4c692f4..1750d82d21 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -3011,6 +3011,9 @@ "extraApiKeysHint": "Extra Api Keys Hint", "extraApiKeysLabel": "Extra Api Keys Label", "googlePseInfo": "Google Pse Info", + "geminiCliProjectIdHint": "Your Google Cloud Project ID. Required for accounts with exceptions. Enter your GCP Project ID to use with Gemini CLI.", + "geminiCliProjectIdLabel": "Google Cloud Project ID", + "geminiCliProjectIdPlaceholder": "my-gcp-project-id", "grokWebCookieHint": "Grok Web Cookie Hint", "grokWebCookiePlaceholder": "Grok Web Cookie Placeholder", "herokuBaseUrlHint": "Heroku Base Url Hint", diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index 97a41d118b..a6096b41aa 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -1557,6 +1557,7 @@ export const updateProviderConnectionSchema = z healthCheckInterval: z.coerce.number().int().min(0).optional(), group: z.union([z.string().max(100), z.null()]).optional(), maxConcurrent: z.union([z.null(), z.coerce.number().int().min(0)]).optional(), + projectId: z.union([z.string(), z.null()]).optional(), // Partial patch of per-connection provider-specific settings (e.g. quota toggles) providerSpecificData: z .record(z.string(), z.unknown()) diff --git a/tests/unit/executor-gemini-cli.test.ts b/tests/unit/executor-gemini-cli.test.ts index e79037fe78..44c65dfe85 100644 --- a/tests/unit/executor-gemini-cli.test.ts +++ b/tests/unit/executor-gemini-cli.test.ts @@ -82,7 +82,7 @@ test("GeminiCLIExecutor.buildHeaders derives the User-Agent from the request mod assert.notEqual(flashHeaders["User-Agent"], proHeaders["User-Agent"]); }); -test("GeminiCLIExecutor.refreshProject caches loadCodeAssist lookups and transformRequest updates body.project", async () => { +test("GeminiCLIExecutor.refreshProject caches loadCodeAssist lookups and transformRequest preserves existing body.project", async () => { const executor = new GeminiCLIExecutor(); const originalFetch = globalThis.fetch; let calls = 0; @@ -107,7 +107,7 @@ test("GeminiCLIExecutor.refreshProject caches loadCodeAssist lookups and transfo assert.equal(first, "fresh-project-id"); assert.equal(second, "fresh-project-id"); assert.equal(calls, 1); - assert.equal(transformed.project, "fresh-project-id"); + assert.equal(transformed.project, "stale-project"); } finally { globalThis.fetch = originalFetch; } @@ -337,10 +337,10 @@ test("GeminiCLIExecutor.execute applies CLI fingerprint to the final Cloud Code }); } - return new Response( - 'data: {"candidates":[{"content":{"parts":[{"text":"ok"}]}}]}\n\n', - { status: 200, headers: { "Content-Type": "text/event-stream" } } - ); + return new Response('data: {"candidates":[{"content":{"parts":[{"text":"ok"}]}}]}\n\n', { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); }; try { @@ -368,13 +368,10 @@ test("GeminiCLIExecutor.execute applies CLI fingerprint to the final Cloud Code "Authorization", ]); assert.equal(finalBody.model, "gemini-3.1-pro-preview"); - assert.equal(finalBody.project, "project-live"); + assert.equal(finalBody.project, "old-project"); assert.match(finalBody.user_prompt_id, /^agent-/); assert.match(finalBody.request.session_id, /^-\d+$/); - assert.match( - finalCall.headers["User-Agent"], - /^GeminiCLI\/0\.40\.1\/gemini-3\.1-pro-preview / - ); + assert.match(finalCall.headers["User-Agent"], /^GeminiCLI\/0\.40\.1\/gemini-3\.1-pro-preview /); assert.equal(finalCall.headers.Accept, "*/*"); } finally { setCliCompatProviders([]);