feat(gemini-cli): add custom projectId support (UI, DB, executor) (#1991)

Integrated into release/v3.8.0
This commit is contained in:
nickwizard
2026-05-06 14:58:43 +03:00
committed by GitHub
parent 90898172bf
commit d7eb92be5a
6 changed files with 57 additions and 19 deletions

View File

@@ -126,7 +126,12 @@ export class GeminiCLIExecutor extends BaseExecutor {
return `${this.config.baseUrl}:${action}`;
}
buildHeaders(credentials, stream = true, clientHeaders?: Record<string, string> | null, model?: string) {
buildHeaders(
credentials,
stream = true,
clientHeaders?: Record<string, string> | 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<string, any> = {
model: currentModel,
project: bodyRecord.project || credentials.projectId || "",
project:
bodyRecord.project ||
credentials.projectId ||
(credentials.providerSpecificData as Record<string, unknown>)?.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;

View File

@@ -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
/>
</div>
)}
{isGeminiCli && (
<div className="flex flex-col gap-4 rounded-lg border border-border/50 bg-surface/20 p-4">
<Input
label={t("geminiCliProjectIdLabel")}
value={formData.geminiProjectId}
onChange={(e) => setFormData({ ...formData, geminiProjectId: e.target.value })}
placeholder={t("geminiCliProjectIdPlaceholder")}
hint={t("geminiCliProjectIdHint")}
className="font-mono text-xs"
/>
</div>
)}
{isOAuth && connection.email && (
<div className="bg-sidebar/50 p-3 rounded-lg">
<p className="text-sm text-text-muted mb-1">{t("email")}</p>

View File

@@ -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") {

View File

@@ -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",

View File

@@ -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())

View File

@@ -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([]);