opencode-gemini-auth 1.3.9 → 1.3.11

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/src/plugin.ts CHANGED
@@ -1,31 +1,23 @@
1
- import { spawn } from "node:child_process";
2
-
3
- import { GEMINI_PROVIDER_ID, GEMINI_REDIRECT_URI } from "./constants";
4
- import {
5
- authorizeGemini,
6
- exchangeGeminiWithVerifier,
7
- } from "./gemini/oauth";
8
- import type { GeminiTokenExchangeResult } from "./gemini/oauth";
1
+ import { GEMINI_PROVIDER_ID } from "./constants";
2
+ import { createOAuthAuthorizeMethod } from "./plugin/oauth-authorize";
9
3
  import { accessTokenExpired, isOAuthAuth } from "./plugin/auth";
10
- import {
11
- ensureProjectContext,
12
- resolveProjectContextFromAccessToken,
13
- } from "./plugin/project";
4
+ import { resolveCachedAuth } from "./plugin/cache";
5
+ import { ensureProjectContext, retrieveUserQuota } from "./plugin/project";
14
6
  import { isGeminiDebugEnabled, logGeminiDebugMessage, startGeminiDebugRequest } from "./plugin/debug";
15
7
  import {
16
8
  isGenerativeLanguageRequest,
17
9
  prepareGeminiRequest,
18
10
  transformGeminiResponse,
19
11
  } from "./plugin/request";
12
+ import { fetchWithRetry } from "./plugin/retry";
20
13
  import { refreshAccessToken } from "./plugin/token";
21
- import { startOAuthListener, type OAuthListener } from "./plugin/server";
22
14
  import type {
23
15
  GetAuth,
24
16
  LoaderResult,
25
17
  OAuthAuthDetails,
18
+ PluginClient,
26
19
  PluginContext,
27
20
  PluginResult,
28
- ProjectContextResult,
29
21
  Provider,
30
22
  } from "./plugin/types";
31
23
 
@@ -44,29 +36,8 @@ export const GeminiCLIOAuthPlugin = async (
44
36
  return null;
45
37
  }
46
38
 
47
- const providerOptions =
48
- provider && typeof provider === "object"
49
- ? ((provider as { options?: Record<string, unknown> }).options ?? undefined)
50
- : undefined;
51
- const projectIdFromConfig =
52
- providerOptions && typeof providerOptions.projectId === "string"
53
- ? providerOptions.projectId.trim()
54
- : "";
55
- const projectIdFromEnv = process.env.OPENCODE_GEMINI_PROJECT_ID?.trim() ?? "";
56
- const googleProjectIdFromEnv =
57
- process.env.GOOGLE_CLOUD_PROJECT?.trim() ??
58
- process.env.GOOGLE_CLOUD_PROJECT_ID?.trim() ??
59
- "";
60
- const configuredProjectId =
61
- projectIdFromEnv || projectIdFromConfig || googleProjectIdFromEnv || undefined;
62
-
63
- if (provider.models) {
64
- for (const model of Object.values(provider.models)) {
65
- if (model) {
66
- model.cost = { input: 0, output: 0 };
67
- }
68
- }
69
- }
39
+ const configuredProjectId = resolveConfiguredProjectId(provider);
40
+ normalizeProviderModelCosts(provider);
70
41
 
71
42
  return {
72
43
  apiKey: "",
@@ -80,7 +51,7 @@ export const GeminiCLIOAuthPlugin = async (
80
51
  return fetch(input, init);
81
52
  }
82
53
 
83
- let authRecord = latestAuth;
54
+ let authRecord = resolveCachedAuth(latestAuth);
84
55
  if (accessTokenExpired(authRecord)) {
85
56
  const refreshed = await refreshAccessToken(authRecord, client);
86
57
  if (!refreshed) {
@@ -89,53 +60,46 @@ export const GeminiCLIOAuthPlugin = async (
89
60
  authRecord = refreshed;
90
61
  }
91
62
 
92
- const accessToken = authRecord.access;
93
- if (!accessToken) {
63
+ if (!authRecord.access) {
94
64
  return fetch(input, init);
95
65
  }
96
66
 
97
- /**
98
- * Ensures we have a usable project context for the current auth snapshot.
99
- */
100
- async function resolveProjectContext(): Promise<ProjectContextResult> {
101
- try {
102
- return await ensureProjectContext(authRecord, client, configuredProjectId);
103
- } catch (error) {
104
- if (error instanceof Error) {
105
- console.error(error.message);
106
- }
107
- throw error;
108
- }
109
- }
110
-
111
- const projectContext = await resolveProjectContext();
112
-
113
- const {
114
- request,
115
- init: transformedInit,
116
- streaming,
117
- requestedModel,
118
- } = prepareGeminiRequest(
67
+ const projectContext = await ensureProjectContextOrThrow(
68
+ authRecord,
69
+ client,
70
+ configuredProjectId,
71
+ );
72
+ await maybeLogAvailableQuotaModels(
73
+ authRecord.access,
74
+ projectContext.effectiveProjectId,
75
+ );
76
+ const transformed = prepareGeminiRequest(
119
77
  input,
120
78
  init,
121
- accessToken,
79
+ authRecord.access,
122
80
  projectContext.effectiveProjectId,
123
81
  );
124
-
125
- const originalUrl = toUrlString(input);
126
- const resolvedUrl = toUrlString(request);
127
82
  const debugContext = startGeminiDebugRequest({
128
- originalUrl,
129
- resolvedUrl,
130
- method: transformedInit.method,
131
- headers: transformedInit.headers,
132
- body: transformedInit.body,
133
- streaming,
83
+ originalUrl: toUrlString(input),
84
+ resolvedUrl: toUrlString(transformed.request),
85
+ method: transformed.init.method,
86
+ headers: transformed.init.headers,
87
+ body: transformed.init.body,
88
+ streaming: transformed.streaming,
134
89
  projectId: projectContext.effectiveProjectId,
135
90
  });
136
91
 
137
- const response = await fetchWithRetry(request, transformedInit);
138
- return transformGeminiResponse(response, streaming, debugContext, requestedModel);
92
+ /**
93
+ * Retry transport/429 failures while preserving the requested model.
94
+ * We intentionally do not auto-downgrade model tiers to avoid misleading users.
95
+ */
96
+ const response = await fetchWithRetry(transformed.request, transformed.init);
97
+ return transformGeminiResponse(
98
+ response,
99
+ transformed.streaming,
100
+ debugContext,
101
+ transformed.requestedModel,
102
+ );
139
103
  },
140
104
  };
141
105
  },
@@ -143,170 +107,7 @@ export const GeminiCLIOAuthPlugin = async (
143
107
  {
144
108
  label: "OAuth with Google (Gemini CLI)",
145
109
  type: "oauth",
146
- authorize: async () => {
147
- const maybeHydrateProjectId = async (
148
- result: GeminiTokenExchangeResult,
149
- ): Promise<GeminiTokenExchangeResult> => {
150
- if (result.type !== "success") {
151
- return result;
152
- }
153
-
154
- const accessToken = result.access;
155
- if (!accessToken) {
156
- return result;
157
- }
158
-
159
- const projectFromEnv = process.env.OPENCODE_GEMINI_PROJECT_ID?.trim() ?? "";
160
- const googleProjectFromEnv =
161
- process.env.GOOGLE_CLOUD_PROJECT?.trim() ??
162
- process.env.GOOGLE_CLOUD_PROJECT_ID?.trim() ??
163
- "";
164
- const configuredProjectId =
165
- projectFromEnv || googleProjectFromEnv || undefined;
166
-
167
- try {
168
- const authSnapshot = {
169
- type: "oauth",
170
- refresh: result.refresh,
171
- access: result.access,
172
- expires: result.expires,
173
- } satisfies OAuthAuthDetails;
174
- const projectContext = await resolveProjectContextFromAccessToken(
175
- authSnapshot,
176
- accessToken,
177
- configuredProjectId,
178
- );
179
-
180
- if (projectContext.auth.refresh !== result.refresh) {
181
- if (isGeminiDebugEnabled()) {
182
- logGeminiDebugMessage(
183
- `OAuth project resolved during auth: ${projectContext.effectiveProjectId || "none"}`,
184
- );
185
- }
186
- return { ...result, refresh: projectContext.auth.refresh };
187
- }
188
- } catch (error) {
189
- if (isGeminiDebugEnabled()) {
190
- const message = error instanceof Error ? error.message : String(error);
191
- console.warn(`[Gemini OAuth] Project resolution skipped: ${message}`);
192
- }
193
- }
194
-
195
- return result;
196
- };
197
-
198
- const isHeadless = !!(
199
- process.env.SSH_CONNECTION ||
200
- process.env.SSH_CLIENT ||
201
- process.env.SSH_TTY ||
202
- process.env.OPENCODE_HEADLESS
203
- );
204
-
205
- let listener: OAuthListener | null = null;
206
- if (!isHeadless) {
207
- try {
208
- listener = await startOAuthListener();
209
- } catch (error) {
210
- if (error instanceof Error) {
211
- console.log(
212
- `Warning: Couldn't start the local callback listener (${error.message}). You'll need to paste the callback URL or authorization code.`,
213
- );
214
- } else {
215
- console.log(
216
- "Warning: Couldn't start the local callback listener. You'll need to paste the callback URL or authorization code.",
217
- );
218
- }
219
- }
220
- } else {
221
- console.log(
222
- "Headless environment detected. You'll need to paste the callback URL or authorization code.",
223
- );
224
- }
225
-
226
- const authorization = await authorizeGemini();
227
- if (!isHeadless) {
228
- openBrowserUrl(authorization.url);
229
- }
230
-
231
- if (listener) {
232
- return {
233
- url: authorization.url,
234
- instructions:
235
- "Complete the sign-in flow in your browser. We'll automatically detect the redirect back to localhost.",
236
- method: "auto",
237
- callback: async (): Promise<GeminiTokenExchangeResult> => {
238
- try {
239
- const callbackUrl = await listener.waitForCallback();
240
- const code = callbackUrl.searchParams.get("code");
241
- const state = callbackUrl.searchParams.get("state");
242
-
243
- if (!code || !state) {
244
- return {
245
- type: "failed",
246
- error: "Missing code or state in callback URL",
247
- };
248
- }
249
-
250
- if (state !== authorization.state) {
251
- return {
252
- type: "failed",
253
- error: "State mismatch in callback URL (possible CSRF attempt)",
254
- };
255
- }
256
-
257
- return await maybeHydrateProjectId(
258
- await exchangeGeminiWithVerifier(code, authorization.verifier),
259
- );
260
- } catch (error) {
261
- return {
262
- type: "failed",
263
- error: error instanceof Error ? error.message : "Unknown error",
264
- };
265
- } finally {
266
- try {
267
- await listener?.close();
268
- } catch {
269
- }
270
- }
271
- },
272
- };
273
- }
274
-
275
- return {
276
- url: authorization.url,
277
- instructions:
278
- "Complete OAuth in your browser, then paste the full redirected URL (e.g., http://localhost:8085/oauth2callback?code=...&state=...) or just the authorization code.",
279
- method: "code",
280
- callback: async (callbackUrl: string): Promise<GeminiTokenExchangeResult> => {
281
- try {
282
- const { code, state } = parseOAuthCallbackInput(callbackUrl);
283
-
284
- if (!code) {
285
- return {
286
- type: "failed",
287
- error: "Missing authorization code in callback input",
288
- };
289
- }
290
-
291
- if (state && state !== authorization.state) {
292
- return {
293
- type: "failed",
294
- error: "State mismatch in callback input (possible CSRF attempt)",
295
- };
296
- }
297
-
298
- return await maybeHydrateProjectId(
299
- await exchangeGeminiWithVerifier(code, authorization.verifier),
300
- );
301
- } catch (error) {
302
- return {
303
- type: "failed",
304
- error: error instanceof Error ? error.message : "Unknown error",
305
- };
306
- }
307
- },
308
- };
309
- },
110
+ authorize: createOAuthAuthorizeMethod(),
310
111
  },
311
112
  {
312
113
  provider: GEMINI_PROVIDER_ID,
@@ -318,266 +119,95 @@ export const GeminiCLIOAuthPlugin = async (
318
119
  });
319
120
 
320
121
  export const GoogleOAuthPlugin = GeminiCLIOAuthPlugin;
321
-
322
- const RETRYABLE_STATUS_CODES = new Set([429, 503]);
323
- const DEFAULT_MAX_RETRIES = 2;
324
- const DEFAULT_BASE_DELAY_MS = 800;
325
- const DEFAULT_MAX_DELAY_MS = 8000;
326
-
327
- function toUrlString(value: RequestInfo): string {
328
- if (typeof value === "string") {
329
- return value;
330
- }
331
- const candidate = (value as Request).url;
332
- if (candidate) {
333
- return candidate;
334
- }
335
- return value.toString();
122
+ const loggedQuotaModelsByProject = new Set<string>();
123
+
124
+ function resolveConfiguredProjectId(provider: Provider): string | undefined {
125
+ const providerOptions =
126
+ provider && typeof provider === "object"
127
+ ? ((provider as { options?: Record<string, unknown> }).options ?? undefined)
128
+ : undefined;
129
+ const projectIdFromConfig =
130
+ providerOptions && typeof providerOptions.projectId === "string"
131
+ ? providerOptions.projectId.trim()
132
+ : "";
133
+ const projectIdFromEnv = process.env.OPENCODE_GEMINI_PROJECT_ID?.trim() ?? "";
134
+ const googleProjectIdFromEnv =
135
+ process.env.GOOGLE_CLOUD_PROJECT?.trim() ??
136
+ process.env.GOOGLE_CLOUD_PROJECT_ID?.trim() ??
137
+ "";
138
+
139
+ return projectIdFromEnv || projectIdFromConfig || googleProjectIdFromEnv || undefined;
336
140
  }
337
141
 
338
- function parseOAuthCallbackInput(input: string): { code?: string; state?: string } {
339
- const trimmed = input.trim();
340
- if (!trimmed) {
341
- return {};
342
- }
343
-
344
- if (/^https?:\/\//i.test(trimmed)) {
345
- try {
346
- const url = new URL(trimmed);
347
- return {
348
- code: url.searchParams.get("code") || undefined,
349
- state: url.searchParams.get("state") || undefined,
350
- };
351
- } catch {
352
- return {};
353
- }
142
+ function normalizeProviderModelCosts(provider: Provider): void {
143
+ if (!provider.models) {
144
+ return;
354
145
  }
355
-
356
- const candidate = trimmed.startsWith("?") ? trimmed.slice(1) : trimmed;
357
- if (candidate.includes("=")) {
358
- const params = new URLSearchParams(candidate);
359
- const code = params.get("code") || undefined;
360
- const state = params.get("state") || undefined;
361
- if (code || state) {
362
- return { code, state };
146
+ for (const model of Object.values(provider.models)) {
147
+ if (model) {
148
+ model.cost = { input: 0, output: 0 };
363
149
  }
364
150
  }
365
-
366
- return { code: trimmed };
367
151
  }
368
152
 
369
- function openBrowserUrl(url: string): void {
153
+ async function ensureProjectContextOrThrow(
154
+ authRecord: OAuthAuthDetails,
155
+ client: PluginClient,
156
+ configuredProjectId?: string,
157
+ ) {
370
158
  try {
371
- // Best-effort: don't block auth flow if spawning fails.
372
- const platform = process.platform;
373
- const command =
374
- platform === "darwin"
375
- ? "open"
376
- : platform === "win32"
377
- ? "rundll32"
378
- : "xdg-open";
379
- const args =
380
- platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url];
381
- const child = spawn(command, args, {
382
- stdio: "ignore",
383
- detached: true,
384
- });
385
- child.unref?.();
386
- } catch {
387
- }
388
- }
389
-
390
- async function fetchWithRetry(input: RequestInfo, init: RequestInit | undefined): Promise<Response> {
391
- const maxRetries = DEFAULT_MAX_RETRIES;
392
- const baseDelayMs = DEFAULT_BASE_DELAY_MS;
393
- const maxDelayMs = DEFAULT_MAX_DELAY_MS;
394
-
395
- if (!canRetryRequest(init)) {
396
- return fetch(input, init);
397
- }
398
-
399
- let attempt = 0;
400
- while (true) {
401
- const response = await fetch(input, init);
402
- if (!RETRYABLE_STATUS_CODES.has(response.status) || attempt >= maxRetries) {
403
- return response;
404
- }
405
-
406
- const delayMs = await getRetryDelayMs(response, attempt, baseDelayMs, maxDelayMs);
407
- if (!delayMs || delayMs <= 0) {
408
- return response;
159
+ return await ensureProjectContext(authRecord, client, configuredProjectId);
160
+ } catch (error) {
161
+ if (error instanceof Error) {
162
+ console.error(error.message);
409
163
  }
410
-
411
- if (init?.signal?.aborted) {
412
- return response;
413
- }
414
-
415
- await wait(delayMs);
416
- attempt += 1;
417
- }
418
- }
419
-
420
- function canRetryRequest(init: RequestInit | undefined): boolean {
421
- if (!init?.body) {
422
- return true;
423
- }
424
-
425
- const body = init.body;
426
- if (typeof body === "string") {
427
- return true;
428
- }
429
- if (typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams) {
430
- return true;
431
- }
432
- if (typeof ArrayBuffer !== "undefined" && body instanceof ArrayBuffer) {
433
- return true;
434
- }
435
- if (typeof ArrayBuffer !== "undefined" && ArrayBuffer.isView(body)) {
436
- return true;
437
- }
438
- if (typeof Blob !== "undefined" && body instanceof Blob) {
439
- return true;
440
- }
441
-
442
- return false;
443
- }
444
-
445
- async function getRetryDelayMs(
446
- response: Response,
447
- attempt: number,
448
- baseDelayMs: number,
449
- maxDelayMs: number,
450
- ): Promise<number | null> {
451
- const headerDelayMs = parseRetryAfterMs(response.headers.get("retry-after-ms"));
452
- if (headerDelayMs !== null) {
453
- return clampDelay(headerDelayMs, maxDelayMs);
454
- }
455
-
456
- const retryAfter = parseRetryAfter(response.headers.get("retry-after"));
457
- if (retryAfter !== null) {
458
- return clampDelay(retryAfter, maxDelayMs);
459
- }
460
-
461
- const bodyDelayMs = await parseRetryDelayFromBody(response);
462
- if (bodyDelayMs !== null) {
463
- return clampDelay(bodyDelayMs, maxDelayMs);
464
- }
465
-
466
- const fallback = baseDelayMs * Math.pow(2, attempt);
467
- return clampDelay(fallback, maxDelayMs);
468
- }
469
-
470
- function clampDelay(delayMs: number, maxDelayMs: number): number {
471
- if (!Number.isFinite(delayMs)) {
472
- return maxDelayMs;
473
- }
474
- return Math.min(Math.max(0, delayMs), maxDelayMs);
475
- }
476
-
477
- function parseRetryAfterMs(value: string | null): number | null {
478
- if (!value) {
479
- return null;
480
- }
481
- const parsed = Number(value);
482
- if (!Number.isFinite(parsed) || parsed <= 0) {
483
- return null;
164
+ throw error;
484
165
  }
485
- return parsed;
486
166
  }
487
167
 
488
- function parseRetryAfter(value: string | null): number | null {
489
- if (!value) {
490
- return null;
491
- }
492
- const trimmed = value.trim();
493
- if (!trimmed) {
494
- return null;
495
- }
496
- const asNumber = Number(trimmed);
497
- if (Number.isFinite(asNumber)) {
498
- return Math.max(0, Math.round(asNumber * 1000));
168
+ function toUrlString(value: RequestInfo): string {
169
+ if (typeof value === "string") {
170
+ return value;
499
171
  }
500
- const asDate = Date.parse(trimmed);
501
- if (!Number.isNaN(asDate)) {
502
- return Math.max(0, asDate - Date.now());
172
+ const candidate = (value as Request).url;
173
+ if (candidate) {
174
+ return candidate;
503
175
  }
504
- return null;
176
+ return value.toString();
505
177
  }
506
178
 
507
- async function parseRetryDelayFromBody(response: Response): Promise<number | null> {
508
- let text = "";
509
- try {
510
- text = await response.clone().text();
511
- } catch {
512
- return null;
513
- }
514
-
515
- if (!text) {
516
- return null;
517
- }
518
-
519
- let parsed: any;
520
- try {
521
- parsed = JSON.parse(text);
522
- } catch {
523
- return null;
524
- }
525
-
526
- const details = parsed?.error?.details;
527
- if (!Array.isArray(details)) {
528
- return null;
529
- }
530
-
531
- for (const detail of details) {
532
- if (!detail || typeof detail !== "object") {
533
- continue;
534
- }
535
- const retryDelay = (detail as Record<string, unknown>).retryDelay;
536
- if (!retryDelay) {
537
- continue;
538
- }
539
- const delayMs = parseRetryDelayValue(retryDelay);
540
- if (delayMs !== null) {
541
- return delayMs;
542
- }
179
+ /**
180
+ * Debug-only, best-effort model visibility log from Code Assist quota buckets.
181
+ *
182
+ * Why: it gives a concrete backend-side list of model IDs currently visible to the
183
+ * current account/project, which helps explain 404/notFound model failures quickly.
184
+ */
185
+ async function maybeLogAvailableQuotaModels(
186
+ accessToken: string,
187
+ projectId: string,
188
+ ): Promise<void> {
189
+ if (!isGeminiDebugEnabled() || !projectId) {
190
+ return;
543
191
  }
544
192
 
545
- return null;
546
- }
547
-
548
- function parseRetryDelayValue(value: unknown): number | null {
549
- if (!value) {
550
- return null;
193
+ if (loggedQuotaModelsByProject.has(projectId)) {
194
+ return;
551
195
  }
196
+ loggedQuotaModelsByProject.add(projectId);
552
197
 
553
- if (typeof value === "string") {
554
- const match = value.match(/^([\d.]+)s$/);
555
- if (!match || !match[1]) {
556
- return null;
557
- }
558
- const seconds = Number(match[1]);
559
- if (!Number.isFinite(seconds) || seconds <= 0) {
560
- return null;
561
- }
562
- return Math.round(seconds * 1000);
198
+ const quota = await retrieveUserQuota(accessToken, projectId);
199
+ if (!quota?.buckets) {
200
+ logGeminiDebugMessage(`Code Assist quota model lookup returned no buckets for project: ${projectId}`);
201
+ return;
563
202
  }
564
203
 
565
- if (typeof value === "object") {
566
- const record = value as Record<string, unknown>;
567
- const seconds = typeof record.seconds === "number" ? record.seconds : 0;
568
- const nanos = typeof record.nanos === "number" ? record.nanos : 0;
569
- if (!Number.isFinite(seconds) || !Number.isFinite(nanos)) {
570
- return null;
571
- }
572
- const totalMs = Math.round(seconds * 1000 + nanos / 1e6);
573
- return totalMs > 0 ? totalMs : null;
204
+ const modelIds = [...new Set(quota.buckets.map((bucket) => bucket.modelId).filter(Boolean))];
205
+ if (modelIds.length === 0) {
206
+ logGeminiDebugMessage(`Code Assist quota buckets contained no model IDs for project: ${projectId}`);
207
+ return;
574
208
  }
575
209
 
576
- return null;
577
- }
578
-
579
- function wait(ms: number): Promise<void> {
580
- return new Promise((resolve) => {
581
- setTimeout(resolve, ms);
582
- });
210
+ logGeminiDebugMessage(
211
+ `Code Assist models visible via quota buckets (${projectId}): ${modelIds.join(", ")}`,
212
+ );
583
213
  }