opencode-gemini-auth 1.3.9 → 1.3.10

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 CHANGED
@@ -189,6 +189,17 @@ OPENCODE_GEMINI_DEBUG=1 opencode
189
189
  This will generate `gemini-debug-<timestamp>.log` files in your working
190
190
  directory containing sanitized request/response details.
191
191
 
192
+ ## Parity Notes
193
+
194
+ This plugin mirrors the official Gemini CLI OAuth flow and Code Assist
195
+ endpoints. In particular, project onboarding and quota retry handling follow
196
+ the same behavior patterns as the Gemini CLI.
197
+
198
+ ### References
199
+
200
+ - Gemini CLI repository: https://github.com/google-gemini/gemini-cli
201
+ - Gemini CLI quota documentation: https://developers.google.com/gemini-code-assist/resources/quotas
202
+
192
203
  ### Updating
193
204
 
194
205
  Opencode does not automatically update plugins. To update to the latest version,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "opencode-gemini-auth",
3
3
  "module": "index.ts",
4
- "version": "1.3.9",
4
+ "version": "1.3.10",
5
5
  "author": "jenslys",
6
6
  "repository": "https://github.com/jenslys/opencode-gemini-auth",
7
7
  "files": [
@@ -134,6 +134,9 @@ function buildIneligibleTierMessage(tiers?: GeminiIneligibleTier[]): string | un
134
134
  return reasons.join(", ");
135
135
  }
136
136
 
137
+ /**
138
+ * Detects VPC-SC errors from Cloud Code responses.
139
+ */
137
140
  function isVpcScError(payload: unknown): boolean {
138
141
  if (!payload || typeof payload !== "object") {
139
142
  return false;
@@ -155,6 +158,9 @@ function isVpcScError(payload: unknown): boolean {
155
158
  });
156
159
  }
157
160
 
161
+ /**
162
+ * Safely parses JSON, returning null on failure.
163
+ */
158
164
  function parseJsonSafe(text: string): unknown {
159
165
  try {
160
166
  return JSON.parse(text);
@@ -402,9 +408,10 @@ export async function resolveProjectContextFromAccessToken(
402
408
  const projectId = effectiveConfiguredProjectId ?? parts.projectId;
403
409
 
404
410
  if (projectId || parts.managedProjectId) {
411
+ const effectiveProjectId = projectId || parts.managedProjectId || "";
405
412
  return {
406
413
  auth,
407
- effectiveProjectId: projectId || parts.managedProjectId || "",
414
+ effectiveProjectId,
408
415
  };
409
416
  }
410
417
 
@@ -467,12 +474,12 @@ export async function resolveProjectContextFromAccessToken(
467
474
  await persistAuth(updatedAuth);
468
475
  }
469
476
 
470
- return { auth: updatedAuth, effectiveProjectId: onboardedProjectId };
471
- }
477
+ return { auth: updatedAuth, effectiveProjectId: onboardedProjectId };
478
+ }
472
479
 
473
- if (projectId) {
474
- return { auth, effectiveProjectId: projectId };
475
- }
480
+ if (projectId) {
481
+ return { auth, effectiveProjectId: projectId };
482
+ }
476
483
 
477
484
  throw new ProjectIdRequiredError();
478
485
  }
@@ -191,6 +191,10 @@ export function rewriteGeminiPreviewAccessError(
191
191
  /**
192
192
  * Enhances Gemini error responses with friendly messages and retry hints.
193
193
  */
194
+ /**
195
+ * Enhances Gemini errors with validation/quota messaging and retry hints.
196
+ * Keeps messaging aligned with Gemini CLI's Cloud Code error handling.
197
+ */
194
198
  export function enhanceGeminiErrorResponse(
195
199
  body: GeminiApiBody,
196
200
  status: number,
@@ -277,6 +281,9 @@ function isGeminiThreeModel(target?: string): boolean {
277
281
  return /gemini[\s-]?3/i.test(target);
278
282
  }
279
283
 
284
+ /**
285
+ * Extracts validation URLs when the backend requires account verification.
286
+ */
280
287
  function extractValidationInfo(details: unknown[]): { link?: string; learnMore?: string } | null {
281
288
  const errorInfo = details.find(
282
289
  (detail): detail is GoogleRpcErrorInfo =>
@@ -328,6 +335,9 @@ function extractValidationInfo(details: unknown[]): { link?: string; learnMore?:
328
335
  return link || learnMore ? { link, learnMore } : null;
329
336
  }
330
337
 
338
+ /**
339
+ * Classifies quota-related error details as retryable or terminal.
340
+ */
331
341
  function extractQuotaInfo(details: unknown[]): { retryable: boolean } | null {
332
342
  const errorInfo = details.find(
333
343
  (detail): detail is GoogleRpcErrorInfo =>
@@ -363,6 +373,9 @@ function extractQuotaInfo(details: unknown[]): { retryable: boolean } | null {
363
373
  return null;
364
374
  }
365
375
 
376
+ /**
377
+ * Extracts retry delay hints from structured error details or message text.
378
+ */
366
379
  function extractRetryDelay(details: unknown[], errorMessage?: string): number | null {
367
380
  const retryInfo = details.find(
368
381
  (detail): detail is GoogleRpcRetryInfo =>
@@ -392,6 +405,9 @@ function extractRetryDelay(details: unknown[], errorMessage?: string): number |
392
405
  return null;
393
406
  }
394
407
 
408
+ /**
409
+ * Parses retry delay values from strings or protobuf-style objects.
410
+ */
395
411
  function parseRetryDelayValue(value: string | { seconds?: number; nanos?: number }): number | null {
396
412
  if (!value) {
397
413
  return null;
package/src/plugin.ts CHANGED
@@ -323,6 +323,11 @@ const RETRYABLE_STATUS_CODES = new Set([429, 503]);
323
323
  const DEFAULT_MAX_RETRIES = 2;
324
324
  const DEFAULT_BASE_DELAY_MS = 800;
325
325
  const DEFAULT_MAX_DELAY_MS = 8000;
326
+ const CLOUDCODE_DOMAINS = [
327
+ "cloudcode-pa.googleapis.com",
328
+ "staging-cloudcode-pa.googleapis.com",
329
+ "autopush-cloudcode-pa.googleapis.com",
330
+ ];
326
331
 
327
332
  function toUrlString(value: RequestInfo): string {
328
333
  if (typeof value === "string") {
@@ -387,6 +392,10 @@ function openBrowserUrl(url: string): void {
387
392
  }
388
393
  }
389
394
 
395
+ /**
396
+ * Sends requests with bounded retry logic for transient Cloud Code failures.
397
+ * Mirrors the Gemini CLI handling of Code Assist rate-limit signals.
398
+ */
390
399
  async function fetchWithRetry(input: RequestInfo, init: RequestInit | undefined): Promise<Response> {
391
400
  const maxRetries = DEFAULT_MAX_RETRIES;
392
401
  const baseDelayMs = DEFAULT_BASE_DELAY_MS;
@@ -403,7 +412,22 @@ async function fetchWithRetry(input: RequestInfo, init: RequestInit | undefined)
403
412
  return response;
404
413
  }
405
414
 
406
- const delayMs = await getRetryDelayMs(response, attempt, baseDelayMs, maxDelayMs);
415
+ let retryDelayMs: number | null = null;
416
+ if (response.status === 429) {
417
+ const quotaContext = await classifyQuotaResponse(response);
418
+ if (quotaContext?.terminal) {
419
+ return response;
420
+ }
421
+ retryDelayMs = quotaContext?.retryDelayMs ?? null;
422
+ }
423
+
424
+ const delayMs = await getRetryDelayMs(
425
+ response,
426
+ attempt,
427
+ baseDelayMs,
428
+ maxDelayMs,
429
+ retryDelayMs,
430
+ );
407
431
  if (!delayMs || delayMs <= 0) {
408
432
  return response;
409
433
  }
@@ -442,11 +466,16 @@ function canRetryRequest(init: RequestInit | undefined): boolean {
442
466
  return false;
443
467
  }
444
468
 
469
+ /**
470
+ * Resolves a retry delay from headers, body hints, or exponential backoff.
471
+ * Honors RetryInfo/Retry-After hints emitted by Code Assist.
472
+ */
445
473
  async function getRetryDelayMs(
446
474
  response: Response,
447
475
  attempt: number,
448
476
  baseDelayMs: number,
449
477
  maxDelayMs: number,
478
+ bodyDelayMs: number | null = null,
450
479
  ): Promise<number | null> {
451
480
  const headerDelayMs = parseRetryAfterMs(response.headers.get("retry-after-ms"));
452
481
  if (headerDelayMs !== null) {
@@ -458,9 +487,9 @@ async function getRetryDelayMs(
458
487
  return clampDelay(retryAfter, maxDelayMs);
459
488
  }
460
489
 
461
- const bodyDelayMs = await parseRetryDelayFromBody(response);
462
- if (bodyDelayMs !== null) {
463
- return clampDelay(bodyDelayMs, maxDelayMs);
490
+ const parsedBodyDelayMs = bodyDelayMs ?? (await parseRetryDelayFromBody(response));
491
+ if (parsedBodyDelayMs !== null) {
492
+ return clampDelay(parsedBodyDelayMs, maxDelayMs);
464
493
  }
465
494
 
466
495
  const fallback = baseDelayMs * Math.pow(2, attempt);
@@ -525,7 +554,8 @@ async function parseRetryDelayFromBody(response: Response): Promise<number | nul
525
554
 
526
555
  const details = parsed?.error?.details;
527
556
  if (!Array.isArray(details)) {
528
- return null;
557
+ const message = typeof parsed?.error?.message === "string" ? parsed.error.message : "";
558
+ return parseRetryDelayFromMessage(message);
529
559
  }
530
560
 
531
561
  for (const detail of details) {
@@ -542,7 +572,8 @@ async function parseRetryDelayFromBody(response: Response): Promise<number | nul
542
572
  }
543
573
  }
544
574
 
545
- return null;
575
+ const message = typeof parsed?.error?.message === "string" ? parsed.error.message : "";
576
+ return parseRetryDelayFromMessage(message);
546
577
  }
547
578
 
548
579
  function parseRetryDelayValue(value: unknown): number | null {
@@ -576,6 +607,91 @@ function parseRetryDelayValue(value: unknown): number | null {
576
607
  return null;
577
608
  }
578
609
 
610
+ /**
611
+ * Parses retry delays embedded in error message strings (e.g., "Please retry in 5s").
612
+ */
613
+ function parseRetryDelayFromMessage(message: string): number | null {
614
+ if (!message) {
615
+ return null;
616
+ }
617
+ const retryMatch = message.match(/Please retry in ([0-9.]+(?:ms|s))/);
618
+ if (retryMatch?.[1]) {
619
+ return parseRetryDelayValue(retryMatch[1]);
620
+ }
621
+ const afterMatch = message.match(/after\s+([0-9.]+(?:ms|s))/i);
622
+ if (afterMatch?.[1]) {
623
+ return parseRetryDelayValue(afterMatch[1]);
624
+ }
625
+ return null;
626
+ }
627
+
628
+ /**
629
+ * Classifies quota errors as terminal vs retryable and extracts retry hints.
630
+ * Matches Gemini CLI semantics: QUOTA_EXHAUSTED is terminal, RATE_LIMIT_EXCEEDED is retryable.
631
+ */
632
+ async function classifyQuotaResponse(
633
+ response: Response,
634
+ ): Promise<{ terminal: boolean; retryDelayMs?: number } | null> {
635
+ let text = "";
636
+ try {
637
+ text = await response.clone().text();
638
+ } catch {
639
+ return null;
640
+ }
641
+
642
+ if (!text) {
643
+ return null;
644
+ }
645
+
646
+ let parsed: any;
647
+ try {
648
+ parsed = JSON.parse(text);
649
+ } catch {
650
+ return null;
651
+ }
652
+
653
+ const error = parsed?.error ?? {};
654
+ const details = Array.isArray(error?.details) ? error.details : [];
655
+ const retryDelayMs = parseRetryDelayFromMessage(error?.message ?? "") ?? undefined;
656
+
657
+ const errorInfo = details.find(
658
+ (detail: any) =>
659
+ detail &&
660
+ typeof detail === "object" &&
661
+ detail["@type"] === "type.googleapis.com/google.rpc.ErrorInfo",
662
+ );
663
+
664
+ if (errorInfo?.domain && !CLOUDCODE_DOMAINS.includes(errorInfo.domain)) {
665
+ return null;
666
+ }
667
+
668
+ if (errorInfo?.reason === "QUOTA_EXHAUSTED") {
669
+ return { terminal: true, retryDelayMs };
670
+ }
671
+ if (errorInfo?.reason === "RATE_LIMIT_EXCEEDED") {
672
+ return { terminal: false, retryDelayMs };
673
+ }
674
+
675
+ const quotaFailure = details.find(
676
+ (detail: any) =>
677
+ detail &&
678
+ typeof detail === "object" &&
679
+ detail["@type"] === "type.googleapis.com/google.rpc.QuotaFailure",
680
+ );
681
+
682
+ if (quotaFailure?.violations && Array.isArray(quotaFailure.violations)) {
683
+ const combined = quotaFailure.violations
684
+ .map((violation: any) => String(violation?.description ?? "").toLowerCase())
685
+ .join(" ");
686
+ if (combined.includes("daily") || combined.includes("per day")) {
687
+ return { terminal: true, retryDelayMs };
688
+ }
689
+ return { terminal: false, retryDelayMs };
690
+ }
691
+
692
+ return { terminal: false, retryDelayMs };
693
+ }
694
+
579
695
  function wait(ms: number): Promise<void> {
580
696
  return new Promise((resolve) => {
581
697
  setTimeout(resolve, ms);