opencode-gemini-auth-proxy 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/LICENSE +21 -0
- package/README.md +254 -0
- package/index.ts +14 -0
- package/package.json +23 -0
- package/src/constants.ts +39 -0
- package/src/fetch.ts +11 -0
- package/src/gemini/oauth.ts +178 -0
- package/src/plugin/auth.test.ts +58 -0
- package/src/plugin/auth.ts +46 -0
- package/src/plugin/cache.ts +65 -0
- package/src/plugin/debug.ts +258 -0
- package/src/plugin/project.test.ts +112 -0
- package/src/plugin/project.ts +552 -0
- package/src/plugin/request-helpers.test.ts +84 -0
- package/src/plugin/request-helpers.ts +439 -0
- package/src/plugin/request.test.ts +50 -0
- package/src/plugin/request.ts +483 -0
- package/src/plugin/server.ts +246 -0
- package/src/plugin/token.test.ts +74 -0
- package/src/plugin/token.ts +188 -0
- package/src/plugin/types.ts +76 -0
- package/src/plugin.ts +700 -0
- package/src/shims.d.ts +8 -0
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import proxyFetch from "./fetch";
|
|
3
|
+
|
|
4
|
+
import { GEMINI_PROVIDER_ID, GEMINI_REDIRECT_URI } from "./constants";
|
|
5
|
+
import {
|
|
6
|
+
authorizeGemini,
|
|
7
|
+
exchangeGeminiWithVerifier,
|
|
8
|
+
} from "./gemini/oauth";
|
|
9
|
+
import type { GeminiTokenExchangeResult } from "./gemini/oauth";
|
|
10
|
+
import { accessTokenExpired, isOAuthAuth } from "./plugin/auth";
|
|
11
|
+
import {
|
|
12
|
+
ensureProjectContext,
|
|
13
|
+
resolveProjectContextFromAccessToken,
|
|
14
|
+
} from "./plugin/project";
|
|
15
|
+
import { isGeminiDebugEnabled, logGeminiDebugMessage, startGeminiDebugRequest } from "./plugin/debug";
|
|
16
|
+
import {
|
|
17
|
+
isGenerativeLanguageRequest,
|
|
18
|
+
prepareGeminiRequest,
|
|
19
|
+
transformGeminiResponse,
|
|
20
|
+
} from "./plugin/request";
|
|
21
|
+
import { refreshAccessToken } from "./plugin/token";
|
|
22
|
+
import { startOAuthListener, type OAuthListener } from "./plugin/server";
|
|
23
|
+
import type {
|
|
24
|
+
GetAuth,
|
|
25
|
+
LoaderResult,
|
|
26
|
+
OAuthAuthDetails,
|
|
27
|
+
PluginContext,
|
|
28
|
+
PluginResult,
|
|
29
|
+
ProjectContextResult,
|
|
30
|
+
Provider,
|
|
31
|
+
} from "./plugin/types";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Registers the Gemini OAuth provider for Opencode, handling auth, request rewriting,
|
|
35
|
+
* debug logging, and response normalization for Gemini Code Assist endpoints.
|
|
36
|
+
*/
|
|
37
|
+
export const GeminiCLIOAuthPlugin = async (
|
|
38
|
+
{ client }: PluginContext,
|
|
39
|
+
): Promise<PluginResult> => ({
|
|
40
|
+
auth: {
|
|
41
|
+
provider: GEMINI_PROVIDER_ID,
|
|
42
|
+
loader: async (getAuth: GetAuth, provider: Provider): Promise<LoaderResult | null> => {
|
|
43
|
+
const auth = await getAuth();
|
|
44
|
+
if (!isOAuthAuth(auth)) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const providerOptions =
|
|
49
|
+
provider && typeof provider === "object"
|
|
50
|
+
? ((provider as { options?: Record<string, unknown> }).options ?? undefined)
|
|
51
|
+
: undefined;
|
|
52
|
+
const projectIdFromConfig =
|
|
53
|
+
providerOptions && typeof providerOptions.projectId === "string"
|
|
54
|
+
? providerOptions.projectId.trim()
|
|
55
|
+
: "";
|
|
56
|
+
const projectIdFromEnv = process.env.OPENCODE_GEMINI_PROJECT_ID?.trim() ?? "";
|
|
57
|
+
const googleProjectIdFromEnv =
|
|
58
|
+
process.env.GOOGLE_CLOUD_PROJECT?.trim() ??
|
|
59
|
+
process.env.GOOGLE_CLOUD_PROJECT_ID?.trim() ??
|
|
60
|
+
"";
|
|
61
|
+
const configuredProjectId =
|
|
62
|
+
projectIdFromEnv || projectIdFromConfig || googleProjectIdFromEnv || undefined;
|
|
63
|
+
|
|
64
|
+
if (provider.models) {
|
|
65
|
+
for (const model of Object.values(provider.models)) {
|
|
66
|
+
if (model) {
|
|
67
|
+
model.cost = { input: 0, output: 0 };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
apiKey: "",
|
|
74
|
+
async fetch(input, init) {
|
|
75
|
+
if (!isGenerativeLanguageRequest(input)) {
|
|
76
|
+
return proxyFetch(input, init);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const latestAuth = await getAuth();
|
|
80
|
+
if (!isOAuthAuth(latestAuth)) {
|
|
81
|
+
return proxyFetch(input, init);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let authRecord = latestAuth;
|
|
85
|
+
if (accessTokenExpired(authRecord)) {
|
|
86
|
+
const refreshed = await refreshAccessToken(authRecord, client);
|
|
87
|
+
if (!refreshed) {
|
|
88
|
+
return proxyFetch(input, init);
|
|
89
|
+
}
|
|
90
|
+
authRecord = refreshed;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const accessToken = authRecord.access;
|
|
94
|
+
if (!accessToken) {
|
|
95
|
+
return proxyFetch(input, init);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Ensures we have a usable project context for the current auth snapshot.
|
|
100
|
+
*/
|
|
101
|
+
async function resolveProjectContext(): Promise<ProjectContextResult> {
|
|
102
|
+
try {
|
|
103
|
+
return await ensureProjectContext(authRecord, client, configuredProjectId);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (error instanceof Error) {
|
|
106
|
+
console.error(error.message);
|
|
107
|
+
}
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const projectContext = await resolveProjectContext();
|
|
113
|
+
|
|
114
|
+
const {
|
|
115
|
+
request,
|
|
116
|
+
init: transformedInit,
|
|
117
|
+
streaming,
|
|
118
|
+
requestedModel,
|
|
119
|
+
} = prepareGeminiRequest(
|
|
120
|
+
input,
|
|
121
|
+
init,
|
|
122
|
+
accessToken,
|
|
123
|
+
projectContext.effectiveProjectId,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const originalUrl = toUrlString(input);
|
|
127
|
+
const resolvedUrl = toUrlString(request);
|
|
128
|
+
const debugContext = startGeminiDebugRequest({
|
|
129
|
+
originalUrl,
|
|
130
|
+
resolvedUrl,
|
|
131
|
+
method: transformedInit.method,
|
|
132
|
+
headers: transformedInit.headers,
|
|
133
|
+
body: transformedInit.body,
|
|
134
|
+
streaming,
|
|
135
|
+
projectId: projectContext.effectiveProjectId,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const response = await fetchWithRetry(request, transformedInit);
|
|
139
|
+
return transformGeminiResponse(response, streaming, debugContext, requestedModel);
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
},
|
|
143
|
+
methods: [
|
|
144
|
+
{
|
|
145
|
+
label: "OAuth with Google (Gemini CLI)",
|
|
146
|
+
type: "oauth",
|
|
147
|
+
authorize: async () => {
|
|
148
|
+
const maybeHydrateProjectId = async (
|
|
149
|
+
result: GeminiTokenExchangeResult,
|
|
150
|
+
): Promise<GeminiTokenExchangeResult> => {
|
|
151
|
+
if (result.type !== "success") {
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const accessToken = result.access;
|
|
156
|
+
if (!accessToken) {
|
|
157
|
+
return result;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const projectFromEnv = process.env.OPENCODE_GEMINI_PROJECT_ID?.trim() ?? "";
|
|
161
|
+
const googleProjectFromEnv =
|
|
162
|
+
process.env.GOOGLE_CLOUD_PROJECT?.trim() ??
|
|
163
|
+
process.env.GOOGLE_CLOUD_PROJECT_ID?.trim() ??
|
|
164
|
+
"";
|
|
165
|
+
const configuredProjectId =
|
|
166
|
+
projectFromEnv || googleProjectFromEnv || undefined;
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
const authSnapshot = {
|
|
170
|
+
type: "oauth",
|
|
171
|
+
refresh: result.refresh,
|
|
172
|
+
access: result.access,
|
|
173
|
+
expires: result.expires,
|
|
174
|
+
} satisfies OAuthAuthDetails;
|
|
175
|
+
const projectContext = await resolveProjectContextFromAccessToken(
|
|
176
|
+
authSnapshot,
|
|
177
|
+
accessToken,
|
|
178
|
+
configuredProjectId,
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
if (projectContext.auth.refresh !== result.refresh) {
|
|
182
|
+
if (isGeminiDebugEnabled()) {
|
|
183
|
+
logGeminiDebugMessage(
|
|
184
|
+
`OAuth project resolved during auth: ${projectContext.effectiveProjectId || "none"}`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
return { ...result, refresh: projectContext.auth.refresh };
|
|
188
|
+
}
|
|
189
|
+
} catch (error) {
|
|
190
|
+
if (isGeminiDebugEnabled()) {
|
|
191
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
192
|
+
console.warn(`[Gemini OAuth] Project resolution skipped: ${message}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return result;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const isHeadless = !!(
|
|
200
|
+
process.env.SSH_CONNECTION ||
|
|
201
|
+
process.env.SSH_CLIENT ||
|
|
202
|
+
process.env.SSH_TTY ||
|
|
203
|
+
process.env.OPENCODE_HEADLESS
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
let listener: OAuthListener | null = null;
|
|
207
|
+
if (!isHeadless) {
|
|
208
|
+
try {
|
|
209
|
+
listener = await startOAuthListener();
|
|
210
|
+
} catch (error) {
|
|
211
|
+
if (error instanceof Error) {
|
|
212
|
+
console.log(
|
|
213
|
+
`Warning: Couldn't start the local callback listener (${error.message}). You'll need to paste the callback URL or authorization code.`,
|
|
214
|
+
);
|
|
215
|
+
} else {
|
|
216
|
+
console.log(
|
|
217
|
+
"Warning: Couldn't start the local callback listener. You'll need to paste the callback URL or authorization code.",
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
} else {
|
|
222
|
+
console.log(
|
|
223
|
+
"Headless environment detected. You'll need to paste the callback URL or authorization code.",
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const authorization = await authorizeGemini();
|
|
228
|
+
if (!isHeadless) {
|
|
229
|
+
openBrowserUrl(authorization.url);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (listener) {
|
|
233
|
+
return {
|
|
234
|
+
url: authorization.url,
|
|
235
|
+
instructions:
|
|
236
|
+
"Complete the sign-in flow in your browser. We'll automatically detect the redirect back to localhost.",
|
|
237
|
+
method: "auto",
|
|
238
|
+
callback: async (): Promise<GeminiTokenExchangeResult> => {
|
|
239
|
+
try {
|
|
240
|
+
const callbackUrl = await listener.waitForCallback();
|
|
241
|
+
const code = callbackUrl.searchParams.get("code");
|
|
242
|
+
const state = callbackUrl.searchParams.get("state");
|
|
243
|
+
|
|
244
|
+
if (!code || !state) {
|
|
245
|
+
return {
|
|
246
|
+
type: "failed",
|
|
247
|
+
error: "Missing code or state in callback URL",
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (state !== authorization.state) {
|
|
252
|
+
return {
|
|
253
|
+
type: "failed",
|
|
254
|
+
error: "State mismatch in callback URL (possible CSRF attempt)",
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return await maybeHydrateProjectId(
|
|
259
|
+
await exchangeGeminiWithVerifier(code, authorization.verifier),
|
|
260
|
+
);
|
|
261
|
+
} catch (error) {
|
|
262
|
+
return {
|
|
263
|
+
type: "failed",
|
|
264
|
+
error: error instanceof Error ? error.message : "Unknown error",
|
|
265
|
+
};
|
|
266
|
+
} finally {
|
|
267
|
+
try {
|
|
268
|
+
await listener?.close();
|
|
269
|
+
} catch {
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
url: authorization.url,
|
|
278
|
+
instructions:
|
|
279
|
+
"Complete OAuth in your browser, then paste the full redirected URL (e.g., http://localhost:8085/oauth2callback?code=...&state=...) or just the authorization code.",
|
|
280
|
+
method: "code",
|
|
281
|
+
callback: async (callbackUrl: string): Promise<GeminiTokenExchangeResult> => {
|
|
282
|
+
try {
|
|
283
|
+
const { code, state } = parseOAuthCallbackInput(callbackUrl);
|
|
284
|
+
|
|
285
|
+
if (!code) {
|
|
286
|
+
return {
|
|
287
|
+
type: "failed",
|
|
288
|
+
error: "Missing authorization code in callback input",
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (state && state !== authorization.state) {
|
|
293
|
+
return {
|
|
294
|
+
type: "failed",
|
|
295
|
+
error: "State mismatch in callback input (possible CSRF attempt)",
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return await maybeHydrateProjectId(
|
|
300
|
+
await exchangeGeminiWithVerifier(code, authorization.verifier),
|
|
301
|
+
);
|
|
302
|
+
} catch (error) {
|
|
303
|
+
return {
|
|
304
|
+
type: "failed",
|
|
305
|
+
error: error instanceof Error ? error.message : "Unknown error",
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
},
|
|
309
|
+
};
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
provider: GEMINI_PROVIDER_ID,
|
|
314
|
+
label: "Manually enter API Key",
|
|
315
|
+
type: "api",
|
|
316
|
+
},
|
|
317
|
+
],
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
export const GoogleOAuthPlugin = GeminiCLIOAuthPlugin;
|
|
322
|
+
|
|
323
|
+
const RETRYABLE_STATUS_CODES = new Set([429, 503]);
|
|
324
|
+
const DEFAULT_MAX_RETRIES = 2;
|
|
325
|
+
const DEFAULT_BASE_DELAY_MS = 800;
|
|
326
|
+
const DEFAULT_MAX_DELAY_MS = 8000;
|
|
327
|
+
const CLOUDCODE_DOMAINS = [
|
|
328
|
+
"cloudcode-pa.googleapis.com",
|
|
329
|
+
"staging-cloudcode-pa.googleapis.com",
|
|
330
|
+
"autopush-cloudcode-pa.googleapis.com",
|
|
331
|
+
];
|
|
332
|
+
|
|
333
|
+
function toUrlString(value: RequestInfo): string {
|
|
334
|
+
if (typeof value === "string") {
|
|
335
|
+
return value;
|
|
336
|
+
}
|
|
337
|
+
const candidate = (value as Request).url;
|
|
338
|
+
if (candidate) {
|
|
339
|
+
return candidate;
|
|
340
|
+
}
|
|
341
|
+
return value.toString();
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function parseOAuthCallbackInput(input: string): { code?: string; state?: string } {
|
|
345
|
+
const trimmed = input.trim();
|
|
346
|
+
if (!trimmed) {
|
|
347
|
+
return {};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (/^https?:\/\//i.test(trimmed)) {
|
|
351
|
+
try {
|
|
352
|
+
const url = new URL(trimmed);
|
|
353
|
+
return {
|
|
354
|
+
code: url.searchParams.get("code") || undefined,
|
|
355
|
+
state: url.searchParams.get("state") || undefined,
|
|
356
|
+
};
|
|
357
|
+
} catch {
|
|
358
|
+
return {};
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const candidate = trimmed.startsWith("?") ? trimmed.slice(1) : trimmed;
|
|
363
|
+
if (candidate.includes("=")) {
|
|
364
|
+
const params = new URLSearchParams(candidate);
|
|
365
|
+
const code = params.get("code") || undefined;
|
|
366
|
+
const state = params.get("state") || undefined;
|
|
367
|
+
if (code || state) {
|
|
368
|
+
return { code, state };
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return { code: trimmed };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function openBrowserUrl(url: string): void {
|
|
376
|
+
try {
|
|
377
|
+
// Best-effort: don't block auth flow if spawning fails.
|
|
378
|
+
const platform = process.platform;
|
|
379
|
+
const command =
|
|
380
|
+
platform === "darwin"
|
|
381
|
+
? "open"
|
|
382
|
+
: platform === "win32"
|
|
383
|
+
? "rundll32"
|
|
384
|
+
: "xdg-open";
|
|
385
|
+
const args =
|
|
386
|
+
platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url];
|
|
387
|
+
const child = spawn(command, args, {
|
|
388
|
+
stdio: "ignore",
|
|
389
|
+
detached: true,
|
|
390
|
+
});
|
|
391
|
+
child.unref?.();
|
|
392
|
+
} catch {
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Sends requests with bounded retry logic for transient Cloud Code failures.
|
|
398
|
+
* Mirrors the Gemini CLI handling of Code Assist rate-limit signals.
|
|
399
|
+
*/
|
|
400
|
+
async function fetchWithRetry(input: RequestInfo, init: RequestInit | undefined): Promise<Response> {
|
|
401
|
+
const maxRetries = DEFAULT_MAX_RETRIES;
|
|
402
|
+
const baseDelayMs = DEFAULT_BASE_DELAY_MS;
|
|
403
|
+
const maxDelayMs = DEFAULT_MAX_DELAY_MS;
|
|
404
|
+
|
|
405
|
+
if (!canRetryRequest(init)) {
|
|
406
|
+
return proxyFetch(input, init);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
let attempt = 0;
|
|
410
|
+
while (true) {
|
|
411
|
+
const response = await proxyFetch(input, init);
|
|
412
|
+
if (!RETRYABLE_STATUS_CODES.has(response.status) || attempt >= maxRetries) {
|
|
413
|
+
return response;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
let retryDelayMs: number | null = null;
|
|
417
|
+
if (response.status === 429) {
|
|
418
|
+
const quotaContext = await classifyQuotaResponse(response);
|
|
419
|
+
if (quotaContext?.terminal) {
|
|
420
|
+
return response;
|
|
421
|
+
}
|
|
422
|
+
retryDelayMs = quotaContext?.retryDelayMs ?? null;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const delayMs = await getRetryDelayMs(
|
|
426
|
+
response,
|
|
427
|
+
attempt,
|
|
428
|
+
baseDelayMs,
|
|
429
|
+
maxDelayMs,
|
|
430
|
+
retryDelayMs,
|
|
431
|
+
);
|
|
432
|
+
if (!delayMs || delayMs <= 0) {
|
|
433
|
+
return response;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (init?.signal?.aborted) {
|
|
437
|
+
return response;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
await wait(delayMs);
|
|
441
|
+
attempt += 1;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function canRetryRequest(init: RequestInit | undefined): boolean {
|
|
446
|
+
if (!init?.body) {
|
|
447
|
+
return true;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const body = init.body;
|
|
451
|
+
if (typeof body === "string") {
|
|
452
|
+
return true;
|
|
453
|
+
}
|
|
454
|
+
if (typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams) {
|
|
455
|
+
return true;
|
|
456
|
+
}
|
|
457
|
+
if (typeof ArrayBuffer !== "undefined" && body instanceof ArrayBuffer) {
|
|
458
|
+
return true;
|
|
459
|
+
}
|
|
460
|
+
if (typeof ArrayBuffer !== "undefined" && ArrayBuffer.isView(body)) {
|
|
461
|
+
return true;
|
|
462
|
+
}
|
|
463
|
+
if (typeof Blob !== "undefined" && body instanceof Blob) {
|
|
464
|
+
return true;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return false;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Resolves a retry delay from headers, body hints, or exponential backoff.
|
|
472
|
+
* Honors RetryInfo/Retry-After hints emitted by Code Assist.
|
|
473
|
+
*/
|
|
474
|
+
async function getRetryDelayMs(
|
|
475
|
+
response: Response,
|
|
476
|
+
attempt: number,
|
|
477
|
+
baseDelayMs: number,
|
|
478
|
+
maxDelayMs: number,
|
|
479
|
+
bodyDelayMs: number | null = null,
|
|
480
|
+
): Promise<number | null> {
|
|
481
|
+
const headerDelayMs = parseRetryAfterMs(response.headers.get("retry-after-ms"));
|
|
482
|
+
if (headerDelayMs !== null) {
|
|
483
|
+
return clampDelay(headerDelayMs, maxDelayMs);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const retryAfter = parseRetryAfter(response.headers.get("retry-after"));
|
|
487
|
+
if (retryAfter !== null) {
|
|
488
|
+
return clampDelay(retryAfter, maxDelayMs);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const parsedBodyDelayMs = bodyDelayMs ?? (await parseRetryDelayFromBody(response));
|
|
492
|
+
if (parsedBodyDelayMs !== null) {
|
|
493
|
+
return clampDelay(parsedBodyDelayMs, maxDelayMs);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const fallback = baseDelayMs * Math.pow(2, attempt);
|
|
497
|
+
return clampDelay(fallback, maxDelayMs);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function clampDelay(delayMs: number, maxDelayMs: number): number {
|
|
501
|
+
if (!Number.isFinite(delayMs)) {
|
|
502
|
+
return maxDelayMs;
|
|
503
|
+
}
|
|
504
|
+
return Math.min(Math.max(0, delayMs), maxDelayMs);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function parseRetryAfterMs(value: string | null): number | null {
|
|
508
|
+
if (!value) {
|
|
509
|
+
return null;
|
|
510
|
+
}
|
|
511
|
+
const parsed = Number(value);
|
|
512
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
513
|
+
return null;
|
|
514
|
+
}
|
|
515
|
+
return parsed;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function parseRetryAfter(value: string | null): number | null {
|
|
519
|
+
if (!value) {
|
|
520
|
+
return null;
|
|
521
|
+
}
|
|
522
|
+
const trimmed = value.trim();
|
|
523
|
+
if (!trimmed) {
|
|
524
|
+
return null;
|
|
525
|
+
}
|
|
526
|
+
const asNumber = Number(trimmed);
|
|
527
|
+
if (Number.isFinite(asNumber)) {
|
|
528
|
+
return Math.max(0, Math.round(asNumber * 1000));
|
|
529
|
+
}
|
|
530
|
+
const asDate = Date.parse(trimmed);
|
|
531
|
+
if (!Number.isNaN(asDate)) {
|
|
532
|
+
return Math.max(0, asDate - Date.now());
|
|
533
|
+
}
|
|
534
|
+
return null;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
async function parseRetryDelayFromBody(response: Response): Promise<number | null> {
|
|
538
|
+
let text = "";
|
|
539
|
+
try {
|
|
540
|
+
text = await response.clone().text();
|
|
541
|
+
} catch {
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (!text) {
|
|
546
|
+
return null;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
let parsed: any;
|
|
550
|
+
try {
|
|
551
|
+
parsed = JSON.parse(text);
|
|
552
|
+
} catch {
|
|
553
|
+
return null;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const details = parsed?.error?.details;
|
|
557
|
+
if (!Array.isArray(details)) {
|
|
558
|
+
const message = typeof parsed?.error?.message === "string" ? parsed.error.message : "";
|
|
559
|
+
return parseRetryDelayFromMessage(message);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
for (const detail of details) {
|
|
563
|
+
if (!detail || typeof detail !== "object") {
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
const retryDelay = (detail as Record<string, unknown>).retryDelay;
|
|
567
|
+
if (!retryDelay) {
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
const delayMs = parseRetryDelayValue(retryDelay);
|
|
571
|
+
if (delayMs !== null) {
|
|
572
|
+
return delayMs;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const message = typeof parsed?.error?.message === "string" ? parsed.error.message : "";
|
|
577
|
+
return parseRetryDelayFromMessage(message);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function parseRetryDelayValue(value: unknown): number | null {
|
|
581
|
+
if (!value) {
|
|
582
|
+
return null;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
if (typeof value === "string") {
|
|
586
|
+
const match = value.match(/^([\d.]+)s$/);
|
|
587
|
+
if (!match || !match[1]) {
|
|
588
|
+
return null;
|
|
589
|
+
}
|
|
590
|
+
const seconds = Number(match[1]);
|
|
591
|
+
if (!Number.isFinite(seconds) || seconds <= 0) {
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
594
|
+
return Math.round(seconds * 1000);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (typeof value === "object") {
|
|
598
|
+
const record = value as Record<string, unknown>;
|
|
599
|
+
const seconds = typeof record.seconds === "number" ? record.seconds : 0;
|
|
600
|
+
const nanos = typeof record.nanos === "number" ? record.nanos : 0;
|
|
601
|
+
if (!Number.isFinite(seconds) || !Number.isFinite(nanos)) {
|
|
602
|
+
return null;
|
|
603
|
+
}
|
|
604
|
+
const totalMs = Math.round(seconds * 1000 + nanos / 1e6);
|
|
605
|
+
return totalMs > 0 ? totalMs : null;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
return null;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Parses retry delays embedded in error message strings (e.g., "Please retry in 5s").
|
|
613
|
+
*/
|
|
614
|
+
function parseRetryDelayFromMessage(message: string): number | null {
|
|
615
|
+
if (!message) {
|
|
616
|
+
return null;
|
|
617
|
+
}
|
|
618
|
+
const retryMatch = message.match(/Please retry in ([0-9.]+(?:ms|s))/);
|
|
619
|
+
if (retryMatch?.[1]) {
|
|
620
|
+
return parseRetryDelayValue(retryMatch[1]);
|
|
621
|
+
}
|
|
622
|
+
const afterMatch = message.match(/after\s+([0-9.]+(?:ms|s))/i);
|
|
623
|
+
if (afterMatch?.[1]) {
|
|
624
|
+
return parseRetryDelayValue(afterMatch[1]);
|
|
625
|
+
}
|
|
626
|
+
return null;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Classifies quota errors as terminal vs retryable and extracts retry hints.
|
|
631
|
+
* Matches Gemini CLI semantics: QUOTA_EXHAUSTED is terminal, RATE_LIMIT_EXCEEDED is retryable.
|
|
632
|
+
*/
|
|
633
|
+
async function classifyQuotaResponse(
|
|
634
|
+
response: Response,
|
|
635
|
+
): Promise<{ terminal: boolean; retryDelayMs?: number } | null> {
|
|
636
|
+
let text = "";
|
|
637
|
+
try {
|
|
638
|
+
text = await response.clone().text();
|
|
639
|
+
} catch {
|
|
640
|
+
return null;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
if (!text) {
|
|
644
|
+
return null;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
let parsed: any;
|
|
648
|
+
try {
|
|
649
|
+
parsed = JSON.parse(text);
|
|
650
|
+
} catch {
|
|
651
|
+
return null;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const error = parsed?.error ?? {};
|
|
655
|
+
const details = Array.isArray(error?.details) ? error.details : [];
|
|
656
|
+
const retryDelayMs = parseRetryDelayFromMessage(error?.message ?? "") ?? undefined;
|
|
657
|
+
|
|
658
|
+
const errorInfo = details.find(
|
|
659
|
+
(detail: any) =>
|
|
660
|
+
detail &&
|
|
661
|
+
typeof detail === "object" &&
|
|
662
|
+
detail["@type"] === "type.googleapis.com/google.rpc.ErrorInfo",
|
|
663
|
+
);
|
|
664
|
+
|
|
665
|
+
if (errorInfo?.domain && !CLOUDCODE_DOMAINS.includes(errorInfo.domain)) {
|
|
666
|
+
return null;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (errorInfo?.reason === "QUOTA_EXHAUSTED") {
|
|
670
|
+
return { terminal: true, retryDelayMs };
|
|
671
|
+
}
|
|
672
|
+
if (errorInfo?.reason === "RATE_LIMIT_EXCEEDED") {
|
|
673
|
+
return { terminal: false, retryDelayMs };
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const quotaFailure = details.find(
|
|
677
|
+
(detail: any) =>
|
|
678
|
+
detail &&
|
|
679
|
+
typeof detail === "object" &&
|
|
680
|
+
detail["@type"] === "type.googleapis.com/google.rpc.QuotaFailure",
|
|
681
|
+
);
|
|
682
|
+
|
|
683
|
+
if (quotaFailure?.violations && Array.isArray(quotaFailure.violations)) {
|
|
684
|
+
const combined = quotaFailure.violations
|
|
685
|
+
.map((violation: any) => String(violation?.description ?? "").toLowerCase())
|
|
686
|
+
.join(" ");
|
|
687
|
+
if (combined.includes("daily") || combined.includes("per day")) {
|
|
688
|
+
return { terminal: true, retryDelayMs };
|
|
689
|
+
}
|
|
690
|
+
return { terminal: false, retryDelayMs };
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
return { terminal: false, retryDelayMs };
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function wait(ms: number): Promise<void> {
|
|
697
|
+
return new Promise((resolve) => {
|
|
698
|
+
setTimeout(resolve, ms);
|
|
699
|
+
});
|
|
700
|
+
}
|