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 +11 -0
- package/package.json +1 -1
- package/src/plugin/project.ts +13 -6
- package/src/plugin/request-helpers.ts +16 -0
- package/src/plugin.ts +122 -6
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
package/src/plugin/project.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
471
|
-
|
|
477
|
+
return { auth: updatedAuth, effectiveProjectId: onboardedProjectId };
|
|
478
|
+
}
|
|
472
479
|
|
|
473
|
-
|
|
474
|
-
|
|
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
|
-
|
|
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
|
|
462
|
-
if (
|
|
463
|
-
return clampDelay(
|
|
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
|
-
|
|
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
|
-
|
|
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);
|