opencode-gemini-auth 1.3.6 → 1.3.8

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
@@ -15,7 +15,7 @@ directly within Opencode, bypassing separate API billing.
15
15
  ## Installation
16
16
 
17
17
  Add the plugin to your Opencode configuration file
18
- (`~/.config/opencode/config.json` or similar):
18
+ (`~/.config/opencode/opencode.json` or similar):
19
19
 
20
20
  ```json
21
21
  {
@@ -37,7 +37,7 @@ Add the plugin to your Opencode configuration file
37
37
  - A browser window will open for you to approve the access.
38
38
  - The plugin spins up a temporary local server to capture the callback.
39
39
  - If the local server fails (e.g., port in use or headless environment),
40
- you can manually copy/paste the callback URL as instructed.
40
+ you can manually paste the callback URL or just the authorization code.
41
41
 
42
42
  Once authenticated, Opencode will use your Google account for Gemini requests.
43
43
 
@@ -60,19 +60,41 @@ project. To force a specific project, set the `projectId` in your configuration:
60
60
  }
61
61
  ```
62
62
 
63
- ### Thinking Models
64
-
65
- Configure "thinking" capabilities for Gemini models using the `thinkingConfig`
66
- option in your `config.json`.
63
+ ### Model list
67
64
 
68
- **Gemini 3 (Thinking Level)**
69
- Use `thinkingLevel` (`"low"`, `"high"`) for Gemini 3 models.
65
+ Below are example model entries you can add under `provider.google.models` in your
66
+ Opencode config. Each model can include an `options.thinkingConfig` block to
67
+ enable "thinking" features.
70
68
 
71
69
  ```json
72
70
  {
73
71
  "provider": {
74
72
  "google": {
75
73
  "models": {
74
+ "gemini-2.5-flash": {
75
+ "options": {
76
+ "thinkingConfig": {
77
+ "thinkingBudget": 8192,
78
+ "includeThoughts": true
79
+ }
80
+ }
81
+ },
82
+ "gemini-2.5-pro": {
83
+ "options": {
84
+ "thinkingConfig": {
85
+ "thinkingBudget": 8192,
86
+ "includeThoughts": true
87
+ }
88
+ }
89
+ },
90
+ "gemini-3-flash-preview": {
91
+ "options": {
92
+ "thinkingConfig": {
93
+ "thinkingLevel": "high",
94
+ "includeThoughts": true
95
+ }
96
+ }
97
+ },
76
98
  "gemini-3-pro-preview": {
77
99
  "options": {
78
100
  "thinkingConfig": {
@@ -87,14 +109,33 @@ Use `thinkingLevel` (`"low"`, `"high"`) for Gemini 3 models.
87
109
  }
88
110
  ```
89
111
 
90
- **Gemini 2.5 (Thinking Budget)**
91
- Use `thinkingBudget` (token count) for Gemini 2.5 models.
112
+ Note: Available model names and previews may change—check Google's documentation or
113
+ the Gemini product page for the current model identifiers.
114
+
115
+ ### Thinking Models
116
+
117
+ The plugin supports configuring Gemini "thinking" features per-model via
118
+ `thinkingConfig`. The available fields depend on the model family:
119
+
120
+ - For Gemini 3 models: use `thinkingLevel` with values `"low"` or `"high"`.
121
+ - For Gemini 2.5 models: use `thinkingBudget` (token count).
122
+ - `includeThoughts` (boolean) controls whether the model emits internal thoughts.
123
+
124
+ A combined example showing both model types:
92
125
 
93
126
  ```json
94
127
  {
95
128
  "provider": {
96
129
  "google": {
97
130
  "models": {
131
+ "gemini-3-pro-preview": {
132
+ "options": {
133
+ "thinkingConfig": {
134
+ "thinkingLevel": "high",
135
+ "includeThoughts": true
136
+ }
137
+ }
138
+ },
98
139
  "gemini-2.5-flash": {
99
140
  "options": {
100
141
  "thinkingConfig": {
@@ -109,6 +150,9 @@ Use `thinkingBudget` (token count) for Gemini 2.5 models.
109
150
  }
110
151
  ```
111
152
 
153
+ If you don't set a `thinkingConfig` for a model, the plugin will use default
154
+ behavior for that model.
155
+
112
156
  ## Troubleshooting
113
157
 
114
158
  ### Manual Google Cloud Setup
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.6",
4
+ "version": "1.3.8",
5
5
  "author": "jenslys",
6
6
  "repository": "https://github.com/jenslys/opencode-gemini-auth",
7
7
  "files": [
@@ -11,7 +11,7 @@
11
11
  "license": "MIT",
12
12
  "type": "module",
13
13
  "devDependencies": {
14
- "@opencode-ai/plugin": "^0.15.31",
14
+ "@opencode-ai/plugin": "^1.1.25",
15
15
  "@types/bun": "latest"
16
16
  },
17
17
  "peerDependencies": {
@@ -90,6 +90,8 @@ export async function authorizeGemini(): Promise<GeminiAuthorization> {
90
90
  url.searchParams.set("state", encodeState({ verifier: pkce.verifier }));
91
91
  url.searchParams.set("access_type", "offline");
92
92
  url.searchParams.set("prompt", "consent");
93
+ // Add a fragment so any stray terminal glyphs are ignored by the auth server.
94
+ url.hash = "opencode";
93
95
 
94
96
  return {
95
97
  url: url.toString(),
@@ -107,53 +109,24 @@ export async function exchangeGemini(
107
109
  try {
108
110
  const { verifier } = decodeState(state);
109
111
 
110
- const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
111
- method: "POST",
112
- headers: {
113
- "Content-Type": "application/x-www-form-urlencoded",
114
- },
115
- body: new URLSearchParams({
116
- client_id: GEMINI_CLIENT_ID,
117
- client_secret: GEMINI_CLIENT_SECRET,
118
- code,
119
- grant_type: "authorization_code",
120
- redirect_uri: GEMINI_REDIRECT_URI,
121
- code_verifier: verifier,
122
- }),
123
- });
124
-
125
- if (!tokenResponse.ok) {
126
- const errorText = await tokenResponse.text();
127
- return { type: "failed", error: errorText };
128
- }
129
-
130
- const tokenPayload = (await tokenResponse.json()) as GeminiTokenResponse;
131
-
132
- const userInfoResponse = await fetch(
133
- "https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
134
- {
135
- headers: {
136
- Authorization: `Bearer ${tokenPayload.access_token}`,
137
- },
138
- },
139
- );
140
-
141
- const userInfo = userInfoResponse.ok
142
- ? ((await userInfoResponse.json()) as GeminiUserInfo)
143
- : {};
144
-
145
- const refreshToken = tokenPayload.refresh_token;
146
- if (!refreshToken) {
147
- return { type: "failed", error: "Missing refresh token in response" };
148
- }
149
-
112
+ return await exchangeGeminiWithVerifierInternal(code, verifier);
113
+ } catch (error) {
150
114
  return {
151
- type: "success",
152
- refresh: refreshToken,
153
- access: tokenPayload.access_token,
154
- expires: Date.now() + tokenPayload.expires_in * 1000,
155
- email: userInfo.email,
115
+ type: "failed",
116
+ error: error instanceof Error ? error.message : "Unknown error",
156
117
  };
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Exchange an authorization code using a known PKCE verifier.
123
+ */
124
+ export async function exchangeGeminiWithVerifier(
125
+ code: string,
126
+ verifier: string,
127
+ ): Promise<GeminiTokenExchangeResult> {
128
+ try {
129
+ return await exchangeGeminiWithVerifierInternal(code, verifier);
157
130
  } catch (error) {
158
131
  return {
159
132
  type: "failed",
@@ -161,3 +134,56 @@ export async function exchangeGemini(
161
134
  };
162
135
  }
163
136
  }
137
+
138
+ async function exchangeGeminiWithVerifierInternal(
139
+ code: string,
140
+ verifier: string,
141
+ ): Promise<GeminiTokenExchangeResult> {
142
+ const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
143
+ method: "POST",
144
+ headers: {
145
+ "Content-Type": "application/x-www-form-urlencoded",
146
+ },
147
+ body: new URLSearchParams({
148
+ client_id: GEMINI_CLIENT_ID,
149
+ client_secret: GEMINI_CLIENT_SECRET,
150
+ code,
151
+ grant_type: "authorization_code",
152
+ redirect_uri: GEMINI_REDIRECT_URI,
153
+ code_verifier: verifier,
154
+ }),
155
+ });
156
+
157
+ if (!tokenResponse.ok) {
158
+ const errorText = await tokenResponse.text();
159
+ return { type: "failed", error: errorText };
160
+ }
161
+
162
+ const tokenPayload = (await tokenResponse.json()) as GeminiTokenResponse;
163
+
164
+ const userInfoResponse = await fetch(
165
+ "https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
166
+ {
167
+ headers: {
168
+ Authorization: `Bearer ${tokenPayload.access_token}`,
169
+ },
170
+ },
171
+ );
172
+
173
+ const userInfo = userInfoResponse.ok
174
+ ? ((await userInfoResponse.json()) as GeminiUserInfo)
175
+ : {};
176
+
177
+ const refreshToken = tokenPayload.refresh_token;
178
+ if (!refreshToken) {
179
+ return { type: "failed", error: "Missing refresh token in response" };
180
+ }
181
+
182
+ return {
183
+ type: "success",
184
+ refresh: refreshToken,
185
+ access: tokenPayload.access_token,
186
+ expires: Date.now() + tokenPayload.expires_in * 1000,
187
+ email: userInfo.email,
188
+ };
189
+ }
@@ -13,6 +13,137 @@ const STREAM_ACTION = "streamGenerateContent";
13
13
  const MODEL_FALLBACKS: Record<string, string> = {
14
14
  "gemini-2.5-flash-image": "gemini-2.5-flash",
15
15
  };
16
+
17
+ interface GeminiFunctionCallPart {
18
+ functionCall?: {
19
+ name: string;
20
+ args?: Record<string, unknown>;
21
+ [key: string]: unknown;
22
+ };
23
+ thoughtSignature?: string;
24
+ [key: string]: unknown;
25
+ }
26
+
27
+ interface GeminiContentPart {
28
+ role?: string;
29
+ parts?: GeminiFunctionCallPart[];
30
+ [key: string]: unknown;
31
+ }
32
+
33
+ interface OpenAIToolCall {
34
+ id?: string;
35
+ type?: string;
36
+ function?: {
37
+ name?: string;
38
+ arguments?: string;
39
+ [key: string]: unknown;
40
+ };
41
+ [key: string]: unknown;
42
+ }
43
+
44
+ interface OpenAIMessage {
45
+ role?: string;
46
+ content?: string | null;
47
+ tool_calls?: OpenAIToolCall[];
48
+ tool_call_id?: string;
49
+ name?: string;
50
+ [key: string]: unknown;
51
+ }
52
+
53
+ /**
54
+ * Transforms OpenAI tool_calls to Gemini functionCall format and adds thoughtSignature.
55
+ * This ensures compatibility when OpenCode sends OpenAI-format function calls.
56
+ */
57
+ function transformOpenAIToolCalls(requestPayload: Record<string, unknown>): void {
58
+ const messages = requestPayload.messages;
59
+ if (!messages || !Array.isArray(messages)) {
60
+ return;
61
+ }
62
+
63
+ for (const message of messages) {
64
+ if (message && typeof message === "object") {
65
+ const msgObj = message as OpenAIMessage;
66
+ const toolCalls = msgObj.tool_calls;
67
+ if (toolCalls && Array.isArray(toolCalls) && toolCalls.length > 0) {
68
+ const parts: GeminiFunctionCallPart[] = [];
69
+
70
+ if (typeof msgObj.content === "string" && msgObj.content.length > 0) {
71
+ parts.push({ text: msgObj.content });
72
+ }
73
+
74
+ for (const toolCall of toolCalls) {
75
+ if (toolCall && typeof toolCall === "object") {
76
+ const functionObj = toolCall.function;
77
+ if (functionObj && typeof functionObj === "object") {
78
+ const name = functionObj.name;
79
+ const argsStr = functionObj.arguments;
80
+ let args: Record<string, unknown> = {};
81
+ if (typeof argsStr === "string") {
82
+ try {
83
+ args = JSON.parse(argsStr) as Record<string, unknown>;
84
+ } catch {
85
+ args = {};
86
+ }
87
+ }
88
+
89
+ parts.push({
90
+ functionCall: {
91
+ name: name ?? "",
92
+ args,
93
+ },
94
+ thoughtSignature: "skip_thought_signature_validator",
95
+ });
96
+ }
97
+ }
98
+ }
99
+
100
+ msgObj.parts = parts;
101
+ delete msgObj.tool_calls;
102
+ delete msgObj.content;
103
+ }
104
+ }
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Adds thoughtSignature to function call parts in the request payload.
110
+ * Gemini 3+ models require thoughtSignature for function calls when using thinking capabilities.
111
+ * This must be applied to all content blocks in the conversation history.
112
+ * Handles both flat contents arrays and nested request.contents (wrapped bodies).
113
+ */
114
+ function addThoughtSignaturesToFunctionCalls(requestPayload: Record<string, unknown>): void {
115
+ const processContents = (contents: unknown): void => {
116
+ if (!contents || !Array.isArray(contents)) {
117
+ return;
118
+ }
119
+
120
+ for (const content of contents) {
121
+ if (content && typeof content === "object") {
122
+ const contentObj = content as Record<string, unknown>;
123
+ const parts = contentObj.parts;
124
+ if (parts && Array.isArray(parts)) {
125
+ for (const part of parts) {
126
+ if (part && typeof part === "object") {
127
+ const partObj = part as Record<string, unknown>;
128
+ if (partObj.functionCall && !partObj.thoughtSignature) {
129
+ partObj.thoughtSignature = "skip_thought_signature_validator";
130
+ }
131
+ }
132
+ }
133
+ }
134
+ }
135
+ }
136
+ };
137
+
138
+ processContents(requestPayload.contents);
139
+
140
+ const nestedRequest = requestPayload.request;
141
+ if (nestedRequest && typeof nestedRequest === "object") {
142
+ const requestObj = nestedRequest as Record<string, unknown>;
143
+ processContents(requestObj.contents);
144
+ }
145
+ }
146
+
16
147
  /**
17
148
  * Detects Gemini/Generative Language API requests by URL.
18
149
  * @param input Request target passed to fetch.
@@ -155,6 +286,9 @@ export function prepareGeminiRequest(
155
286
  } else {
156
287
  const requestPayload: Record<string, unknown> = { ...parsedBody };
157
288
 
289
+ transformOpenAIToolCalls(requestPayload);
290
+ addThoughtSignaturesToFunctionCalls(requestPayload);
291
+
158
292
  const rawGenerationConfig = requestPayload.generationConfig as Record<string, unknown> | undefined;
159
293
  const normalizedThinking = normalizeThinkingConfig(rawGenerationConfig?.thinkingConfig);
160
294
  if (normalizedThinking) {
package/src/plugin.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  import { spawn } from "node:child_process";
2
2
 
3
3
  import { GEMINI_PROVIDER_ID, GEMINI_REDIRECT_URI } from "./constants";
4
- import { authorizeGemini, exchangeGemini } from "./gemini/oauth";
4
+ import {
5
+ authorizeGemini,
6
+ exchangeGemini,
7
+ exchangeGeminiWithVerifier,
8
+ } from "./gemini/oauth";
5
9
  import type { GeminiTokenExchangeResult } from "./gemini/oauth";
6
10
  import { accessTokenExpired, isOAuthAuth } from "./plugin/auth";
7
11
  import { ensureProjectContext } from "./plugin/project";
@@ -122,7 +126,7 @@ export const GeminiCLIOAuthPlugin = async (
122
126
  projectId: projectContext.effectiveProjectId,
123
127
  });
124
128
 
125
- const response = await fetch(request, transformedInit);
129
+ const response = await fetchWithRetry(request, transformedInit);
126
130
  return transformGeminiResponse(response, streaming, debugContext, requestedModel);
127
131
  },
128
132
  };
@@ -146,16 +150,18 @@ export const GeminiCLIOAuthPlugin = async (
146
150
  } catch (error) {
147
151
  if (error instanceof Error) {
148
152
  console.log(
149
- `Warning: Couldn't start the local callback listener (${error.message}). You'll need to paste the callback URL.`,
153
+ `Warning: Couldn't start the local callback listener (${error.message}). You'll need to paste the callback URL or authorization code.`,
150
154
  );
151
155
  } else {
152
156
  console.log(
153
- "Warning: Couldn't start the local callback listener. You'll need to paste the callback URL.",
157
+ "Warning: Couldn't start the local callback listener. You'll need to paste the callback URL or authorization code.",
154
158
  );
155
159
  }
156
160
  }
157
161
  } else {
158
- console.log("Headless environment detected. You'll need to paste the callback URL.");
162
+ console.log(
163
+ "Headless environment detected. You'll need to paste the callback URL or authorization code.",
164
+ );
159
165
  }
160
166
 
161
167
  const authorization = await authorizeGemini();
@@ -201,22 +207,24 @@ export const GeminiCLIOAuthPlugin = async (
201
207
  return {
202
208
  url: authorization.url,
203
209
  instructions:
204
- "Complete OAuth in your browser, then paste the full redirected URL (e.g., http://localhost:8085/oauth2callback?code=...&state=...)",
210
+ "Complete OAuth in your browser, then paste the full redirected URL (e.g., http://localhost:8085/oauth2callback?code=...&state=...) or just the authorization code.",
205
211
  method: "code",
206
212
  callback: async (callbackUrl: string): Promise<GeminiTokenExchangeResult> => {
207
213
  try {
208
- const url = new URL(callbackUrl);
209
- const code = url.searchParams.get("code");
210
- const state = url.searchParams.get("state");
214
+ const { code, state } = parseOAuthCallbackInput(callbackUrl);
211
215
 
212
- if (!code || !state) {
216
+ if (!code) {
213
217
  return {
214
218
  type: "failed",
215
- error: "Missing code or state in callback URL",
219
+ error: "Missing authorization code in callback input",
216
220
  };
217
221
  }
218
222
 
219
- return exchangeGemini(code, state);
223
+ if (state) {
224
+ return exchangeGemini(code, state);
225
+ }
226
+
227
+ return exchangeGeminiWithVerifier(code, authorization.verifier);
220
228
  } catch (error) {
221
229
  return {
222
230
  type: "failed",
@@ -238,6 +246,11 @@ export const GeminiCLIOAuthPlugin = async (
238
246
 
239
247
  export const GoogleOAuthPlugin = GeminiCLIOAuthPlugin;
240
248
 
249
+ const RETRYABLE_STATUS_CODES = new Set([429, 503]);
250
+ const DEFAULT_MAX_RETRIES = 2;
251
+ const DEFAULT_BASE_DELAY_MS = 800;
252
+ const DEFAULT_MAX_DELAY_MS = 8000;
253
+
241
254
  function toUrlString(value: RequestInfo): string {
242
255
  if (typeof value === "string") {
243
256
  return value;
@@ -249,6 +262,37 @@ function toUrlString(value: RequestInfo): string {
249
262
  return value.toString();
250
263
  }
251
264
 
265
+ function parseOAuthCallbackInput(input: string): { code?: string; state?: string } {
266
+ const trimmed = input.trim();
267
+ if (!trimmed) {
268
+ return {};
269
+ }
270
+
271
+ if (/^https?:\/\//i.test(trimmed)) {
272
+ try {
273
+ const url = new URL(trimmed);
274
+ return {
275
+ code: url.searchParams.get("code") || undefined,
276
+ state: url.searchParams.get("state") || undefined,
277
+ };
278
+ } catch {
279
+ return {};
280
+ }
281
+ }
282
+
283
+ const candidate = trimmed.startsWith("?") ? trimmed.slice(1) : trimmed;
284
+ if (candidate.includes("=")) {
285
+ const params = new URLSearchParams(candidate);
286
+ const code = params.get("code") || undefined;
287
+ const state = params.get("state") || undefined;
288
+ if (code || state) {
289
+ return { code, state };
290
+ }
291
+ }
292
+
293
+ return { code: trimmed };
294
+ }
295
+
252
296
  function openBrowserUrl(url: string): void {
253
297
  try {
254
298
  // Best-effort: don't block auth flow if spawning fails.
@@ -269,3 +313,198 @@ function openBrowserUrl(url: string): void {
269
313
  } catch {
270
314
  }
271
315
  }
316
+
317
+ async function fetchWithRetry(input: RequestInfo, init: RequestInit | undefined): Promise<Response> {
318
+ const maxRetries = DEFAULT_MAX_RETRIES;
319
+ const baseDelayMs = DEFAULT_BASE_DELAY_MS;
320
+ const maxDelayMs = DEFAULT_MAX_DELAY_MS;
321
+
322
+ if (!canRetryRequest(init)) {
323
+ return fetch(input, init);
324
+ }
325
+
326
+ let attempt = 0;
327
+ while (true) {
328
+ const response = await fetch(input, init);
329
+ if (!RETRYABLE_STATUS_CODES.has(response.status) || attempt >= maxRetries) {
330
+ return response;
331
+ }
332
+
333
+ const delayMs = await getRetryDelayMs(response, attempt, baseDelayMs, maxDelayMs);
334
+ if (!delayMs || delayMs <= 0) {
335
+ return response;
336
+ }
337
+
338
+ if (init?.signal?.aborted) {
339
+ return response;
340
+ }
341
+
342
+ await wait(delayMs);
343
+ attempt += 1;
344
+ }
345
+ }
346
+
347
+ function canRetryRequest(init: RequestInit | undefined): boolean {
348
+ if (!init?.body) {
349
+ return true;
350
+ }
351
+
352
+ const body = init.body;
353
+ if (typeof body === "string") {
354
+ return true;
355
+ }
356
+ if (typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams) {
357
+ return true;
358
+ }
359
+ if (typeof ArrayBuffer !== "undefined" && body instanceof ArrayBuffer) {
360
+ return true;
361
+ }
362
+ if (typeof ArrayBuffer !== "undefined" && ArrayBuffer.isView(body)) {
363
+ return true;
364
+ }
365
+ if (typeof Blob !== "undefined" && body instanceof Blob) {
366
+ return true;
367
+ }
368
+
369
+ return false;
370
+ }
371
+
372
+ async function getRetryDelayMs(
373
+ response: Response,
374
+ attempt: number,
375
+ baseDelayMs: number,
376
+ maxDelayMs: number,
377
+ ): Promise<number | null> {
378
+ const headerDelayMs = parseRetryAfterMs(response.headers.get("retry-after-ms"));
379
+ if (headerDelayMs !== null) {
380
+ return clampDelay(headerDelayMs, maxDelayMs);
381
+ }
382
+
383
+ const retryAfter = parseRetryAfter(response.headers.get("retry-after"));
384
+ if (retryAfter !== null) {
385
+ return clampDelay(retryAfter, maxDelayMs);
386
+ }
387
+
388
+ const bodyDelayMs = await parseRetryDelayFromBody(response);
389
+ if (bodyDelayMs !== null) {
390
+ return clampDelay(bodyDelayMs, maxDelayMs);
391
+ }
392
+
393
+ const fallback = baseDelayMs * Math.pow(2, attempt);
394
+ return clampDelay(fallback, maxDelayMs);
395
+ }
396
+
397
+ function clampDelay(delayMs: number, maxDelayMs: number): number {
398
+ if (!Number.isFinite(delayMs)) {
399
+ return maxDelayMs;
400
+ }
401
+ return Math.min(Math.max(0, delayMs), maxDelayMs);
402
+ }
403
+
404
+ function parseRetryAfterMs(value: string | null): number | null {
405
+ if (!value) {
406
+ return null;
407
+ }
408
+ const parsed = Number(value);
409
+ if (!Number.isFinite(parsed) || parsed <= 0) {
410
+ return null;
411
+ }
412
+ return parsed;
413
+ }
414
+
415
+ function parseRetryAfter(value: string | null): number | null {
416
+ if (!value) {
417
+ return null;
418
+ }
419
+ const trimmed = value.trim();
420
+ if (!trimmed) {
421
+ return null;
422
+ }
423
+ const asNumber = Number(trimmed);
424
+ if (Number.isFinite(asNumber)) {
425
+ return Math.max(0, Math.round(asNumber * 1000));
426
+ }
427
+ const asDate = Date.parse(trimmed);
428
+ if (!Number.isNaN(asDate)) {
429
+ return Math.max(0, asDate - Date.now());
430
+ }
431
+ return null;
432
+ }
433
+
434
+ async function parseRetryDelayFromBody(response: Response): Promise<number | null> {
435
+ let text = "";
436
+ try {
437
+ text = await response.clone().text();
438
+ } catch {
439
+ return null;
440
+ }
441
+
442
+ if (!text) {
443
+ return null;
444
+ }
445
+
446
+ let parsed: any;
447
+ try {
448
+ parsed = JSON.parse(text);
449
+ } catch {
450
+ return null;
451
+ }
452
+
453
+ const details = parsed?.error?.details;
454
+ if (!Array.isArray(details)) {
455
+ return null;
456
+ }
457
+
458
+ for (const detail of details) {
459
+ if (!detail || typeof detail !== "object") {
460
+ continue;
461
+ }
462
+ const retryDelay = (detail as Record<string, unknown>).retryDelay;
463
+ if (!retryDelay) {
464
+ continue;
465
+ }
466
+ const delayMs = parseRetryDelayValue(retryDelay);
467
+ if (delayMs !== null) {
468
+ return delayMs;
469
+ }
470
+ }
471
+
472
+ return null;
473
+ }
474
+
475
+ function parseRetryDelayValue(value: unknown): number | null {
476
+ if (!value) {
477
+ return null;
478
+ }
479
+
480
+ if (typeof value === "string") {
481
+ const match = value.match(/^([\d.]+)s$/);
482
+ if (!match || !match[1]) {
483
+ return null;
484
+ }
485
+ const seconds = Number(match[1]);
486
+ if (!Number.isFinite(seconds) || seconds <= 0) {
487
+ return null;
488
+ }
489
+ return Math.round(seconds * 1000);
490
+ }
491
+
492
+ if (typeof value === "object") {
493
+ const record = value as Record<string, unknown>;
494
+ const seconds = typeof record.seconds === "number" ? record.seconds : 0;
495
+ const nanos = typeof record.nanos === "number" ? record.nanos : 0;
496
+ if (!Number.isFinite(seconds) || !Number.isFinite(nanos)) {
497
+ return null;
498
+ }
499
+ const totalMs = Math.round(seconds * 1000 + nanos / 1e6);
500
+ return totalMs > 0 ? totalMs : null;
501
+ }
502
+
503
+ return null;
504
+ }
505
+
506
+ function wait(ms: number): Promise<void> {
507
+ return new Promise((resolve) => {
508
+ setTimeout(resolve, ms);
509
+ });
510
+ }