opencode-gemini-auth 1.3.8 → 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 +24 -1
- package/index.ts +1 -1
- package/package.json +2 -2
- package/src/gemini/oauth.ts +35 -47
- package/src/plugin/debug.ts +60 -0
- package/src/plugin/project.test.ts +112 -0
- package/src/plugin/project.ts +321 -121
- package/src/plugin/request-helpers.ts +265 -0
- package/src/plugin/request.ts +14 -30
- package/src/plugin/token.ts +32 -7
- package/src/plugin.ts +207 -18
package/src/plugin.ts
CHANGED
|
@@ -3,13 +3,15 @@ import { spawn } from "node:child_process";
|
|
|
3
3
|
import { GEMINI_PROVIDER_ID, GEMINI_REDIRECT_URI } from "./constants";
|
|
4
4
|
import {
|
|
5
5
|
authorizeGemini,
|
|
6
|
-
exchangeGemini,
|
|
7
6
|
exchangeGeminiWithVerifier,
|
|
8
7
|
} from "./gemini/oauth";
|
|
9
8
|
import type { GeminiTokenExchangeResult } from "./gemini/oauth";
|
|
10
9
|
import { accessTokenExpired, isOAuthAuth } from "./plugin/auth";
|
|
11
|
-
import {
|
|
12
|
-
|
|
10
|
+
import {
|
|
11
|
+
ensureProjectContext,
|
|
12
|
+
resolveProjectContextFromAccessToken,
|
|
13
|
+
} from "./plugin/project";
|
|
14
|
+
import { isGeminiDebugEnabled, logGeminiDebugMessage, startGeminiDebugRequest } from "./plugin/debug";
|
|
13
15
|
import {
|
|
14
16
|
isGenerativeLanguageRequest,
|
|
15
17
|
prepareGeminiRequest,
|
|
@@ -20,6 +22,7 @@ import { startOAuthListener, type OAuthListener } from "./plugin/server";
|
|
|
20
22
|
import type {
|
|
21
23
|
GetAuth,
|
|
22
24
|
LoaderResult,
|
|
25
|
+
OAuthAuthDetails,
|
|
23
26
|
PluginContext,
|
|
24
27
|
PluginResult,
|
|
25
28
|
ProjectContextResult,
|
|
@@ -50,7 +53,12 @@ export const GeminiCLIOAuthPlugin = async (
|
|
|
50
53
|
? providerOptions.projectId.trim()
|
|
51
54
|
: "";
|
|
52
55
|
const projectIdFromEnv = process.env.OPENCODE_GEMINI_PROJECT_ID?.trim() ?? "";
|
|
53
|
-
const
|
|
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;
|
|
54
62
|
|
|
55
63
|
if (provider.models) {
|
|
56
64
|
for (const model of Object.values(provider.models)) {
|
|
@@ -136,6 +144,57 @@ export const GeminiCLIOAuthPlugin = async (
|
|
|
136
144
|
label: "OAuth with Google (Gemini CLI)",
|
|
137
145
|
type: "oauth",
|
|
138
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
|
+
|
|
139
198
|
const isHeadless = !!(
|
|
140
199
|
process.env.SSH_CONNECTION ||
|
|
141
200
|
process.env.SSH_CLIENT ||
|
|
@@ -188,7 +247,16 @@ export const GeminiCLIOAuthPlugin = async (
|
|
|
188
247
|
};
|
|
189
248
|
}
|
|
190
249
|
|
|
191
|
-
|
|
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
|
+
);
|
|
192
260
|
} catch (error) {
|
|
193
261
|
return {
|
|
194
262
|
type: "failed",
|
|
@@ -208,10 +276,10 @@ export const GeminiCLIOAuthPlugin = async (
|
|
|
208
276
|
url: authorization.url,
|
|
209
277
|
instructions:
|
|
210
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.",
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
279
|
+
method: "code",
|
|
280
|
+
callback: async (callbackUrl: string): Promise<GeminiTokenExchangeResult> => {
|
|
281
|
+
try {
|
|
282
|
+
const { code, state } = parseOAuthCallbackInput(callbackUrl);
|
|
215
283
|
|
|
216
284
|
if (!code) {
|
|
217
285
|
return {
|
|
@@ -220,11 +288,16 @@ export const GeminiCLIOAuthPlugin = async (
|
|
|
220
288
|
};
|
|
221
289
|
}
|
|
222
290
|
|
|
223
|
-
if (state) {
|
|
224
|
-
return
|
|
291
|
+
if (state && state !== authorization.state) {
|
|
292
|
+
return {
|
|
293
|
+
type: "failed",
|
|
294
|
+
error: "State mismatch in callback input (possible CSRF attempt)",
|
|
295
|
+
};
|
|
225
296
|
}
|
|
226
297
|
|
|
227
|
-
return
|
|
298
|
+
return await maybeHydrateProjectId(
|
|
299
|
+
await exchangeGeminiWithVerifier(code, authorization.verifier),
|
|
300
|
+
);
|
|
228
301
|
} catch (error) {
|
|
229
302
|
return {
|
|
230
303
|
type: "failed",
|
|
@@ -250,6 +323,11 @@ const RETRYABLE_STATUS_CODES = new Set([429, 503]);
|
|
|
250
323
|
const DEFAULT_MAX_RETRIES = 2;
|
|
251
324
|
const DEFAULT_BASE_DELAY_MS = 800;
|
|
252
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
|
+
];
|
|
253
331
|
|
|
254
332
|
function toUrlString(value: RequestInfo): string {
|
|
255
333
|
if (typeof value === "string") {
|
|
@@ -314,6 +392,10 @@ function openBrowserUrl(url: string): void {
|
|
|
314
392
|
}
|
|
315
393
|
}
|
|
316
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
|
+
*/
|
|
317
399
|
async function fetchWithRetry(input: RequestInfo, init: RequestInit | undefined): Promise<Response> {
|
|
318
400
|
const maxRetries = DEFAULT_MAX_RETRIES;
|
|
319
401
|
const baseDelayMs = DEFAULT_BASE_DELAY_MS;
|
|
@@ -330,7 +412,22 @@ async function fetchWithRetry(input: RequestInfo, init: RequestInit | undefined)
|
|
|
330
412
|
return response;
|
|
331
413
|
}
|
|
332
414
|
|
|
333
|
-
|
|
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
|
+
);
|
|
334
431
|
if (!delayMs || delayMs <= 0) {
|
|
335
432
|
return response;
|
|
336
433
|
}
|
|
@@ -369,11 +466,16 @@ function canRetryRequest(init: RequestInit | undefined): boolean {
|
|
|
369
466
|
return false;
|
|
370
467
|
}
|
|
371
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
|
+
*/
|
|
372
473
|
async function getRetryDelayMs(
|
|
373
474
|
response: Response,
|
|
374
475
|
attempt: number,
|
|
375
476
|
baseDelayMs: number,
|
|
376
477
|
maxDelayMs: number,
|
|
478
|
+
bodyDelayMs: number | null = null,
|
|
377
479
|
): Promise<number | null> {
|
|
378
480
|
const headerDelayMs = parseRetryAfterMs(response.headers.get("retry-after-ms"));
|
|
379
481
|
if (headerDelayMs !== null) {
|
|
@@ -385,9 +487,9 @@ async function getRetryDelayMs(
|
|
|
385
487
|
return clampDelay(retryAfter, maxDelayMs);
|
|
386
488
|
}
|
|
387
489
|
|
|
388
|
-
const
|
|
389
|
-
if (
|
|
390
|
-
return clampDelay(
|
|
490
|
+
const parsedBodyDelayMs = bodyDelayMs ?? (await parseRetryDelayFromBody(response));
|
|
491
|
+
if (parsedBodyDelayMs !== null) {
|
|
492
|
+
return clampDelay(parsedBodyDelayMs, maxDelayMs);
|
|
391
493
|
}
|
|
392
494
|
|
|
393
495
|
const fallback = baseDelayMs * Math.pow(2, attempt);
|
|
@@ -452,7 +554,8 @@ async function parseRetryDelayFromBody(response: Response): Promise<number | nul
|
|
|
452
554
|
|
|
453
555
|
const details = parsed?.error?.details;
|
|
454
556
|
if (!Array.isArray(details)) {
|
|
455
|
-
|
|
557
|
+
const message = typeof parsed?.error?.message === "string" ? parsed.error.message : "";
|
|
558
|
+
return parseRetryDelayFromMessage(message);
|
|
456
559
|
}
|
|
457
560
|
|
|
458
561
|
for (const detail of details) {
|
|
@@ -469,7 +572,8 @@ async function parseRetryDelayFromBody(response: Response): Promise<number | nul
|
|
|
469
572
|
}
|
|
470
573
|
}
|
|
471
574
|
|
|
472
|
-
|
|
575
|
+
const message = typeof parsed?.error?.message === "string" ? parsed.error.message : "";
|
|
576
|
+
return parseRetryDelayFromMessage(message);
|
|
473
577
|
}
|
|
474
578
|
|
|
475
579
|
function parseRetryDelayValue(value: unknown): number | null {
|
|
@@ -503,6 +607,91 @@ function parseRetryDelayValue(value: unknown): number | null {
|
|
|
503
607
|
return null;
|
|
504
608
|
}
|
|
505
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
|
+
|
|
506
695
|
function wait(ms: number): Promise<void> {
|
|
507
696
|
return new Promise((resolve) => {
|
|
508
697
|
setTimeout(resolve, ms);
|