opencode-gemini-auth 1.3.7 → 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 +53 -9
- package/package.json +2 -2
- package/src/gemini/oauth.ts +2 -0
- package/src/plugin/request.ts +134 -0
- package/src/plugin.ts +201 -1
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/
|
|
18
|
+
(`~/.config/opencode/opencode.json` or similar):
|
|
19
19
|
|
|
20
20
|
```json
|
|
21
21
|
{
|
|
@@ -60,19 +60,41 @@ project. To force a specific project, set the `projectId` in your configuration:
|
|
|
60
60
|
}
|
|
61
61
|
```
|
|
62
62
|
|
|
63
|
-
###
|
|
64
|
-
|
|
65
|
-
Configure "thinking" capabilities for Gemini models using the `thinkingConfig`
|
|
66
|
-
option in your `config.json`.
|
|
63
|
+
### Model list
|
|
67
64
|
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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.
|
|
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": "^1.
|
|
14
|
+
"@opencode-ai/plugin": "^1.1.25",
|
|
15
15
|
"@types/bun": "latest"
|
|
16
16
|
},
|
|
17
17
|
"peerDependencies": {
|
package/src/gemini/oauth.ts
CHANGED
|
@@ -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(),
|
package/src/plugin/request.ts
CHANGED
|
@@ -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
|
@@ -126,7 +126,7 @@ export const GeminiCLIOAuthPlugin = async (
|
|
|
126
126
|
projectId: projectContext.effectiveProjectId,
|
|
127
127
|
});
|
|
128
128
|
|
|
129
|
-
const response = await
|
|
129
|
+
const response = await fetchWithRetry(request, transformedInit);
|
|
130
130
|
return transformGeminiResponse(response, streaming, debugContext, requestedModel);
|
|
131
131
|
},
|
|
132
132
|
};
|
|
@@ -246,6 +246,11 @@ export const GeminiCLIOAuthPlugin = async (
|
|
|
246
246
|
|
|
247
247
|
export const GoogleOAuthPlugin = GeminiCLIOAuthPlugin;
|
|
248
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
|
+
|
|
249
254
|
function toUrlString(value: RequestInfo): string {
|
|
250
255
|
if (typeof value === "string") {
|
|
251
256
|
return value;
|
|
@@ -308,3 +313,198 @@ function openBrowserUrl(url: string): void {
|
|
|
308
313
|
} catch {
|
|
309
314
|
}
|
|
310
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
|
+
}
|