@oh-my-pi/pi-ai 1.337.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +962 -0
- package/package.json +60 -0
- package/src/cli.ts +171 -0
- package/src/index.ts +13 -0
- package/src/models.generated.ts +7105 -0
- package/src/models.ts +68 -0
- package/src/providers/anthropic.ts +587 -0
- package/src/providers/google-gemini-cli.ts +603 -0
- package/src/providers/google-shared.ts +227 -0
- package/src/providers/google.ts +324 -0
- package/src/providers/openai-completions.ts +675 -0
- package/src/providers/openai-responses.ts +569 -0
- package/src/providers/transorm-messages.ts +143 -0
- package/src/stream.ts +340 -0
- package/src/types.ts +218 -0
- package/src/utils/event-stream.ts +82 -0
- package/src/utils/json-parse.ts +28 -0
- package/src/utils/oauth/anthropic.ts +118 -0
- package/src/utils/oauth/github-copilot.ts +311 -0
- package/src/utils/oauth/google-antigravity.ts +322 -0
- package/src/utils/oauth/google-gemini-cli.ts +353 -0
- package/src/utils/oauth/index.ts +143 -0
- package/src/utils/oauth/pkce.ts +34 -0
- package/src/utils/oauth/types.ts +27 -0
- package/src/utils/overflow.ts +115 -0
- package/src/utils/sanitize-unicode.ts +25 -0
- package/src/utils/typebox-helpers.ts +24 -0
- package/src/utils/validation.ts +80 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Antigravity OAuth flow (Gemini 3, Claude, GPT-OSS via Google Cloud)
|
|
3
|
+
* Uses different OAuth credentials than google-gemini-cli for access to additional models.
|
|
4
|
+
*
|
|
5
|
+
* NOTE: This module uses Node.js http.createServer for the OAuth callback.
|
|
6
|
+
* It is only intended for CLI use, not browser environments.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { generatePKCE } from "./pkce.js";
|
|
10
|
+
import type { OAuthCredentials } from "./types.js";
|
|
11
|
+
|
|
12
|
+
// Antigravity OAuth credentials (different from Gemini CLI)
|
|
13
|
+
const decode = (s: string) => atob(s);
|
|
14
|
+
const CLIENT_ID = decode(
|
|
15
|
+
"MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==",
|
|
16
|
+
);
|
|
17
|
+
const CLIENT_SECRET = decode("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY=");
|
|
18
|
+
const REDIRECT_URI = "http://localhost:51121/oauth-callback";
|
|
19
|
+
|
|
20
|
+
// Antigravity requires additional scopes
|
|
21
|
+
const SCOPES = [
|
|
22
|
+
"https://www.googleapis.com/auth/cloud-platform",
|
|
23
|
+
"https://www.googleapis.com/auth/userinfo.email",
|
|
24
|
+
"https://www.googleapis.com/auth/userinfo.profile",
|
|
25
|
+
"https://www.googleapis.com/auth/cclog",
|
|
26
|
+
"https://www.googleapis.com/auth/experimentsandconfigs",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
30
|
+
const TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
31
|
+
|
|
32
|
+
// Fallback project ID when discovery fails
|
|
33
|
+
const DEFAULT_PROJECT_ID = "rising-fact-p41fc";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Start a local HTTP server to receive the OAuth callback
|
|
37
|
+
*/
|
|
38
|
+
async function startCallbackServer(): Promise<{
|
|
39
|
+
server: { stop: () => void };
|
|
40
|
+
getCode: () => Promise<{ code: string; state: string }>;
|
|
41
|
+
}> {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
let codeResolve: (value: { code: string; state: string }) => void;
|
|
44
|
+
let codeReject: (error: Error) => void;
|
|
45
|
+
|
|
46
|
+
const codePromise = new Promise<{ code: string; state: string }>((res, rej) => {
|
|
47
|
+
codeResolve = res;
|
|
48
|
+
codeReject = rej;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const server = Bun.serve({
|
|
52
|
+
port: 51121,
|
|
53
|
+
hostname: "127.0.0.1",
|
|
54
|
+
fetch(req) {
|
|
55
|
+
const url = new URL(req.url);
|
|
56
|
+
|
|
57
|
+
if (url.pathname === "/oauth-callback") {
|
|
58
|
+
const code = url.searchParams.get("code");
|
|
59
|
+
const state = url.searchParams.get("state");
|
|
60
|
+
const error = url.searchParams.get("error");
|
|
61
|
+
|
|
62
|
+
if (error) {
|
|
63
|
+
codeReject(new Error(`OAuth error: ${error}`));
|
|
64
|
+
return new Response(
|
|
65
|
+
`<html><body><h1>Authentication Failed</h1><p>Error: ${error}</p><p>You can close this window.</p></body></html>`,
|
|
66
|
+
{ status: 400, headers: { "Content-Type": "text/html" } },
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (code && state) {
|
|
71
|
+
codeResolve({ code, state });
|
|
72
|
+
return new Response(
|
|
73
|
+
`<html><body><h1>Authentication Successful</h1><p>You can close this window and return to the terminal.</p></body></html>`,
|
|
74
|
+
{ status: 200, headers: { "Content-Type": "text/html" } },
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
codeReject(new Error("Missing code or state in callback"));
|
|
79
|
+
return new Response(
|
|
80
|
+
`<html><body><h1>Authentication Failed</h1><p>Missing code or state parameter.</p></body></html>`,
|
|
81
|
+
{ status: 400, headers: { "Content-Type": "text/html" } },
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return new Response(null, { status: 404 });
|
|
86
|
+
},
|
|
87
|
+
error(err) {
|
|
88
|
+
reject(err);
|
|
89
|
+
return new Response("Internal Server Error", { status: 500 });
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
resolve({
|
|
94
|
+
server,
|
|
95
|
+
getCode: () => codePromise,
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface LoadCodeAssistPayload {
|
|
101
|
+
cloudaicompanionProject?: string | { id?: string };
|
|
102
|
+
currentTier?: { id?: string };
|
|
103
|
+
allowedTiers?: Array<{ id?: string; isDefault?: boolean }>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Discover or provision a project for the user
|
|
108
|
+
*/
|
|
109
|
+
async function discoverProject(accessToken: string, onProgress?: (message: string) => void): Promise<string> {
|
|
110
|
+
const headers = {
|
|
111
|
+
Authorization: `Bearer ${accessToken}`,
|
|
112
|
+
"Content-Type": "application/json",
|
|
113
|
+
"User-Agent": "google-api-nodejs-client/9.15.1",
|
|
114
|
+
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
|
|
115
|
+
"Client-Metadata": JSON.stringify({
|
|
116
|
+
ideType: "IDE_UNSPECIFIED",
|
|
117
|
+
platform: "PLATFORM_UNSPECIFIED",
|
|
118
|
+
pluginType: "GEMINI",
|
|
119
|
+
}),
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// Try endpoints in order: prod first, then sandbox
|
|
123
|
+
const endpoints = ["https://cloudcode-pa.googleapis.com", "https://daily-cloudcode-pa.sandbox.googleapis.com"];
|
|
124
|
+
|
|
125
|
+
onProgress?.("Checking for existing project...");
|
|
126
|
+
|
|
127
|
+
for (const endpoint of endpoints) {
|
|
128
|
+
try {
|
|
129
|
+
const loadResponse = await fetch(`${endpoint}/v1internal:loadCodeAssist`, {
|
|
130
|
+
method: "POST",
|
|
131
|
+
headers,
|
|
132
|
+
body: JSON.stringify({
|
|
133
|
+
metadata: {
|
|
134
|
+
ideType: "IDE_UNSPECIFIED",
|
|
135
|
+
platform: "PLATFORM_UNSPECIFIED",
|
|
136
|
+
pluginType: "GEMINI",
|
|
137
|
+
},
|
|
138
|
+
}),
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
if (loadResponse.ok) {
|
|
142
|
+
const data = (await loadResponse.json()) as LoadCodeAssistPayload;
|
|
143
|
+
|
|
144
|
+
// Handle both string and object formats
|
|
145
|
+
if (typeof data.cloudaicompanionProject === "string" && data.cloudaicompanionProject) {
|
|
146
|
+
return data.cloudaicompanionProject;
|
|
147
|
+
}
|
|
148
|
+
if (
|
|
149
|
+
data.cloudaicompanionProject &&
|
|
150
|
+
typeof data.cloudaicompanionProject === "object" &&
|
|
151
|
+
data.cloudaicompanionProject.id
|
|
152
|
+
) {
|
|
153
|
+
return data.cloudaicompanionProject.id;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
} catch {
|
|
157
|
+
// Try next endpoint
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Use fallback project ID
|
|
162
|
+
onProgress?.("Using default project...");
|
|
163
|
+
return DEFAULT_PROJECT_ID;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Get user email from the access token
|
|
168
|
+
*/
|
|
169
|
+
async function getUserEmail(accessToken: string): Promise<string | undefined> {
|
|
170
|
+
try {
|
|
171
|
+
const response = await fetch("https://www.googleapis.com/oauth2/v1/userinfo?alt=json", {
|
|
172
|
+
headers: {
|
|
173
|
+
Authorization: `Bearer ${accessToken}`,
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
if (response.ok) {
|
|
178
|
+
const data = (await response.json()) as { email?: string };
|
|
179
|
+
return data.email;
|
|
180
|
+
}
|
|
181
|
+
} catch {
|
|
182
|
+
// Ignore errors, email is optional
|
|
183
|
+
}
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Refresh Antigravity token
|
|
189
|
+
*/
|
|
190
|
+
export async function refreshAntigravityToken(refreshToken: string, projectId: string): Promise<OAuthCredentials> {
|
|
191
|
+
const response = await fetch(TOKEN_URL, {
|
|
192
|
+
method: "POST",
|
|
193
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
194
|
+
body: new URLSearchParams({
|
|
195
|
+
client_id: CLIENT_ID,
|
|
196
|
+
client_secret: CLIENT_SECRET,
|
|
197
|
+
refresh_token: refreshToken,
|
|
198
|
+
grant_type: "refresh_token",
|
|
199
|
+
}),
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
if (!response.ok) {
|
|
203
|
+
const error = await response.text();
|
|
204
|
+
throw new Error(`Antigravity token refresh failed: ${error}`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const data = (await response.json()) as {
|
|
208
|
+
access_token: string;
|
|
209
|
+
expires_in: number;
|
|
210
|
+
refresh_token?: string;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
refresh: data.refresh_token || refreshToken,
|
|
215
|
+
access: data.access_token,
|
|
216
|
+
expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000,
|
|
217
|
+
projectId,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Login with Antigravity OAuth
|
|
223
|
+
*
|
|
224
|
+
* @param onAuth - Callback with URL and optional instructions
|
|
225
|
+
* @param onProgress - Optional progress callback
|
|
226
|
+
*/
|
|
227
|
+
export async function loginAntigravity(
|
|
228
|
+
onAuth: (info: { url: string; instructions?: string }) => void,
|
|
229
|
+
onProgress?: (message: string) => void,
|
|
230
|
+
): Promise<OAuthCredentials> {
|
|
231
|
+
const { verifier, challenge } = await generatePKCE();
|
|
232
|
+
|
|
233
|
+
// Start local server for callback
|
|
234
|
+
onProgress?.("Starting local server for OAuth callback...");
|
|
235
|
+
const { server, getCode } = await startCallbackServer();
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
// Build authorization URL
|
|
239
|
+
const authParams = new URLSearchParams({
|
|
240
|
+
client_id: CLIENT_ID,
|
|
241
|
+
response_type: "code",
|
|
242
|
+
redirect_uri: REDIRECT_URI,
|
|
243
|
+
scope: SCOPES.join(" "),
|
|
244
|
+
code_challenge: challenge,
|
|
245
|
+
code_challenge_method: "S256",
|
|
246
|
+
state: verifier,
|
|
247
|
+
access_type: "offline",
|
|
248
|
+
prompt: "consent",
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const authUrl = `${AUTH_URL}?${authParams.toString()}`;
|
|
252
|
+
|
|
253
|
+
// Notify caller with URL to open
|
|
254
|
+
onAuth({
|
|
255
|
+
url: authUrl,
|
|
256
|
+
instructions: "Complete the sign-in in your browser. The callback will be captured automatically.",
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// Wait for the callback
|
|
260
|
+
onProgress?.("Waiting for OAuth callback...");
|
|
261
|
+
const { code, state } = await getCode();
|
|
262
|
+
|
|
263
|
+
// Verify state matches
|
|
264
|
+
if (state !== verifier) {
|
|
265
|
+
throw new Error("OAuth state mismatch - possible CSRF attack");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Exchange code for tokens
|
|
269
|
+
onProgress?.("Exchanging authorization code for tokens...");
|
|
270
|
+
const tokenResponse = await fetch(TOKEN_URL, {
|
|
271
|
+
method: "POST",
|
|
272
|
+
headers: {
|
|
273
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
274
|
+
},
|
|
275
|
+
body: new URLSearchParams({
|
|
276
|
+
client_id: CLIENT_ID,
|
|
277
|
+
client_secret: CLIENT_SECRET,
|
|
278
|
+
code,
|
|
279
|
+
grant_type: "authorization_code",
|
|
280
|
+
redirect_uri: REDIRECT_URI,
|
|
281
|
+
code_verifier: verifier,
|
|
282
|
+
}),
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
if (!tokenResponse.ok) {
|
|
286
|
+
const error = await tokenResponse.text();
|
|
287
|
+
throw new Error(`Token exchange failed: ${error}`);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const tokenData = (await tokenResponse.json()) as {
|
|
291
|
+
access_token: string;
|
|
292
|
+
refresh_token: string;
|
|
293
|
+
expires_in: number;
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
if (!tokenData.refresh_token) {
|
|
297
|
+
throw new Error("No refresh token received. Please try again.");
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Get user email
|
|
301
|
+
onProgress?.("Getting user info...");
|
|
302
|
+
const email = await getUserEmail(tokenData.access_token);
|
|
303
|
+
|
|
304
|
+
// Discover project
|
|
305
|
+
const projectId = await discoverProject(tokenData.access_token, onProgress);
|
|
306
|
+
|
|
307
|
+
// Calculate expiry time (current time + expires_in seconds - 5 min buffer)
|
|
308
|
+
const expiresAt = Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000;
|
|
309
|
+
|
|
310
|
+
const credentials: OAuthCredentials = {
|
|
311
|
+
refresh: tokenData.refresh_token,
|
|
312
|
+
access: tokenData.access_token,
|
|
313
|
+
expires: expiresAt,
|
|
314
|
+
projectId,
|
|
315
|
+
email,
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
return credentials;
|
|
319
|
+
} finally {
|
|
320
|
+
server.stop();
|
|
321
|
+
}
|
|
322
|
+
}
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemini CLI OAuth flow (Google Cloud Code Assist)
|
|
3
|
+
* Standard Gemini models only (gemini-2.0-flash, gemini-2.5-*)
|
|
4
|
+
*
|
|
5
|
+
* NOTE: This module uses Node.js http.createServer for the OAuth callback.
|
|
6
|
+
* It is only intended for CLI use, not browser environments.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { generatePKCE } from "./pkce.js";
|
|
10
|
+
import type { OAuthCredentials } from "./types.js";
|
|
11
|
+
|
|
12
|
+
const decode = (s: string) => atob(s);
|
|
13
|
+
const CLIENT_ID = decode(
|
|
14
|
+
"NjgxMjU1ODA5Mzk1LW9vOGZ0Mm9wcmRybnA5ZTNhcWY2YXYzaG1kaWIxMzVqLmFwcHMuZ29vZ2xldXNlcmNvbnRlbnQuY29t",
|
|
15
|
+
);
|
|
16
|
+
const CLIENT_SECRET = decode("R09DU1BYLTR1SGdNUG0tMW83U2stZ2VWNkN1NWNsWEZzeGw=");
|
|
17
|
+
const REDIRECT_URI = "http://localhost:8085/oauth2callback";
|
|
18
|
+
const SCOPES = [
|
|
19
|
+
"https://www.googleapis.com/auth/cloud-platform",
|
|
20
|
+
"https://www.googleapis.com/auth/userinfo.email",
|
|
21
|
+
"https://www.googleapis.com/auth/userinfo.profile",
|
|
22
|
+
];
|
|
23
|
+
const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
24
|
+
const TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
25
|
+
const CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Start a local HTTP server to receive the OAuth callback
|
|
29
|
+
*/
|
|
30
|
+
async function startCallbackServer(): Promise<{
|
|
31
|
+
server: { stop: () => void };
|
|
32
|
+
getCode: () => Promise<{ code: string; state: string }>;
|
|
33
|
+
}> {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
let codeResolve: (value: { code: string; state: string }) => void;
|
|
36
|
+
let codeReject: (error: Error) => void;
|
|
37
|
+
|
|
38
|
+
const codePromise = new Promise<{ code: string; state: string }>((res, rej) => {
|
|
39
|
+
codeResolve = res;
|
|
40
|
+
codeReject = rej;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const server = Bun.serve({
|
|
44
|
+
port: 8085,
|
|
45
|
+
hostname: "127.0.0.1",
|
|
46
|
+
fetch(req) {
|
|
47
|
+
const url = new URL(req.url);
|
|
48
|
+
|
|
49
|
+
if (url.pathname === "/oauth2callback") {
|
|
50
|
+
const code = url.searchParams.get("code");
|
|
51
|
+
const state = url.searchParams.get("state");
|
|
52
|
+
const error = url.searchParams.get("error");
|
|
53
|
+
|
|
54
|
+
if (error) {
|
|
55
|
+
codeReject(new Error(`OAuth error: ${error}`));
|
|
56
|
+
return new Response(
|
|
57
|
+
`<html><body><h1>Authentication Failed</h1><p>Error: ${error}</p><p>You can close this window.</p></body></html>`,
|
|
58
|
+
{ status: 400, headers: { "Content-Type": "text/html" } },
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (code && state) {
|
|
63
|
+
codeResolve({ code, state });
|
|
64
|
+
return new Response(
|
|
65
|
+
`<html><body><h1>Authentication Successful</h1><p>You can close this window and return to the terminal.</p></body></html>`,
|
|
66
|
+
{ status: 200, headers: { "Content-Type": "text/html" } },
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
codeReject(new Error("Missing code or state in callback"));
|
|
71
|
+
return new Response(
|
|
72
|
+
`<html><body><h1>Authentication Failed</h1><p>Missing code or state parameter.</p></body></html>`,
|
|
73
|
+
{ status: 400, headers: { "Content-Type": "text/html" } },
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return new Response(null, { status: 404 });
|
|
78
|
+
},
|
|
79
|
+
error(err) {
|
|
80
|
+
reject(err);
|
|
81
|
+
return new Response("Internal Server Error", { status: 500 });
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
resolve({
|
|
86
|
+
server,
|
|
87
|
+
getCode: () => codePromise,
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface LoadCodeAssistPayload {
|
|
93
|
+
cloudaicompanionProject?: string;
|
|
94
|
+
currentTier?: { id?: string };
|
|
95
|
+
allowedTiers?: Array<{ id?: string; isDefault?: boolean }>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface OnboardUserPayload {
|
|
99
|
+
done?: boolean;
|
|
100
|
+
response?: {
|
|
101
|
+
cloudaicompanionProject?: { id?: string };
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Wait helper for onboarding retries
|
|
107
|
+
*/
|
|
108
|
+
function wait(ms: number): Promise<void> {
|
|
109
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Get default tier ID from allowed tiers
|
|
114
|
+
*/
|
|
115
|
+
function getDefaultTierId(allowedTiers?: Array<{ id?: string; isDefault?: boolean }>): string | undefined {
|
|
116
|
+
if (!allowedTiers || allowedTiers.length === 0) return undefined;
|
|
117
|
+
const defaultTier = allowedTiers.find((t) => t.isDefault);
|
|
118
|
+
return defaultTier?.id ?? allowedTiers[0]?.id;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Discover or provision a Google Cloud project for the user
|
|
123
|
+
*/
|
|
124
|
+
async function discoverProject(accessToken: string, onProgress?: (message: string) => void): Promise<string> {
|
|
125
|
+
const headers = {
|
|
126
|
+
Authorization: `Bearer ${accessToken}`,
|
|
127
|
+
"Content-Type": "application/json",
|
|
128
|
+
"User-Agent": "google-api-nodejs-client/9.15.1",
|
|
129
|
+
"X-Goog-Api-Client": "gl-node/22.17.0",
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// Try to load existing project via loadCodeAssist
|
|
133
|
+
onProgress?.("Checking for existing Cloud Code Assist project...");
|
|
134
|
+
const loadResponse = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:loadCodeAssist`, {
|
|
135
|
+
method: "POST",
|
|
136
|
+
headers,
|
|
137
|
+
body: JSON.stringify({
|
|
138
|
+
metadata: {
|
|
139
|
+
ideType: "IDE_UNSPECIFIED",
|
|
140
|
+
platform: "PLATFORM_UNSPECIFIED",
|
|
141
|
+
pluginType: "GEMINI",
|
|
142
|
+
},
|
|
143
|
+
}),
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
if (loadResponse.ok) {
|
|
147
|
+
const data = (await loadResponse.json()) as LoadCodeAssistPayload;
|
|
148
|
+
|
|
149
|
+
// If we have an existing project, use it
|
|
150
|
+
if (data.cloudaicompanionProject) {
|
|
151
|
+
return data.cloudaicompanionProject;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Otherwise, try to onboard with the FREE tier
|
|
155
|
+
const tierId = getDefaultTierId(data.allowedTiers) ?? "FREE";
|
|
156
|
+
|
|
157
|
+
onProgress?.("Provisioning Cloud Code Assist project (this may take a moment)...");
|
|
158
|
+
|
|
159
|
+
// Onboard with retries (the API may take time to provision)
|
|
160
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
161
|
+
const onboardResponse = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:onboardUser`, {
|
|
162
|
+
method: "POST",
|
|
163
|
+
headers,
|
|
164
|
+
body: JSON.stringify({
|
|
165
|
+
tierId,
|
|
166
|
+
metadata: {
|
|
167
|
+
ideType: "IDE_UNSPECIFIED",
|
|
168
|
+
platform: "PLATFORM_UNSPECIFIED",
|
|
169
|
+
pluginType: "GEMINI",
|
|
170
|
+
},
|
|
171
|
+
}),
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
if (onboardResponse.ok) {
|
|
175
|
+
const onboardData = (await onboardResponse.json()) as OnboardUserPayload;
|
|
176
|
+
const projectId = onboardData.response?.cloudaicompanionProject?.id;
|
|
177
|
+
|
|
178
|
+
if (onboardData.done && projectId) {
|
|
179
|
+
return projectId;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Wait before retrying
|
|
184
|
+
if (attempt < 9) {
|
|
185
|
+
onProgress?.(`Waiting for project provisioning (attempt ${attempt + 2}/10)...`);
|
|
186
|
+
await wait(3000);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
throw new Error(
|
|
192
|
+
"Could not discover or provision a Google Cloud project. " +
|
|
193
|
+
"Please ensure you have access to Google Cloud Code Assist (Gemini CLI).",
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Get user email from the access token
|
|
199
|
+
*/
|
|
200
|
+
async function getUserEmail(accessToken: string): Promise<string | undefined> {
|
|
201
|
+
try {
|
|
202
|
+
const response = await fetch("https://www.googleapis.com/oauth2/v1/userinfo?alt=json", {
|
|
203
|
+
headers: {
|
|
204
|
+
Authorization: `Bearer ${accessToken}`,
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
if (response.ok) {
|
|
209
|
+
const data = (await response.json()) as { email?: string };
|
|
210
|
+
return data.email;
|
|
211
|
+
}
|
|
212
|
+
} catch {
|
|
213
|
+
// Ignore errors, email is optional
|
|
214
|
+
}
|
|
215
|
+
return undefined;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Refresh Google Cloud Code Assist token
|
|
220
|
+
*/
|
|
221
|
+
export async function refreshGoogleCloudToken(refreshToken: string, projectId: string): Promise<OAuthCredentials> {
|
|
222
|
+
const response = await fetch(TOKEN_URL, {
|
|
223
|
+
method: "POST",
|
|
224
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
225
|
+
body: new URLSearchParams({
|
|
226
|
+
client_id: CLIENT_ID,
|
|
227
|
+
client_secret: CLIENT_SECRET,
|
|
228
|
+
refresh_token: refreshToken,
|
|
229
|
+
grant_type: "refresh_token",
|
|
230
|
+
}),
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
if (!response.ok) {
|
|
234
|
+
const error = await response.text();
|
|
235
|
+
throw new Error(`Google Cloud token refresh failed: ${error}`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const data = (await response.json()) as {
|
|
239
|
+
access_token: string;
|
|
240
|
+
expires_in: number;
|
|
241
|
+
refresh_token?: string;
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
refresh: data.refresh_token || refreshToken,
|
|
246
|
+
access: data.access_token,
|
|
247
|
+
expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000,
|
|
248
|
+
projectId,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Login with Gemini CLI (Google Cloud Code Assist) OAuth
|
|
254
|
+
*
|
|
255
|
+
* @param onAuth - Callback with URL and optional instructions
|
|
256
|
+
* @param onProgress - Optional progress callback
|
|
257
|
+
*/
|
|
258
|
+
export async function loginGeminiCli(
|
|
259
|
+
onAuth: (info: { url: string; instructions?: string }) => void,
|
|
260
|
+
onProgress?: (message: string) => void,
|
|
261
|
+
): Promise<OAuthCredentials> {
|
|
262
|
+
const { verifier, challenge } = await generatePKCE();
|
|
263
|
+
|
|
264
|
+
// Start local server for callback
|
|
265
|
+
onProgress?.("Starting local server for OAuth callback...");
|
|
266
|
+
const { server, getCode } = await startCallbackServer();
|
|
267
|
+
|
|
268
|
+
try {
|
|
269
|
+
// Build authorization URL
|
|
270
|
+
const authParams = new URLSearchParams({
|
|
271
|
+
client_id: CLIENT_ID,
|
|
272
|
+
response_type: "code",
|
|
273
|
+
redirect_uri: REDIRECT_URI,
|
|
274
|
+
scope: SCOPES.join(" "),
|
|
275
|
+
code_challenge: challenge,
|
|
276
|
+
code_challenge_method: "S256",
|
|
277
|
+
state: verifier,
|
|
278
|
+
access_type: "offline",
|
|
279
|
+
prompt: "consent",
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
const authUrl = `${AUTH_URL}?${authParams.toString()}`;
|
|
283
|
+
|
|
284
|
+
// Notify caller with URL to open
|
|
285
|
+
onAuth({
|
|
286
|
+
url: authUrl,
|
|
287
|
+
instructions: "Complete the sign-in in your browser. The callback will be captured automatically.",
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// Wait for the callback
|
|
291
|
+
onProgress?.("Waiting for OAuth callback...");
|
|
292
|
+
const { code, state } = await getCode();
|
|
293
|
+
|
|
294
|
+
// Verify state matches
|
|
295
|
+
if (state !== verifier) {
|
|
296
|
+
throw new Error("OAuth state mismatch - possible CSRF attack");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Exchange code for tokens
|
|
300
|
+
onProgress?.("Exchanging authorization code for tokens...");
|
|
301
|
+
const tokenResponse = await fetch(TOKEN_URL, {
|
|
302
|
+
method: "POST",
|
|
303
|
+
headers: {
|
|
304
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
305
|
+
},
|
|
306
|
+
body: new URLSearchParams({
|
|
307
|
+
client_id: CLIENT_ID,
|
|
308
|
+
client_secret: CLIENT_SECRET,
|
|
309
|
+
code,
|
|
310
|
+
grant_type: "authorization_code",
|
|
311
|
+
redirect_uri: REDIRECT_URI,
|
|
312
|
+
code_verifier: verifier,
|
|
313
|
+
}),
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
if (!tokenResponse.ok) {
|
|
317
|
+
const error = await tokenResponse.text();
|
|
318
|
+
throw new Error(`Token exchange failed: ${error}`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const tokenData = (await tokenResponse.json()) as {
|
|
322
|
+
access_token: string;
|
|
323
|
+
refresh_token: string;
|
|
324
|
+
expires_in: number;
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
if (!tokenData.refresh_token) {
|
|
328
|
+
throw new Error("No refresh token received. Please try again.");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Get user email
|
|
332
|
+
onProgress?.("Getting user info...");
|
|
333
|
+
const email = await getUserEmail(tokenData.access_token);
|
|
334
|
+
|
|
335
|
+
// Discover project
|
|
336
|
+
const projectId = await discoverProject(tokenData.access_token, onProgress);
|
|
337
|
+
|
|
338
|
+
// Calculate expiry time (current time + expires_in seconds - 5 min buffer)
|
|
339
|
+
const expiresAt = Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000;
|
|
340
|
+
|
|
341
|
+
const credentials: OAuthCredentials = {
|
|
342
|
+
refresh: tokenData.refresh_token,
|
|
343
|
+
access: tokenData.access_token,
|
|
344
|
+
expires: expiresAt,
|
|
345
|
+
projectId,
|
|
346
|
+
email,
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
return credentials;
|
|
350
|
+
} finally {
|
|
351
|
+
server.stop();
|
|
352
|
+
}
|
|
353
|
+
}
|