forked from boos/BoosAPI
Merge pull request #144 from cita-777/fix/oauth-onboarding-cliproxyapi
fix: align oauth onboarding with cliproxyapi
This commit is contained in:
@@ -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 () => {
|
||||
const startResponse = await app.inject({
|
||||
method: 'POST',
|
||||
@@ -461,6 +576,32 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
||||
}),
|
||||
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({
|
||||
ok: true,
|
||||
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[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[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 () => {
|
||||
|
||||
@@ -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_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_INTERNAL_API_VERSION = 'v1internal';
|
||||
export const ANTIGRAVITY_ONBOARD_POLL_INTERVAL_MS = 2_000;
|
||||
export const ANTIGRAVITY_ONBOARD_MAX_ATTEMPTS = 5;
|
||||
|
||||
const ANTIGRAVITY_SCOPES = [
|
||||
'https://www.googleapis.com/auth/cloud-platform',
|
||||
@@ -34,6 +37,16 @@ type AntigravityOAuthTokenPayload = {
|
||||
expiry?: unknown;
|
||||
};
|
||||
|
||||
type AntigravityLoadCodeAssistPayload = {
|
||||
cloudaicompanionProject?: unknown;
|
||||
allowedTiers?: unknown;
|
||||
};
|
||||
|
||||
type AntigravityOnboardUserPayload = {
|
||||
done?: unknown;
|
||||
response?: unknown;
|
||||
};
|
||||
|
||||
function asTrimmedString(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const trimmed = value.trim();
|
||||
@@ -57,6 +70,65 @@ function parseExpiresAt(payload: AntigravityOAuthTokenPayload): number | undefin
|
||||
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) {
|
||||
const response = await fetch(ANTIGRAVITY_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
@@ -99,34 +171,45 @@ async function fetchAntigravityUserEmail(accessToken: string): Promise<string |
|
||||
}
|
||||
|
||||
async function fetchAntigravityProjectId(accessToken: string): Promise<string | undefined> {
|
||||
const response = await fetch(`${ANTIGRAVITY_UPSTREAM_BASE_URL}/v1internal:loadCodeAssist`, {
|
||||
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({
|
||||
metadata: {
|
||||
ideType: 'ANTIGRAVITY',
|
||||
platform: 'PLATFORM_UNSPECIFIED',
|
||||
pluginType: 'GEMINI',
|
||||
const metadata = buildAntigravityMetadata();
|
||||
const payload = await callAntigravityInternalApi<AntigravityLoadCodeAssistPayload>(
|
||||
accessToken,
|
||||
'loadCodeAssist',
|
||||
{ metadata },
|
||||
);
|
||||
if (!payload) return undefined;
|
||||
|
||||
const discoveredFromLoad = extractAntigravityProjectId(payload.cloudaicompanionProject);
|
||||
if (discoveredFromLoad) {
|
||||
return discoveredFromLoad;
|
||||
}
|
||||
|
||||
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 (!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);
|
||||
);
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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_USER_AGENT = 'GeminiCLI/0.31.0/unknown (win32; x64)';
|
||||
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() {
|
||||
if (!GEMINI_CLI_CLIENT_ID) {
|
||||
@@ -46,6 +51,16 @@ type GeminiOAuthTokenPayload = {
|
||||
expiry?: unknown;
|
||||
};
|
||||
|
||||
type GeminiLoadCodeAssistPayload = {
|
||||
cloudaicompanionProject?: unknown;
|
||||
allowedTiers?: unknown;
|
||||
};
|
||||
|
||||
type GeminiOnboardUserPayload = {
|
||||
done?: unknown;
|
||||
response?: unknown;
|
||||
};
|
||||
|
||||
function asTrimmedString(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const trimmed = value.trim();
|
||||
@@ -69,6 +84,69 @@ function parseExpiresAt(payload: GeminiOAuthTokenPayload): number | 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) {
|
||||
const response = await fetch(GEMINI_CLI_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
@@ -110,6 +188,32 @@ async function fetchGeminiUserEmail(accessToken: string): Promise<string | undef
|
||||
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[]> {
|
||||
const response = await fetch(GEMINI_CLI_PROJECTS_URL, {
|
||||
headers: {
|
||||
@@ -127,45 +231,159 @@ async function fetchGcpProjects(accessToken: string): Promise<string[]> {
|
||||
.filter((projectId): projectId is string => !!projectId);
|
||||
}
|
||||
|
||||
async function ensureGeminiProjectEnabled(accessToken: string, projectId: string): Promise<void> {
|
||||
const response = await fetch(
|
||||
async function checkCloudAIAPIEnabled(accessToken: string, projectId: string): Promise<void> {
|
||||
const checkResponse = await fetch(
|
||||
`${GEMINI_CLI_SERVICE_USAGE_URL}/projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(GEMINI_CLI_REQUIRED_SERVICE)}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
'User-Agent': GEMINI_CLI_USER_AGENT,
|
||||
'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(text || `service usage lookup failed with status ${response.status}`);
|
||||
if (checkResponse.ok) {
|
||||
const payload = await checkResponse.json() as { state?: unknown };
|
||||
const state = asTrimmedString(payload.state);
|
||||
if ((state || '').toUpperCase() === 'ENABLED') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const payload = await response.json() as { state?: unknown };
|
||||
const state = asTrimmedString(payload.state);
|
||||
if ((state || '').toUpperCase() !== 'ENABLED') {
|
||||
throw new Error(`Cloud AI API not enabled for project ${projectId}`);
|
||||
|
||||
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 resolveGeminiProjectId(accessToken: string, requestedProjectId?: string): Promise<string> {
|
||||
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);
|
||||
if (explicitProject) {
|
||||
await ensureGeminiProjectEnabled(accessToken, explicitProject);
|
||||
return explicitProject;
|
||||
return performGeminiCliSetup(accessToken, explicitProject);
|
||||
}
|
||||
|
||||
const projects = await fetchGcpProjects(accessToken);
|
||||
if (projects.length <= 0) {
|
||||
throw new Error('no Google Cloud projects available for this account');
|
||||
}
|
||||
|
||||
const firstProject = projects[0];
|
||||
if (!firstProject) {
|
||||
throw new Error('no Google Cloud projects available for this account');
|
||||
}
|
||||
await ensureGeminiProjectEnabled(accessToken, firstProject);
|
||||
return firstProject;
|
||||
return performGeminiCliSetup(accessToken, firstProject);
|
||||
}
|
||||
|
||||
export const geminiCliOauthProvider: OAuthProviderDefinition = {
|
||||
@@ -213,7 +431,8 @@ export const geminiCliOauthProvider: OAuthProviderDefinition = {
|
||||
redirect_uri: redirectUri,
|
||||
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);
|
||||
return {
|
||||
...token,
|
||||
@@ -231,16 +450,17 @@ export const geminiCliOauthProvider: OAuthProviderDefinition = {
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
}));
|
||||
if (oauth?.projectId) {
|
||||
await ensureGeminiProjectEnabled(token.accessToken, oauth.projectId);
|
||||
}
|
||||
const nextProjectId = oauth?.projectId
|
||||
? oauth.projectId
|
||||
: await ensureGeminiProjectAndOnboard(token.accessToken);
|
||||
await checkCloudAIAPIEnabled(token.accessToken, nextProjectId);
|
||||
const email = await fetchGeminiUserEmail(token.accessToken);
|
||||
return {
|
||||
...token,
|
||||
email,
|
||||
accountKey: email,
|
||||
accountId: email,
|
||||
projectId: oauth?.projectId,
|
||||
projectId: nextProjectId,
|
||||
};
|
||||
},
|
||||
buildProxyHeaders: () => ({
|
||||
|
||||
Reference in New Issue
Block a user