opencode-gemini-oauth 1.0.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 ADDED
@@ -0,0 +1,73 @@
1
+ # opencode-gemini-oauth
2
+
3
+ OpenCode plugin for Google Gemini authentication via OAuth. Use your Google AI Pro subscription quota without API billing.
4
+
5
+ ## Features
6
+
7
+ - OAuth PKCE authentication with Google
8
+ - Automatic token refresh
9
+ - Request interception and transformation for Antigravity/Code Assist API
10
+ - Token storage with secure local file
11
+
12
+ ## Installation
13
+
14
+ Add to your `opencode.json`:
15
+
16
+ ```json
17
+ {
18
+ "plugins": ["opencode-gemini-oauth@latest"]
19
+ }
20
+ ```
21
+
22
+ Or use the local path for development:
23
+
24
+ ```json
25
+ {
26
+ "plugins": ["D:\\git\\opencode-gemini-oauth"]
27
+ }
28
+ ```
29
+
30
+ ## Configuration
31
+
32
+ In your `opencode.json`, configure the Google provider:
33
+
34
+ ```json
35
+ {
36
+ "provider": {
37
+ "google": {
38
+ "npm": "opencode-gemini-oauth"
39
+ }
40
+ }
41
+ }
42
+ ```
43
+
44
+ ## Usage
45
+
46
+ 1. Start OpenCode
47
+ 2. Select "OAuth with Google (Gemini)" authentication method
48
+ 3. Complete the OAuth flow in your browser
49
+ 4. Your Google AI Pro quota will be used for requests
50
+
51
+ ## Supported Models
52
+
53
+ - `gemini-2.5-pro`
54
+ - `gemini-2.5-flash`
55
+ - `gemini-2.0-flash`
56
+ - And other Gemini models available through your subscription
57
+
58
+ ## How It Works
59
+
60
+ This plugin:
61
+ 1. Authenticates via Google OAuth using PKCE flow
62
+ 2. Stores refresh tokens locally in `%APPDATA%/opencode/gemini-oauth-accounts.json`
63
+ 3. Intercepts API requests to `generativelanguage.googleapis.com`
64
+ 4. Transforms them to use the Code Assist endpoint with your OAuth token
65
+ 5. Automatically refreshes expired tokens
66
+
67
+ ## Credits
68
+
69
+ Based on the Antigravity OAuth flow used by Google's AI Studio and Cloud Code extensions.
70
+
71
+ ## License
72
+
73
+ MIT
@@ -0,0 +1,5 @@
1
+ import { Plugin } from '@opencode-ai/plugin';
2
+
3
+ declare const GeminiOAuthPlugin: Plugin;
4
+
5
+ export { GeminiOAuthPlugin as default };
package/dist/index.js ADDED
@@ -0,0 +1,502 @@
1
+ // src/oauth.ts
2
+ import { createServer } from "http";
3
+ import { randomBytes, createHash } from "crypto";
4
+ import { URL as URL2, URLSearchParams as URLSearchParams2 } from "url";
5
+
6
+ // src/constants.ts
7
+ var OAUTH_CONFIG = {
8
+ clientId: "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com",
9
+ clientSecret: "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",
10
+ redirectUri: "http://localhost:36742/oauth-callback",
11
+ port: 36742,
12
+ scopes: [
13
+ "https://www.googleapis.com/auth/cloud-platform",
14
+ "https://www.googleapis.com/auth/userinfo.email",
15
+ "https://www.googleapis.com/auth/userinfo.profile",
16
+ "https://www.googleapis.com/auth/cclog",
17
+ "https://www.googleapis.com/auth/experimentsandconfigs"
18
+ ],
19
+ authUrl: "https://accounts.google.com/o/oauth2/v2/auth",
20
+ tokenUrl: "https://oauth2.googleapis.com/token"
21
+ };
22
+ var CODE_ASSIST_ENDPOINTS = [
23
+ "https://autopush-cloudcode-pa.sandbox.googleapis.com",
24
+ "https://cloudcode-pa.googleapis.com"
25
+ ];
26
+ var CODE_ASSIST_HEADERS = {
27
+ "User-Agent": "antigravity/1.11.5",
28
+ "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1"
29
+ };
30
+ var STORAGE_FILE = "gemini-oauth-accounts.json";
31
+ var TOKEN_REFRESH_BUFFER_MS = 60 * 1e3;
32
+
33
+ // src/storage.ts
34
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
35
+ import { join } from "path";
36
+ import { homedir } from "os";
37
+ function getStoragePath() {
38
+ const configDir = process.env.APPDATA || (process.platform === "darwin" ? join(homedir(), "Library", "Application Support") : join(homedir(), ".config"));
39
+ const opencodeDir = join(configDir, "opencode");
40
+ if (!existsSync(opencodeDir)) {
41
+ mkdirSync(opencodeDir, { recursive: true });
42
+ }
43
+ return join(opencodeDir, STORAGE_FILE);
44
+ }
45
+ function loadStorage() {
46
+ const path = getStoragePath();
47
+ if (!existsSync(path)) {
48
+ return { version: 1, accounts: [], activeIndex: 0 };
49
+ }
50
+ try {
51
+ const data = JSON.parse(readFileSync(path, "utf-8"));
52
+ return {
53
+ version: 1,
54
+ accounts: data.accounts || [],
55
+ activeIndex: data.activeIndex || 0
56
+ };
57
+ } catch {
58
+ return { version: 1, accounts: [], activeIndex: 0 };
59
+ }
60
+ }
61
+ function saveStorage(data) {
62
+ const path = getStoragePath();
63
+ writeFileSync(path, JSON.stringify(data, null, 2), "utf-8");
64
+ }
65
+ function getActiveAccount() {
66
+ const storage = loadStorage();
67
+ if (storage.accounts.length === 0) {
68
+ return null;
69
+ }
70
+ return storage.accounts[storage.activeIndex] || storage.accounts[0] || null;
71
+ }
72
+ function addOrUpdateAccount(account) {
73
+ const storage = loadStorage();
74
+ const existingIndex = storage.accounts.findIndex(
75
+ (a) => a.email === account.email
76
+ );
77
+ if (existingIndex >= 0) {
78
+ storage.accounts[existingIndex] = account;
79
+ storage.activeIndex = existingIndex;
80
+ } else {
81
+ storage.accounts.push(account);
82
+ storage.activeIndex = storage.accounts.length - 1;
83
+ }
84
+ saveStorage(storage);
85
+ }
86
+
87
+ // src/oauth.ts
88
+ function generatePKCE() {
89
+ const verifier = randomBytes(32).toString("base64url").replace(/[^a-zA-Z0-9]/g, "").substring(0, 43);
90
+ const challenge = createHash("sha256").update(verifier).digest("base64url").replace(/[^a-zA-Z0-9\-_]/g, "");
91
+ return { verifier, challenge };
92
+ }
93
+ function generateState() {
94
+ return randomBytes(16).toString("hex");
95
+ }
96
+ function buildAuthUrl(state, codeChallenge) {
97
+ const params = new URLSearchParams2({
98
+ client_id: OAUTH_CONFIG.clientId,
99
+ redirect_uri: OAUTH_CONFIG.redirectUri,
100
+ response_type: "code",
101
+ scope: OAUTH_CONFIG.scopes.join(" "),
102
+ state,
103
+ code_challenge: codeChallenge,
104
+ code_challenge_method: "S256",
105
+ access_type: "offline",
106
+ prompt: "consent"
107
+ });
108
+ return `${OAUTH_CONFIG.authUrl}?${params.toString()}`;
109
+ }
110
+ async function exchangeCodeForTokens(code, codeVerifier) {
111
+ const response = await fetch(OAUTH_CONFIG.tokenUrl, {
112
+ method: "POST",
113
+ headers: {
114
+ "Content-Type": "application/x-www-form-urlencoded"
115
+ },
116
+ body: new URLSearchParams2({
117
+ client_id: OAUTH_CONFIG.clientId,
118
+ client_secret: OAUTH_CONFIG.clientSecret,
119
+ code,
120
+ code_verifier: codeVerifier,
121
+ grant_type: "authorization_code",
122
+ redirect_uri: OAUTH_CONFIG.redirectUri
123
+ }).toString()
124
+ });
125
+ if (!response.ok) {
126
+ const error = await response.text();
127
+ throw new Error(`Token exchange failed: ${error}`);
128
+ }
129
+ const data = await response.json();
130
+ return {
131
+ accessToken: data.access_token,
132
+ refreshToken: data.refresh_token,
133
+ expiresIn: data.expires_in
134
+ };
135
+ }
136
+ async function getUserInfo(accessToken) {
137
+ const response = await fetch(
138
+ "https://www.googleapis.com/oauth2/v2/userinfo",
139
+ {
140
+ headers: {
141
+ Authorization: `Bearer ${accessToken}`
142
+ }
143
+ }
144
+ );
145
+ if (!response.ok) {
146
+ throw new Error("Failed to get user info");
147
+ }
148
+ const data = await response.json();
149
+ return { email: data.email, name: data.name };
150
+ }
151
+ async function getProjectId(accessToken) {
152
+ try {
153
+ const response = await fetch(
154
+ "https://cloudcode-pa.googleapis.com/v1/userWorkspace",
155
+ {
156
+ headers: {
157
+ Authorization: `Bearer ${accessToken}`,
158
+ "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1"
159
+ }
160
+ }
161
+ );
162
+ if (response.ok) {
163
+ const data = await response.json();
164
+ return data.managedProjectId;
165
+ }
166
+ } catch {
167
+ }
168
+ return void 0;
169
+ }
170
+ async function startOAuthFlow() {
171
+ const { verifier, challenge } = generatePKCE();
172
+ const state = generateState();
173
+ const authUrl = buildAuthUrl(state, challenge);
174
+ return new Promise((resolveSetup, rejectSetup) => {
175
+ let callbackResolve;
176
+ let callbackReject;
177
+ const callbackPromise = new Promise((resolve, reject) => {
178
+ callbackResolve = resolve;
179
+ callbackReject = reject;
180
+ });
181
+ const server = createServer(async (req, res) => {
182
+ try {
183
+ const url = new URL2(req.url || "/", `http://localhost:${OAUTH_CONFIG.port}`);
184
+ if (url.pathname === "/oauth-callback") {
185
+ const code = url.searchParams.get("code");
186
+ const returnedState = url.searchParams.get("state");
187
+ const error = url.searchParams.get("error");
188
+ if (error) {
189
+ res.writeHead(400, { "Content-Type": "text/html" });
190
+ res.end(`
191
+ <html>
192
+ <body style="font-family: system-ui; padding: 40px; text-align: center;">
193
+ <h1 style="color: #dc2626;">Authentication Failed</h1>
194
+ <p>Error: ${error}</p>
195
+ <p>You can close this window.</p>
196
+ </body>
197
+ </html>
198
+ `);
199
+ callbackReject(new Error(`OAuth error: ${error}`));
200
+ return;
201
+ }
202
+ if (!code || returnedState !== state) {
203
+ res.writeHead(400, { "Content-Type": "text/html" });
204
+ res.end(`
205
+ <html>
206
+ <body style="font-family: system-ui; padding: 40px; text-align: center;">
207
+ <h1 style="color: #dc2626;">Invalid Response</h1>
208
+ <p>Missing code or invalid state.</p>
209
+ <p>You can close this window.</p>
210
+ </body>
211
+ </html>
212
+ `);
213
+ callbackReject(new Error("Invalid OAuth response"));
214
+ return;
215
+ }
216
+ const tokens = await exchangeCodeForTokens(code, verifier);
217
+ const userInfo = await getUserInfo(tokens.accessToken);
218
+ const projectId = await getProjectId(tokens.accessToken);
219
+ const account = {
220
+ email: userInfo.email,
221
+ refreshToken: tokens.refreshToken,
222
+ accessToken: tokens.accessToken,
223
+ expiresAt: Date.now() + tokens.expiresIn * 1e3,
224
+ projectId,
225
+ managedProjectId: projectId
226
+ };
227
+ addOrUpdateAccount(account);
228
+ res.writeHead(200, { "Content-Type": "text/html" });
229
+ res.end(`
230
+ <html>
231
+ <body style="font-family: system-ui; padding: 40px; text-align: center;">
232
+ <h1 style="color: #16a34a;">Authentication Successful!</h1>
233
+ <p>Logged in as: <strong>${userInfo.email}</strong></p>
234
+ <p>You can close this window and return to OpenCode.</p>
235
+ <script>setTimeout(() => window.close(), 2000);</script>
236
+ </body>
237
+ </html>
238
+ `);
239
+ callbackResolve(account);
240
+ } else {
241
+ res.writeHead(404);
242
+ res.end("Not found");
243
+ }
244
+ } catch (err) {
245
+ res.writeHead(500, { "Content-Type": "text/html" });
246
+ res.end(`
247
+ <html>
248
+ <body style="font-family: system-ui; padding: 40px; text-align: center;">
249
+ <h1 style="color: #dc2626;">Error</h1>
250
+ <p>${err instanceof Error ? err.message : "Unknown error"}</p>
251
+ <p>You can close this window.</p>
252
+ </body>
253
+ </html>
254
+ `);
255
+ callbackReject(err instanceof Error ? err : new Error("Unknown error"));
256
+ }
257
+ });
258
+ server.listen(OAUTH_CONFIG.port, async () => {
259
+ try {
260
+ const open = (await import("open")).default;
261
+ await open(authUrl);
262
+ } catch {
263
+ }
264
+ resolveSetup({
265
+ authUrl,
266
+ server,
267
+ waitForCallback: () => callbackPromise
268
+ });
269
+ });
270
+ server.on("error", (err) => {
271
+ rejectSetup(new Error(`Failed to start OAuth server: ${err.message}`));
272
+ });
273
+ setTimeout(() => {
274
+ server.close();
275
+ callbackReject(new Error("OAuth flow timed out"));
276
+ }, 5 * 60 * 1e3);
277
+ });
278
+ }
279
+
280
+ // src/token.ts
281
+ function isTokenExpired(account) {
282
+ if (!account.accessToken || !account.accessTokenExpiry) {
283
+ return true;
284
+ }
285
+ return Date.now() >= account.accessTokenExpiry - TOKEN_REFRESH_BUFFER_MS;
286
+ }
287
+ async function refreshAccessToken(account) {
288
+ const response = await fetch(OAUTH_CONFIG.tokenUrl, {
289
+ method: "POST",
290
+ headers: {
291
+ "Content-Type": "application/x-www-form-urlencoded"
292
+ },
293
+ body: new URLSearchParams({
294
+ client_id: OAUTH_CONFIG.clientId,
295
+ client_secret: OAUTH_CONFIG.clientSecret,
296
+ refresh_token: account.refreshToken,
297
+ grant_type: "refresh_token"
298
+ }).toString()
299
+ });
300
+ if (!response.ok) {
301
+ const error = await response.text();
302
+ throw new Error(`Token refresh failed: ${error}`);
303
+ }
304
+ const data = await response.json();
305
+ const expiresAt = Date.now() + data.expires_in * 1e3;
306
+ const updatedAccount = {
307
+ ...account,
308
+ accessToken: data.access_token,
309
+ accessTokenExpiry: expiresAt
310
+ };
311
+ addOrUpdateAccount(updatedAccount);
312
+ return {
313
+ accessToken: data.access_token,
314
+ expiresAt
315
+ };
316
+ }
317
+ async function getValidAccessToken() {
318
+ const account = getActiveAccount();
319
+ if (!account) {
320
+ throw new Error("No authenticated account. Please run OAuth login first.");
321
+ }
322
+ if (isTokenExpired(account)) {
323
+ const { accessToken } = await refreshAccessToken(account);
324
+ return accessToken;
325
+ }
326
+ return account.accessToken;
327
+ }
328
+
329
+ // src/fetch-wrapper.ts
330
+ var MODEL_ALIASES = {
331
+ "gemini-3-pro-preview": "gemini-3-pro-high",
332
+ "gemini-3-flash-preview": "gemini-3-flash",
333
+ "gemini-2.5-pro": "gemini-2.5-pro-exp-03-25",
334
+ "gemini-2.5-flash": "gemini-2.5-flash",
335
+ "gemini-2.5-flash-lite": "gemini-2.5-flash-lite-001",
336
+ // Claude models via Antigravity
337
+ "gemini-claude-sonnet-4-5": "claude-sonnet-4-5",
338
+ "gemini-claude-sonnet-4-5-thinking": "claude-sonnet-4-5-thinking",
339
+ "gemini-claude-opus-4-5": "claude-opus-4-5",
340
+ "gemini-claude-opus-4-5-thinking": "claude-opus-4-5-thinking"
341
+ };
342
+ function extractAction(url) {
343
+ const match = url.match(/:(\w+)(?:\?|$)/);
344
+ return match ? match[1] : null;
345
+ }
346
+ function transformRequestBody(body) {
347
+ try {
348
+ const parsed = JSON.parse(body);
349
+ if (parsed.model) {
350
+ const modelName = parsed.model.replace(/^models\//, "");
351
+ parsed.model = `models/${MODEL_ALIASES[modelName] || modelName}`;
352
+ }
353
+ return JSON.stringify(parsed);
354
+ } catch {
355
+ return body;
356
+ }
357
+ }
358
+ function createAntigravityFetch() {
359
+ let currentEndpointIndex = 0;
360
+ const antigravityFetch = async (input, init) => {
361
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
362
+ if (!url.includes("generativelanguage.googleapis.com")) {
363
+ return fetch(input, init);
364
+ }
365
+ const action = extractAction(url);
366
+ if (!action) {
367
+ return fetch(input, init);
368
+ }
369
+ let accessToken;
370
+ try {
371
+ accessToken = await getValidAccessToken();
372
+ } catch (error) {
373
+ throw new Error(
374
+ `Failed to get access token: ${error instanceof Error ? error.message : "Unknown error"}`
375
+ );
376
+ }
377
+ let body = init?.body;
378
+ if (typeof body === "string") {
379
+ body = transformRequestBody(body);
380
+ }
381
+ let lastError = null;
382
+ for (let i = 0; i < CODE_ASSIST_ENDPOINTS.length; i++) {
383
+ const endpointIndex = (currentEndpointIndex + i) % CODE_ASSIST_ENDPOINTS.length;
384
+ const endpoint = CODE_ASSIST_ENDPOINTS[endpointIndex];
385
+ const targetUrl = `${endpoint}/v1internal:${action}`;
386
+ try {
387
+ const response = await fetch(targetUrl, {
388
+ method: init?.method || "POST",
389
+ headers: {
390
+ ...CODE_ASSIST_HEADERS,
391
+ "Content-Type": "application/json",
392
+ Authorization: `Bearer ${accessToken}`,
393
+ ...init?.headers
394
+ },
395
+ body,
396
+ signal: init?.signal
397
+ });
398
+ if (response.ok || response.status >= 400 && response.status < 500) {
399
+ currentEndpointIndex = endpointIndex;
400
+ return response;
401
+ }
402
+ if (response.status >= 500) {
403
+ lastError = new Error(`Server error: ${response.status}`);
404
+ continue;
405
+ }
406
+ return response;
407
+ } catch (error) {
408
+ lastError = error instanceof Error ? error : new Error(String(error));
409
+ continue;
410
+ }
411
+ }
412
+ throw lastError || new Error("All Code Assist endpoints failed");
413
+ };
414
+ return antigravityFetch;
415
+ }
416
+
417
+ // src/index.ts
418
+ var GeminiOAuthPlugin = async (_input) => {
419
+ const authHook = {
420
+ provider: "google",
421
+ loader: async (_auth, _provider) => {
422
+ const account = getActiveAccount();
423
+ if (!account) {
424
+ return {
425
+ apiKey: ""
426
+ };
427
+ }
428
+ return {
429
+ apiKey: "",
430
+ fetch: createAntigravityFetch()
431
+ };
432
+ },
433
+ methods: [
434
+ {
435
+ type: "oauth",
436
+ label: "OAuth with Google (Gemini Pro)",
437
+ authorize: async () => {
438
+ const { authUrl, waitForCallback, server } = await startOAuthFlow();
439
+ return {
440
+ url: authUrl,
441
+ instructions: "Opening browser for Google authentication. Please sign in with your Google AI Pro account.",
442
+ method: "auto",
443
+ callback: async () => {
444
+ try {
445
+ const account = await waitForCallback();
446
+ return {
447
+ type: "success",
448
+ refresh: account.refreshToken,
449
+ access: account.accessToken || "",
450
+ expires: account.expiresAt || Date.now() + 3600 * 1e3
451
+ };
452
+ } catch (error) {
453
+ console.error("OAuth failed:", error);
454
+ return { type: "failed" };
455
+ } finally {
456
+ server.close();
457
+ }
458
+ }
459
+ };
460
+ }
461
+ },
462
+ {
463
+ type: "api",
464
+ label: "Manually enter API Key",
465
+ prompts: [
466
+ {
467
+ type: "text",
468
+ key: "apiKey",
469
+ message: "Enter your Google API Key:",
470
+ placeholder: "AIza...",
471
+ validate: (value) => {
472
+ if (!value || value.trim().length === 0) {
473
+ return "API Key is required";
474
+ }
475
+ if (!value.startsWith("AIza")) {
476
+ return "Invalid Google API Key format";
477
+ }
478
+ return void 0;
479
+ }
480
+ }
481
+ ],
482
+ authorize: async (inputs) => {
483
+ const apiKey = inputs?.apiKey;
484
+ if (!apiKey) {
485
+ return { type: "failed" };
486
+ }
487
+ return {
488
+ type: "success",
489
+ key: apiKey
490
+ };
491
+ }
492
+ }
493
+ ]
494
+ };
495
+ return {
496
+ auth: authHook
497
+ };
498
+ };
499
+ var index_default = GeminiOAuthPlugin;
500
+ export {
501
+ index_default as default
502
+ };
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "opencode-gemini-oauth",
3
+ "version": "1.0.0",
4
+ "description": "OpenCode plugin for Google Gemini OAuth authentication (Antigravity)",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "type": "module",
8
+ "scripts": {
9
+ "build": "tsup src/index.ts --format esm --dts --clean",
10
+ "dev": "tsup src/index.ts --format esm --dts --watch",
11
+ "prepublishOnly": "npm run build"
12
+ },
13
+ "keywords": [
14
+ "opencode",
15
+ "opencode-plugin",
16
+ "gemini",
17
+ "google",
18
+ "oauth",
19
+ "antigravity"
20
+ ],
21
+ "author": "Marquinho",
22
+ "license": "MIT",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "https://github.com/Marquinho/opencode-gemini-oauth"
26
+ },
27
+ "dependencies": {
28
+ "open": "^10.1.0"
29
+ },
30
+ "devDependencies": {
31
+ "@opencode-ai/plugin": "^1.0.182",
32
+ "@types/node": "^22.10.2",
33
+ "tsup": "^8.3.5",
34
+ "typescript": "^5.7.2"
35
+ },
36
+ "peerDependencies": {
37
+ "@opencode-ai/plugin": "^1.0.0"
38
+ },
39
+ "engines": {
40
+ "node": ">=18.0.0"
41
+ },
42
+ "files": [
43
+ "dist"
44
+ ]
45
+ }