Merge pull request #144 from cita-777/fix/oauth-onboarding-cliproxyapi

fix: align oauth onboarding with cliproxyapi
This commit is contained in:
Cita
2026-03-18 00:02:10 +08:00
committed by GitHub
3 changed files with 609 additions and 50 deletions
+257 -1
View File
@@ -129,6 +129,121 @@ describe('oauth routes', { timeout: 15_000 }, () => {
}); });
}); });
it('discovers the Antigravity project via onboardUser polling when loadCodeAssist does not return one', async () => {
fetchMock
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
access_token: 'antigravity-access-token',
refresh_token: 'antigravity-refresh-token',
expires_in: 3600,
token_type: 'Bearer',
scope: 'cloud-platform',
}),
text: async () => JSON.stringify({ ok: true }),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ email: 'antigravity-user@example.com' }),
text: async () => JSON.stringify({ email: 'antigravity-user@example.com' }),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
allowedTiers: [
{ id: 'legacy-tier', isDefault: true },
],
}),
text: async () => JSON.stringify({ ok: true }),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
done: false,
}),
text: async () => JSON.stringify({ done: false }),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
done: true,
response: {
cloudaicompanionProject: {
id: 'antigravity-auto-project',
},
},
}),
text: async () => JSON.stringify({ done: true }),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
models: {
'gemini-3-pro-preview': { displayName: 'Gemini 3 Pro Preview' },
},
}),
text: async () => JSON.stringify({ ok: true }),
});
const startResponse = await app.inject({
method: 'POST',
url: '/api/oauth/providers/antigravity/start',
headers: {
host: 'metapi.example',
'x-forwarded-proto': 'https',
},
});
expect(startResponse.statusCode).toBe(200);
const startBody = startResponse.json() as { state: string };
const callbackResponse = await app.inject({
method: 'POST',
url: `/api/oauth/sessions/${encodeURIComponent(startBody.state)}/manual-callback`,
payload: {
callbackUrl: `http://localhost:51121/oauth-callback?state=${encodeURIComponent(startBody.state)}&code=antigravity-oauth-code-123`,
},
});
expect(callbackResponse.statusCode).toBe(200);
expect(callbackResponse.json()).toEqual({ success: true });
const sessionResponse = await app.inject({
method: 'GET',
url: `/api/oauth/sessions/${startBody.state}`,
});
expect(sessionResponse.statusCode).toBe(200);
expect(sessionResponse.json()).toMatchObject({
provider: 'antigravity',
status: 'success',
});
const accounts = await db.select().from(schema.accounts).all();
expect(accounts).toHaveLength(1);
expect(accounts[0]).toMatchObject({
oauthProvider: 'antigravity',
oauthProjectId: 'antigravity-auto-project',
username: 'antigravity-user@example.com',
accessToken: 'antigravity-access-token',
});
const parsed = JSON.parse(accounts[0]?.extraConfig || '{}');
expect(parsed.oauth).toMatchObject({
provider: 'antigravity',
email: 'antigravity-user@example.com',
projectId: 'antigravity-auto-project',
refreshToken: 'antigravity-refresh-token',
});
expect(String(fetchMock.mock.calls[2]?.[0] || '')).toContain('/v1internal:loadCodeAssist');
expect(String(fetchMock.mock.calls[3]?.[0] || '')).toContain('/v1internal:onboardUser');
expect(String(fetchMock.mock.calls[4]?.[0] || '')).toContain('/v1internal:onboardUser');
});
it('starts a codex oauth session and exposes pending status', async () => { it('starts a codex oauth session and exposes pending status', async () => {
const startResponse = await app.inject({ const startResponse = await app.inject({
method: 'POST', method: 'POST',
@@ -461,6 +576,32 @@ describe('oauth routes', { timeout: 15_000 }, () => {
}), }),
text: async () => JSON.stringify({ ok: true }), text: async () => JSON.stringify({ ok: true }),
}) })
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
cloudaicompanionProject: {
id: 'first-project-id',
},
allowedTiers: [
{ id: 'legacy-tier', isDefault: true },
],
}),
text: async () => JSON.stringify({ ok: true }),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
done: true,
response: {
cloudaicompanionProject: {
id: 'first-project-id',
},
},
}),
text: async () => JSON.stringify({ done: true }),
})
.mockResolvedValueOnce({ .mockResolvedValueOnce({
ok: true, ok: true,
status: 200, status: 200,
@@ -529,8 +670,123 @@ describe('oauth routes', { timeout: 15_000 }, () => {
}); });
expect(String(fetchMock.mock.calls[1]?.[0] || '')).toContain('cloudresourcemanager.googleapis.com/v1/projects'); expect(String(fetchMock.mock.calls[1]?.[0] || '')).toContain('cloudresourcemanager.googleapis.com/v1/projects');
expect(String(fetchMock.mock.calls[2]?.[0] || '')).toContain('/projects/first-project-id/services/cloudaicompanion.googleapis.com'); expect(String(fetchMock.mock.calls[2]?.[0] || '')).toContain('/v1internal:loadCodeAssist');
expect(String(fetchMock.mock.calls[3]?.[0] || '')).toContain('/v1internal:onboardUser');
expect(String(fetchMock.mock.calls[4]?.[0] || '')).toContain('/projects/first-project-id/services/cloudaicompanion.googleapis.com'); expect(String(fetchMock.mock.calls[4]?.[0] || '')).toContain('/projects/first-project-id/services/cloudaicompanion.googleapis.com');
expect(String(fetchMock.mock.calls[6]?.[0] || '')).toContain('/projects/first-project-id/services/cloudaicompanion.googleapis.com');
});
it('onboards Gemini CLI into the backend project and auto-enables Cloud AI API when Google returns a free-tier project remap', async () => {
fetchMock
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
access_token: 'gemini-access-token',
refresh_token: 'gemini-refresh-token',
expires_in: 3600,
token_type: 'Bearer',
scope: 'cloud-platform',
}),
text: async () => JSON.stringify({ ok: true }),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
projects: [
{ projectId: 'gen-lang-client-source-project' },
],
}),
text: async () => JSON.stringify({ ok: true }),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
allowedTiers: [
{ id: 'legacy-tier', isDefault: true },
],
}),
text: async () => JSON.stringify({ ok: true }),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
done: true,
response: {
cloudaicompanionProject: {
id: 'gen-lang-client-0123456789',
},
},
}),
text: async () => JSON.stringify({ done: true }),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ state: 'DISABLED' }),
text: async () => JSON.stringify({ state: 'DISABLED' }),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ ok: true }),
text: async () => JSON.stringify({ ok: true }),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ email: 'gemini-free-user@example.com' }),
text: async () => JSON.stringify({ email: 'gemini-free-user@example.com' }),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ state: 'ENABLED' }),
text: async () => JSON.stringify({ state: 'ENABLED' }),
});
const startResponse = await app.inject({
method: 'POST',
url: '/api/oauth/providers/gemini-cli/start',
headers: {
host: 'metapi.example',
'x-forwarded-proto': 'https',
},
});
expect(startResponse.statusCode).toBe(200);
const startBody = startResponse.json() as { state: string };
const callbackResponse = await app.inject({
method: 'POST',
url: `/api/oauth/sessions/${encodeURIComponent(startBody.state)}/manual-callback`,
payload: {
callbackUrl: `http://localhost:8085/oauth2callback?state=${encodeURIComponent(startBody.state)}&code=gemini-oauth-code-free-tier`,
},
});
expect(callbackResponse.statusCode).toBe(200);
expect(callbackResponse.json()).toEqual({ success: true });
const accounts = await db.select().from(schema.accounts).all();
expect(accounts).toHaveLength(1);
expect(accounts[0]).toMatchObject({
oauthProvider: 'gemini-cli',
oauthProjectId: 'gen-lang-client-0123456789',
username: 'gemini-free-user@example.com',
});
const parsed = JSON.parse(accounts[0]?.extraConfig || '{}');
expect(parsed.oauth).toMatchObject({
projectId: 'gen-lang-client-0123456789',
});
expect(String(fetchMock.mock.calls[2]?.[0] || '')).toContain('/v1internal:loadCodeAssist');
expect(String(fetchMock.mock.calls[3]?.[0] || '')).toContain('/v1internal:onboardUser');
expect(String(fetchMock.mock.calls[4]?.[0] || '')).toContain('/projects/gen-lang-client-0123456789/services/cloudaicompanion.googleapis.com');
expect(String(fetchMock.mock.calls[5]?.[0] || '')).toContain('/projects/gen-lang-client-0123456789/services/cloudaicompanion.googleapis.com:enable');
expect(String(fetchMock.mock.calls[7]?.[0] || '')).toContain('/projects/gen-lang-client-0123456789/services/cloudaicompanion.googleapis.com');
}); });
it('lists oauth connection health metadata and supports deleting the connection', async () => { it('lists oauth connection health metadata and supports deleting the connection', async () => {
+109 -26
View File
@@ -16,6 +16,9 @@ export const ANTIGRAVITY_SANDBOX_DAILY_UPSTREAM_BASE_URL = 'https://daily-cloudc
export const ANTIGRAVITY_GOOGLE_API_CLIENT = 'google-cloud-sdk vscode_cloudshelleditor/0.1'; export const ANTIGRAVITY_GOOGLE_API_CLIENT = 'google-cloud-sdk vscode_cloudshelleditor/0.1';
export const ANTIGRAVITY_USER_AGENT = 'google-api-nodejs-client/9.15.1'; export const ANTIGRAVITY_USER_AGENT = 'google-api-nodejs-client/9.15.1';
export const ANTIGRAVITY_CLIENT_METADATA = '{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}'; export const ANTIGRAVITY_CLIENT_METADATA = '{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}';
export const ANTIGRAVITY_INTERNAL_API_VERSION = 'v1internal';
export const ANTIGRAVITY_ONBOARD_POLL_INTERVAL_MS = 2_000;
export const ANTIGRAVITY_ONBOARD_MAX_ATTEMPTS = 5;
const ANTIGRAVITY_SCOPES = [ const ANTIGRAVITY_SCOPES = [
'https://www.googleapis.com/auth/cloud-platform', 'https://www.googleapis.com/auth/cloud-platform',
@@ -34,6 +37,16 @@ type AntigravityOAuthTokenPayload = {
expiry?: unknown; expiry?: unknown;
}; };
type AntigravityLoadCodeAssistPayload = {
cloudaicompanionProject?: unknown;
allowedTiers?: unknown;
};
type AntigravityOnboardUserPayload = {
done?: unknown;
response?: unknown;
};
function asTrimmedString(value: unknown): string | undefined { function asTrimmedString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined; if (typeof value !== 'string') return undefined;
const trimmed = value.trim(); const trimmed = value.trim();
@@ -57,6 +70,65 @@ function parseExpiresAt(payload: AntigravityOAuthTokenPayload): number | undefin
return undefined; return undefined;
} }
function buildAntigravityMetadata() {
return {
ideType: 'ANTIGRAVITY',
platform: 'PLATFORM_UNSPECIFIED',
pluginType: 'GEMINI',
};
}
function extractAntigravityProjectId(value: unknown): string | undefined {
if (typeof value === 'string') {
return asTrimmedString(value);
}
if (value && typeof value === 'object' && !Array.isArray(value)) {
return asTrimmedString((value as { id?: unknown }).id);
}
return undefined;
}
function extractAntigravityDefaultTierId(payload: AntigravityLoadCodeAssistPayload): string {
const allowedTiers = Array.isArray(payload.allowedTiers) ? payload.allowedTiers : [];
for (const rawTier of allowedTiers) {
if (!rawTier || typeof rawTier !== 'object' || Array.isArray(rawTier)) continue;
const tier = rawTier as { id?: unknown; isDefault?: unknown };
if (tier.isDefault === true) {
const tierId = asTrimmedString(tier.id);
if (tierId) return tierId;
}
}
return 'legacy-tier';
}
async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function callAntigravityInternalApi<T>(
accessToken: string,
method: 'loadCodeAssist' | 'onboardUser',
body: Record<string, unknown>,
): Promise<T | undefined> {
const response = await fetch(
`${ANTIGRAVITY_UPSTREAM_BASE_URL}/${ANTIGRAVITY_INTERNAL_API_VERSION}:${method}`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
'Content-Type': 'application/json',
'User-Agent': ANTIGRAVITY_USER_AGENT,
'X-Goog-Api-Client': ANTIGRAVITY_GOOGLE_API_CLIENT,
'Client-Metadata': ANTIGRAVITY_CLIENT_METADATA,
},
body: JSON.stringify(body),
},
);
if (!response.ok) return undefined;
return response.json() as Promise<T>;
}
async function postAntigravityToken(body: URLSearchParams) { async function postAntigravityToken(body: URLSearchParams) {
const response = await fetch(ANTIGRAVITY_TOKEN_URL, { const response = await fetch(ANTIGRAVITY_TOKEN_URL, {
method: 'POST', method: 'POST',
@@ -99,34 +171,45 @@ async function fetchAntigravityUserEmail(accessToken: string): Promise<string |
} }
async function fetchAntigravityProjectId(accessToken: string): Promise<string | undefined> { async function fetchAntigravityProjectId(accessToken: string): Promise<string | undefined> {
const response = await fetch(`${ANTIGRAVITY_UPSTREAM_BASE_URL}/v1internal:loadCodeAssist`, { const metadata = buildAntigravityMetadata();
method: 'POST', const payload = await callAntigravityInternalApi<AntigravityLoadCodeAssistPayload>(
headers: { accessToken,
Authorization: `Bearer ${accessToken}`, 'loadCodeAssist',
Accept: 'application/json', { metadata },
'Content-Type': 'application/json', );
'User-Agent': ANTIGRAVITY_USER_AGENT, if (!payload) return undefined;
'X-Goog-Api-Client': ANTIGRAVITY_GOOGLE_API_CLIENT,
'Client-Metadata': ANTIGRAVITY_CLIENT_METADATA, const discoveredFromLoad = extractAntigravityProjectId(payload.cloudaicompanionProject);
}, if (discoveredFromLoad) {
body: JSON.stringify({ return discoveredFromLoad;
metadata: {
ideType: 'ANTIGRAVITY',
platform: 'PLATFORM_UNSPECIFIED',
pluginType: 'GEMINI',
},
}),
});
if (!response.ok) return undefined;
const payload = await response.json() as {
cloudaicompanionProject?: unknown;
};
if (typeof payload.cloudaicompanionProject === 'string') {
return asTrimmedString(payload.cloudaicompanionProject);
} }
if (payload.cloudaicompanionProject && typeof payload.cloudaicompanionProject === 'object') {
return asTrimmedString((payload.cloudaicompanionProject as { id?: unknown }).id); const tierId = extractAntigravityDefaultTierId(payload);
for (let attempt = 0; attempt < ANTIGRAVITY_ONBOARD_MAX_ATTEMPTS; attempt += 1) {
const onboardPayload = await callAntigravityInternalApi<AntigravityOnboardUserPayload>(
accessToken,
'onboardUser',
{
tierId,
metadata,
},
);
if (!onboardPayload) return undefined;
if (onboardPayload.done === true) {
const response = (
onboardPayload.response
&& typeof onboardPayload.response === 'object'
&& !Array.isArray(onboardPayload.response)
)
? onboardPayload.response as { cloudaicompanionProject?: unknown }
: undefined;
return extractAntigravityProjectId(response?.cloudaicompanionProject);
} }
if ((attempt + 1) < ANTIGRAVITY_ONBOARD_MAX_ATTEMPTS) {
await sleep(ANTIGRAVITY_ONBOARD_POLL_INTERVAL_MS);
}
}
return undefined; return undefined;
} }
+243 -23
View File
@@ -17,6 +17,11 @@ export const GEMINI_CLI_UPSTREAM_BASE_URL = 'https://cloudcode-pa.googleapis.com
export const GEMINI_CLI_GOOGLE_API_CLIENT = 'google-genai-sdk/1.41.0 gl-node/v22.19.0'; export const GEMINI_CLI_GOOGLE_API_CLIENT = 'google-genai-sdk/1.41.0 gl-node/v22.19.0';
export const GEMINI_CLI_USER_AGENT = 'GeminiCLI/0.31.0/unknown (win32; x64)'; export const GEMINI_CLI_USER_AGENT = 'GeminiCLI/0.31.0/unknown (win32; x64)';
export const GEMINI_CLI_REQUIRED_SERVICE = 'cloudaicompanion.googleapis.com'; export const GEMINI_CLI_REQUIRED_SERVICE = 'cloudaicompanion.googleapis.com';
export const GEMINI_CLI_INTERNAL_API_VERSION = 'v1internal';
export const GEMINI_CLI_AUTO_ONBOARD_POLL_INTERVAL_MS = 2_000;
export const GEMINI_CLI_AUTO_ONBOARD_MAX_ATTEMPTS = 15;
export const GEMINI_CLI_ONBOARD_POLL_INTERVAL_MS = 5_000;
export const GEMINI_CLI_ONBOARD_MAX_ATTEMPTS = 6;
function requireGeminiCliOAuthConfig() { function requireGeminiCliOAuthConfig() {
if (!GEMINI_CLI_CLIENT_ID) { if (!GEMINI_CLI_CLIENT_ID) {
@@ -46,6 +51,16 @@ type GeminiOAuthTokenPayload = {
expiry?: unknown; expiry?: unknown;
}; };
type GeminiLoadCodeAssistPayload = {
cloudaicompanionProject?: unknown;
allowedTiers?: unknown;
};
type GeminiOnboardUserPayload = {
done?: unknown;
response?: unknown;
};
function asTrimmedString(value: unknown): string | undefined { function asTrimmedString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined; if (typeof value !== 'string') return undefined;
const trimmed = value.trim(); const trimmed = value.trim();
@@ -69,6 +84,69 @@ function parseExpiresAt(payload: GeminiOAuthTokenPayload): number | undefined {
return undefined; return undefined;
} }
function buildGeminiCliMetadata() {
return {
ideType: 'IDE_UNSPECIFIED',
platform: 'PLATFORM_UNSPECIFIED',
pluginType: 'GEMINI',
};
}
function extractGeminiProjectId(value: unknown): string | undefined {
if (typeof value === 'string') {
return asTrimmedString(value);
}
if (value && typeof value === 'object' && !Array.isArray(value)) {
return asTrimmedString((value as { id?: unknown }).id);
}
return undefined;
}
function extractGeminiDefaultTierId(payload: GeminiLoadCodeAssistPayload): string {
const allowedTiers = Array.isArray(payload.allowedTiers) ? payload.allowedTiers : [];
for (const rawTier of allowedTiers) {
if (!rawTier || typeof rawTier !== 'object' || Array.isArray(rawTier)) continue;
const tier = rawTier as { id?: unknown; isDefault?: unknown };
if (tier.isDefault === true) {
const tierId = asTrimmedString(tier.id);
if (tierId) return tierId;
}
}
return 'legacy-tier';
}
function isGeminiFreeUserProject(input: {
requestedProjectId?: string;
tierId: string;
}) {
const projectId = (input.requestedProjectId || '').trim();
const tierId = input.tierId.trim();
return projectId.startsWith('gen-lang-client-')
|| tierId.toUpperCase() === 'FREE'
|| tierId.toUpperCase() === 'LEGACY';
}
function isSameGeminiProjectId(a: string, b: string): boolean {
return a.trim().toLowerCase() === b.trim().toLowerCase();
}
function extractGeminiServiceErrorMessage(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) return '';
try {
const parsed = JSON.parse(trimmed) as {
error?: { message?: unknown };
};
return asTrimmedString(parsed.error?.message) || trimmed;
} catch {
return trimmed;
}
}
async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function postGeminiToken(body: URLSearchParams) { async function postGeminiToken(body: URLSearchParams) {
const response = await fetch(GEMINI_CLI_TOKEN_URL, { const response = await fetch(GEMINI_CLI_TOKEN_URL, {
method: 'POST', method: 'POST',
@@ -110,6 +188,32 @@ async function fetchGeminiUserEmail(accessToken: string): Promise<string | undef
return asTrimmedString(payload.email); return asTrimmedString(payload.email);
} }
async function callGeminiCliInternalApi<T>(
accessToken: string,
method: 'loadCodeAssist' | 'onboardUser',
body: Record<string, unknown>,
): Promise<T> {
const response = await fetch(
`${GEMINI_CLI_UPSTREAM_BASE_URL}/${GEMINI_CLI_INTERNAL_API_VERSION}:${method}`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
'Content-Type': 'application/json',
'User-Agent': GEMINI_CLI_USER_AGENT,
'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT,
},
body: JSON.stringify(body),
},
);
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(text || `api request failed with status ${response.status}`);
}
return response.json() as Promise<T>;
}
async function fetchGcpProjects(accessToken: string): Promise<string[]> { async function fetchGcpProjects(accessToken: string): Promise<string[]> {
const response = await fetch(GEMINI_CLI_PROJECTS_URL, { const response = await fetch(GEMINI_CLI_PROJECTS_URL, {
headers: { headers: {
@@ -127,45 +231,159 @@ async function fetchGcpProjects(accessToken: string): Promise<string[]> {
.filter((projectId): projectId is string => !!projectId); .filter((projectId): projectId is string => !!projectId);
} }
async function ensureGeminiProjectEnabled(accessToken: string, projectId: string): Promise<void> { async function checkCloudAIAPIEnabled(accessToken: string, projectId: string): Promise<void> {
const response = await fetch( const checkResponse = await fetch(
`${GEMINI_CLI_SERVICE_USAGE_URL}/projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(GEMINI_CLI_REQUIRED_SERVICE)}`, `${GEMINI_CLI_SERVICE_USAGE_URL}/projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(GEMINI_CLI_REQUIRED_SERVICE)}`,
{ {
headers: { headers: {
Authorization: `Bearer ${accessToken}`, Authorization: `Bearer ${accessToken}`,
Accept: 'application/json', Accept: 'application/json',
'User-Agent': GEMINI_CLI_USER_AGENT,
'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT,
}, },
}, },
); );
if (!response.ok) { if (checkResponse.ok) {
const text = await response.text().catch(() => ''); const payload = await checkResponse.json() as { state?: unknown };
throw new Error(text || `service usage lookup failed with status ${response.status}`);
}
const payload = await response.json() as { state?: unknown };
const state = asTrimmedString(payload.state); const state = asTrimmedString(payload.state);
if ((state || '').toUpperCase() !== 'ENABLED') { if ((state || '').toUpperCase() === 'ENABLED') {
throw new Error(`Cloud AI API not enabled for project ${projectId}`); return;
} }
} }
async function resolveGeminiProjectId(accessToken: string, requestedProjectId?: string): Promise<string> { const response = await fetch(
`${GEMINI_CLI_SERVICE_USAGE_URL}/projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(GEMINI_CLI_REQUIRED_SERVICE)}:enable`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
'Content-Type': 'application/json',
'User-Agent': GEMINI_CLI_USER_AGENT,
'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT,
},
body: '{}',
},
);
const text = await response.text().catch(() => '');
if (response.ok) {
return;
}
if (response.status === 400 && extractGeminiServiceErrorMessage(text).toLowerCase().includes('already enabled')) {
return;
}
throw new Error(`project activation required: ${extractGeminiServiceErrorMessage(text) || `HTTP ${response.status}`}`);
}
async function performGeminiCliSetup(
accessToken: string,
requestedProjectId: string,
): Promise<string> {
const trimmedRequest = requestedProjectId.trim();
const explicitProject = !!trimmedRequest;
const metadata = buildGeminiCliMetadata();
const loadResponse = await callGeminiCliInternalApi<GeminiLoadCodeAssistPayload>(
accessToken,
'loadCodeAssist',
explicitProject
? {
metadata,
cloudaicompanionProject: trimmedRequest,
}
: { metadata },
);
const tierId = extractGeminiDefaultTierId(loadResponse);
let projectId = trimmedRequest || extractGeminiProjectId(loadResponse.cloudaicompanionProject) || '';
if (!projectId) {
for (let attempt = 0; attempt < GEMINI_CLI_AUTO_ONBOARD_MAX_ATTEMPTS; attempt += 1) {
const onboardResponse = await callGeminiCliInternalApi<GeminiOnboardUserPayload>(
accessToken,
'onboardUser',
{
tierId,
metadata,
},
);
if (onboardResponse.done === true) {
const response = (
onboardResponse.response
&& typeof onboardResponse.response === 'object'
&& !Array.isArray(onboardResponse.response)
)
? onboardResponse.response as { cloudaicompanionProject?: unknown }
: undefined;
projectId = extractGeminiProjectId(response?.cloudaicompanionProject) || '';
break;
}
if ((attempt + 1) < GEMINI_CLI_AUTO_ONBOARD_MAX_ATTEMPTS) {
await sleep(GEMINI_CLI_AUTO_ONBOARD_POLL_INTERVAL_MS);
}
}
}
if (!projectId) {
throw new Error('gemini cli: project selection required');
}
let finalProjectId = projectId;
for (let attempt = 0; attempt < GEMINI_CLI_ONBOARD_MAX_ATTEMPTS; attempt += 1) {
const onboardResponse = await callGeminiCliInternalApi<GeminiOnboardUserPayload>(
accessToken,
'onboardUser',
{
tierId,
metadata,
cloudaicompanionProject: projectId,
},
);
if (onboardResponse.done === true) {
const response = (
onboardResponse.response
&& typeof onboardResponse.response === 'object'
&& !Array.isArray(onboardResponse.response)
)
? onboardResponse.response as { cloudaicompanionProject?: unknown }
: undefined;
const responseProjectId = extractGeminiProjectId(response?.cloudaicompanionProject) || '';
if (responseProjectId) {
if (explicitProject && !isSameGeminiProjectId(responseProjectId, projectId)) {
finalProjectId = isGeminiFreeUserProject({ requestedProjectId: projectId, tierId })
? responseProjectId
: projectId;
} else {
finalProjectId = responseProjectId;
}
}
return finalProjectId || projectId;
}
if ((attempt + 1) < GEMINI_CLI_ONBOARD_MAX_ATTEMPTS) {
await sleep(GEMINI_CLI_ONBOARD_POLL_INTERVAL_MS);
}
}
if (finalProjectId) {
return finalProjectId;
}
throw new Error('gemini cli: onboarding timed out');
}
async function ensureGeminiProjectAndOnboard(
accessToken: string,
requestedProjectId?: string,
): Promise<string> {
const explicitProject = asTrimmedString(requestedProjectId); const explicitProject = asTrimmedString(requestedProjectId);
if (explicitProject) { if (explicitProject) {
await ensureGeminiProjectEnabled(accessToken, explicitProject); return performGeminiCliSetup(accessToken, explicitProject);
return explicitProject;
} }
const projects = await fetchGcpProjects(accessToken); const projects = await fetchGcpProjects(accessToken);
if (projects.length <= 0) {
throw new Error('no Google Cloud projects available for this account');
}
const firstProject = projects[0]; const firstProject = projects[0];
if (!firstProject) { if (!firstProject) {
throw new Error('no Google Cloud projects available for this account'); throw new Error('no Google Cloud projects available for this account');
} }
await ensureGeminiProjectEnabled(accessToken, firstProject); return performGeminiCliSetup(accessToken, firstProject);
return firstProject;
} }
export const geminiCliOauthProvider: OAuthProviderDefinition = { export const geminiCliOauthProvider: OAuthProviderDefinition = {
@@ -213,7 +431,8 @@ export const geminiCliOauthProvider: OAuthProviderDefinition = {
redirect_uri: redirectUri, redirect_uri: redirectUri,
grant_type: 'authorization_code', grant_type: 'authorization_code',
})); }));
const resolvedProjectId = await resolveGeminiProjectId(token.accessToken, projectId); const resolvedProjectId = await ensureGeminiProjectAndOnboard(token.accessToken, projectId);
await checkCloudAIAPIEnabled(token.accessToken, resolvedProjectId);
const email = await fetchGeminiUserEmail(token.accessToken); const email = await fetchGeminiUserEmail(token.accessToken);
return { return {
...token, ...token,
@@ -231,16 +450,17 @@ export const geminiCliOauthProvider: OAuthProviderDefinition = {
grant_type: 'refresh_token', grant_type: 'refresh_token',
refresh_token: refreshToken, refresh_token: refreshToken,
})); }));
if (oauth?.projectId) { const nextProjectId = oauth?.projectId
await ensureGeminiProjectEnabled(token.accessToken, oauth.projectId); ? oauth.projectId
} : await ensureGeminiProjectAndOnboard(token.accessToken);
await checkCloudAIAPIEnabled(token.accessToken, nextProjectId);
const email = await fetchGeminiUserEmail(token.accessToken); const email = await fetchGeminiUserEmail(token.accessToken);
return { return {
...token, ...token,
email, email,
accountKey: email, accountKey: email,
accountId: email, accountId: email,
projectId: oauth?.projectId, projectId: nextProjectId,
}; };
}, },
buildProxyHeaders: () => ({ buildProxyHeaders: () => ({