antigravity-claude-proxy 1.0.0

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/src/server.js ADDED
@@ -0,0 +1,517 @@
1
+ /**
2
+ * Express Server - Anthropic-compatible API
3
+ * Proxies to Google Cloud Code via Antigravity
4
+ * Supports multi-account load balancing
5
+ */
6
+
7
+ import express from 'express';
8
+ import cors from 'cors';
9
+ import { sendMessage, sendMessageStream, listModels, getModelQuotas } from './cloudcode-client.js';
10
+ import { forceRefresh } from './token-extractor.js';
11
+ import { REQUEST_BODY_LIMIT } from './constants.js';
12
+ import { AccountManager } from './account-manager.js';
13
+ import { formatDuration } from './utils/helpers.js';
14
+
15
+ const app = express();
16
+
17
+ // Initialize account manager (will be fully initialized on first request or startup)
18
+ const accountManager = new AccountManager();
19
+
20
+ // Track initialization status
21
+ let isInitialized = false;
22
+ let initError = null;
23
+ let initPromise = null;
24
+
25
+ /**
26
+ * Ensure account manager is initialized (with race condition protection)
27
+ */
28
+ async function ensureInitialized() {
29
+ if (isInitialized) return;
30
+
31
+ // If initialization is already in progress, wait for it
32
+ if (initPromise) return initPromise;
33
+
34
+ initPromise = (async () => {
35
+ try {
36
+ await accountManager.initialize();
37
+ isInitialized = true;
38
+ const status = accountManager.getStatus();
39
+ console.log(`[Server] Account pool initialized: ${status.summary}`);
40
+ } catch (error) {
41
+ initError = error;
42
+ initPromise = null; // Allow retry on failure
43
+ console.error('[Server] Failed to initialize account manager:', error.message);
44
+ throw error;
45
+ }
46
+ })();
47
+
48
+ return initPromise;
49
+ }
50
+
51
+ // Middleware
52
+ app.use(cors());
53
+ app.use(express.json({ limit: REQUEST_BODY_LIMIT }));
54
+
55
+ /**
56
+ * Parse error message to extract error type, status code, and user-friendly message
57
+ */
58
+ function parseError(error) {
59
+ let errorType = 'api_error';
60
+ let statusCode = 500;
61
+ let errorMessage = error.message;
62
+
63
+ if (error.message.includes('401') || error.message.includes('UNAUTHENTICATED')) {
64
+ errorType = 'authentication_error';
65
+ statusCode = 401;
66
+ errorMessage = 'Authentication failed. Make sure Antigravity is running with a valid token.';
67
+ } else if (error.message.includes('429') || error.message.includes('RESOURCE_EXHAUSTED') || error.message.includes('QUOTA_EXHAUSTED')) {
68
+ errorType = 'invalid_request_error'; // Use invalid_request_error to force client to purge/stop
69
+ statusCode = 400; // Use 400 to ensure client does not retry (429 and 529 trigger retries)
70
+
71
+ // Try to extract the quota reset time from the error
72
+ const resetMatch = error.message.match(/quota will reset after (\d+h\d+m\d+s|\d+m\d+s|\d+s)/i);
73
+ const modelMatch = error.message.match(/"model":\s*"([^"]+)"/);
74
+ const model = modelMatch ? modelMatch[1] : 'the model';
75
+
76
+ if (resetMatch) {
77
+ errorMessage = `You have exhausted your capacity on ${model}. Quota will reset after ${resetMatch[1]}.`;
78
+ } else {
79
+ errorMessage = `You have exhausted your capacity on ${model}. Please wait for your quota to reset.`;
80
+ }
81
+ } else if (error.message.includes('invalid_request_error') || error.message.includes('INVALID_ARGUMENT')) {
82
+ errorType = 'invalid_request_error';
83
+ statusCode = 400;
84
+ const msgMatch = error.message.match(/"message":"([^"]+)"/);
85
+ if (msgMatch) errorMessage = msgMatch[1];
86
+ } else if (error.message.includes('All endpoints failed')) {
87
+ errorType = 'api_error';
88
+ statusCode = 503;
89
+ errorMessage = 'Unable to connect to Claude API. Check that Antigravity is running.';
90
+ } else if (error.message.includes('PERMISSION_DENIED')) {
91
+ errorType = 'permission_error';
92
+ statusCode = 403;
93
+ errorMessage = 'Permission denied. Check your Antigravity license.';
94
+ }
95
+
96
+ return { errorType, statusCode, errorMessage };
97
+ }
98
+
99
+ // Request logging middleware
100
+ app.use((req, res, next) => {
101
+ console.log(`[${new Date().toISOString()}] ${req.method} ${req.path}`);
102
+ next();
103
+ });
104
+
105
+ /**
106
+ * Health check endpoint
107
+ */
108
+ app.get('/health', async (req, res) => {
109
+ try {
110
+ await ensureInitialized();
111
+ const status = accountManager.getStatus();
112
+
113
+ res.json({
114
+ status: 'ok',
115
+ accounts: status.summary,
116
+ available: status.available,
117
+ rateLimited: status.rateLimited,
118
+ invalid: status.invalid,
119
+ timestamp: new Date().toISOString()
120
+ });
121
+ } catch (error) {
122
+ res.status(503).json({
123
+ status: 'error',
124
+ error: error.message,
125
+ timestamp: new Date().toISOString()
126
+ });
127
+ }
128
+ });
129
+
130
+ /**
131
+ * Account limits endpoint - fetch quota/limits for all accounts × all models
132
+ * Returns a table showing remaining quota and reset time for each combination
133
+ * Use ?format=table for ASCII table output, default is JSON
134
+ */
135
+ app.get('/account-limits', async (req, res) => {
136
+ try {
137
+ await ensureInitialized();
138
+ const allAccounts = accountManager.getAllAccounts();
139
+ const format = req.query.format || 'json';
140
+
141
+ // Fetch quotas for each account in parallel
142
+ const results = await Promise.allSettled(
143
+ allAccounts.map(async (account) => {
144
+ // Skip invalid accounts
145
+ if (account.isInvalid) {
146
+ return {
147
+ email: account.email,
148
+ status: 'invalid',
149
+ error: account.invalidReason,
150
+ models: {}
151
+ };
152
+ }
153
+
154
+ try {
155
+ const token = await accountManager.getTokenForAccount(account);
156
+ const quotas = await getModelQuotas(token);
157
+
158
+ return {
159
+ email: account.email,
160
+ status: 'ok',
161
+ models: quotas
162
+ };
163
+ } catch (error) {
164
+ return {
165
+ email: account.email,
166
+ status: 'error',
167
+ error: error.message,
168
+ models: {}
169
+ };
170
+ }
171
+ })
172
+ );
173
+
174
+ // Process results
175
+ const accountLimits = results.map((result, index) => {
176
+ if (result.status === 'fulfilled') {
177
+ return result.value;
178
+ } else {
179
+ return {
180
+ email: allAccounts[index].email,
181
+ status: 'error',
182
+ error: result.reason?.message || 'Unknown error',
183
+ models: {}
184
+ };
185
+ }
186
+ });
187
+
188
+ // Collect all unique model IDs
189
+ const allModelIds = new Set();
190
+ for (const account of accountLimits) {
191
+ for (const modelId of Object.keys(account.models || {})) {
192
+ allModelIds.add(modelId);
193
+ }
194
+ }
195
+
196
+ const sortedModels = Array.from(allModelIds).filter(m => m.includes('claude')).sort();
197
+
198
+ // Return ASCII table format
199
+ if (format === 'table') {
200
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
201
+
202
+ // Build table
203
+ const lines = [];
204
+ const timestamp = new Date().toLocaleString();
205
+ lines.push(`Account Limits (${timestamp})`);
206
+
207
+ // Get account status info
208
+ const status = accountManager.getStatus();
209
+ lines.push(`Accounts: ${status.total} total, ${status.available} available, ${status.rateLimited} rate-limited, ${status.invalid} invalid`);
210
+ lines.push('');
211
+
212
+ // Table 1: Account status
213
+ const accColWidth = 25;
214
+ const statusColWidth = 15;
215
+ const lastUsedColWidth = 25;
216
+ const resetColWidth = 25;
217
+
218
+ let accHeader = 'Account'.padEnd(accColWidth) + 'Status'.padEnd(statusColWidth) + 'Last Used'.padEnd(lastUsedColWidth) + 'Quota Reset';
219
+ lines.push(accHeader);
220
+ lines.push('─'.repeat(accColWidth + statusColWidth + lastUsedColWidth + resetColWidth));
221
+
222
+ for (const acc of status.accounts) {
223
+ const shortEmail = acc.email.split('@')[0].slice(0, 22);
224
+ const lastUsed = acc.lastUsed ? new Date(acc.lastUsed).toLocaleString() : 'never';
225
+
226
+ // Get status and error from accountLimits
227
+ const accLimit = accountLimits.find(a => a.email === acc.email);
228
+ let accStatus;
229
+ if (acc.isInvalid) {
230
+ accStatus = 'invalid';
231
+ } else if (acc.isRateLimited) {
232
+ const remaining = acc.rateLimitResetTime ? acc.rateLimitResetTime - Date.now() : 0;
233
+ accStatus = remaining > 0 ? `limited (${formatDuration(remaining)})` : 'rate-limited';
234
+ } else {
235
+ accStatus = accLimit?.status || 'ok';
236
+ }
237
+
238
+ // Get reset time from quota API
239
+ const claudeModel = sortedModels.find(m => m.includes('claude'));
240
+ const quota = claudeModel && accLimit?.models?.[claudeModel];
241
+ const resetTime = quota?.resetTime
242
+ ? new Date(quota.resetTime).toLocaleString()
243
+ : '-';
244
+
245
+ let row = shortEmail.padEnd(accColWidth) + accStatus.padEnd(statusColWidth) + lastUsed.padEnd(lastUsedColWidth) + resetTime;
246
+
247
+ // Add error on next line if present
248
+ if (accLimit?.error) {
249
+ lines.push(row);
250
+ lines.push(' └─ ' + accLimit.error);
251
+ } else {
252
+ lines.push(row);
253
+ }
254
+ }
255
+ lines.push('');
256
+
257
+ // Calculate column widths
258
+ const modelColWidth = Math.max(25, ...sortedModels.map(m => m.length)) + 2;
259
+ const accountColWidth = 22;
260
+
261
+ // Header row
262
+ let header = 'Model'.padEnd(modelColWidth);
263
+ for (const acc of accountLimits) {
264
+ const shortEmail = acc.email.split('@')[0].slice(0, 18);
265
+ header += shortEmail.padEnd(accountColWidth);
266
+ }
267
+ lines.push(header);
268
+ lines.push('─'.repeat(modelColWidth + accountLimits.length * accountColWidth));
269
+
270
+ // Data rows
271
+ for (const modelId of sortedModels) {
272
+ let row = modelId.padEnd(modelColWidth);
273
+ for (const acc of accountLimits) {
274
+ const quota = acc.models?.[modelId];
275
+ let cell;
276
+ if (acc.status !== 'ok') {
277
+ cell = `[${acc.status}]`;
278
+ } else if (!quota) {
279
+ cell = '-';
280
+ } else if (quota.remainingFraction === null) {
281
+ cell = '0% (exhausted)';
282
+ } else {
283
+ const pct = Math.round(quota.remainingFraction * 100);
284
+ cell = `${pct}%`;
285
+ }
286
+ row += cell.padEnd(accountColWidth);
287
+ }
288
+ lines.push(row);
289
+ }
290
+
291
+ return res.send(lines.join('\n'));
292
+ }
293
+
294
+ // Default: JSON format
295
+ res.json({
296
+ timestamp: new Date().toLocaleString(),
297
+ totalAccounts: allAccounts.length,
298
+ models: sortedModels,
299
+ accounts: accountLimits.map(acc => ({
300
+ email: acc.email,
301
+ status: acc.status,
302
+ error: acc.error || null,
303
+ limits: Object.fromEntries(
304
+ sortedModels.map(modelId => {
305
+ const quota = acc.models?.[modelId];
306
+ if (!quota) {
307
+ return [modelId, null];
308
+ }
309
+ return [modelId, {
310
+ remaining: quota.remainingFraction !== null
311
+ ? `${Math.round(quota.remainingFraction * 100)}%`
312
+ : 'N/A',
313
+ remainingFraction: quota.remainingFraction,
314
+ resetTime: quota.resetTime || null
315
+ }];
316
+ })
317
+ )
318
+ }))
319
+ });
320
+ } catch (error) {
321
+ res.status(500).json({
322
+ status: 'error',
323
+ error: error.message
324
+ });
325
+ }
326
+ });
327
+
328
+ /**
329
+ * Force token refresh endpoint
330
+ */
331
+ app.post('/refresh-token', async (req, res) => {
332
+ try {
333
+ await ensureInitialized();
334
+ // Clear all caches
335
+ accountManager.clearTokenCache();
336
+ accountManager.clearProjectCache();
337
+ // Force refresh default token
338
+ const token = await forceRefresh();
339
+ res.json({
340
+ status: 'ok',
341
+ message: 'Token caches cleared and refreshed',
342
+ tokenPrefix: token.substring(0, 10) + '...'
343
+ });
344
+ } catch (error) {
345
+ res.status(500).json({
346
+ status: 'error',
347
+ error: error.message
348
+ });
349
+ }
350
+ });
351
+
352
+ /**
353
+ * List models endpoint (OpenAI-compatible format)
354
+ */
355
+ app.get('/v1/models', (req, res) => {
356
+ res.json(listModels());
357
+ });
358
+
359
+ /**
360
+ * Main messages endpoint - Anthropic Messages API compatible
361
+ */
362
+ app.post('/v1/messages', async (req, res) => {
363
+ try {
364
+ // Ensure account manager is initialized
365
+ await ensureInitialized();
366
+
367
+ // Optimistic Retry: If ALL accounts are rate-limited, reset them to force a fresh check.
368
+ // If we have some available accounts, we try them first.
369
+ if (accountManager.isAllRateLimited()) {
370
+ console.log('[Server] All accounts rate-limited. Resetting state for optimistic retry.');
371
+ accountManager.resetAllRateLimits();
372
+ }
373
+
374
+ const {
375
+ model,
376
+ messages,
377
+ max_tokens,
378
+ stream,
379
+ system,
380
+ tools,
381
+ tool_choice,
382
+ thinking,
383
+ top_p,
384
+ top_k,
385
+ temperature
386
+ } = req.body;
387
+
388
+ // Validate required fields
389
+ if (!messages || !Array.isArray(messages)) {
390
+ return res.status(400).json({
391
+ type: 'error',
392
+ error: {
393
+ type: 'invalid_request_error',
394
+ message: 'messages is required and must be an array'
395
+ }
396
+ });
397
+ }
398
+
399
+ // Build the request object
400
+ const request = {
401
+ model: model || 'claude-3-5-sonnet-20241022',
402
+ messages,
403
+ max_tokens: max_tokens || 4096,
404
+ stream,
405
+ system,
406
+ tools,
407
+ tool_choice,
408
+ thinking,
409
+ top_p,
410
+ top_k,
411
+ temperature
412
+ };
413
+
414
+ console.log(`[API] Request for model: ${request.model}, stream: ${!!stream}`);
415
+
416
+ // Debug: Log message structure to diagnose tool_use/tool_result ordering
417
+ if (process.env.DEBUG) {
418
+ console.log('[API] Message structure:');
419
+ messages.forEach((msg, i) => {
420
+ const contentTypes = Array.isArray(msg.content)
421
+ ? msg.content.map(c => c.type || 'text').join(', ')
422
+ : (typeof msg.content === 'string' ? 'text' : 'unknown');
423
+ console.log(` [${i}] ${msg.role}: ${contentTypes}`);
424
+ });
425
+ }
426
+
427
+ if (stream) {
428
+ // Handle streaming response
429
+ res.setHeader('Content-Type', 'text/event-stream');
430
+ res.setHeader('Cache-Control', 'no-cache');
431
+ res.setHeader('Connection', 'keep-alive');
432
+ res.setHeader('X-Accel-Buffering', 'no');
433
+
434
+ // Flush headers immediately to start the stream
435
+ res.flushHeaders();
436
+
437
+ try {
438
+ // Use the streaming generator with account manager
439
+ for await (const event of sendMessageStream(request, accountManager)) {
440
+ res.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
441
+ // Flush after each event for real-time streaming
442
+ if (res.flush) res.flush();
443
+ }
444
+ res.end();
445
+
446
+ } catch (streamError) {
447
+ console.error('[API] Stream error:', streamError);
448
+
449
+ const { errorType, errorMessage } = parseError(streamError);
450
+
451
+ res.write(`event: error\ndata: ${JSON.stringify({
452
+ type: 'error',
453
+ error: { type: errorType, message: errorMessage }
454
+ })}\n\n`);
455
+ res.end();
456
+ }
457
+
458
+ } else {
459
+ // Handle non-streaming response
460
+ const response = await sendMessage(request, accountManager);
461
+ res.json(response);
462
+ }
463
+
464
+ } catch (error) {
465
+ console.error('[API] Error:', error);
466
+
467
+ let { errorType, statusCode, errorMessage } = parseError(error);
468
+
469
+ // For auth errors, try to refresh token
470
+ if (errorType === 'authentication_error') {
471
+ console.log('[API] Token might be expired, attempting refresh...');
472
+ try {
473
+ accountManager.clearProjectCache();
474
+ accountManager.clearTokenCache();
475
+ await forceRefresh();
476
+ errorMessage = 'Token was expired and has been refreshed. Please retry your request.';
477
+ } catch (refreshError) {
478
+ errorMessage = 'Could not refresh token. Make sure Antigravity is running.';
479
+ }
480
+ }
481
+
482
+ console.log(`[API] Returning error response: ${statusCode} ${errorType} - ${errorMessage}`);
483
+
484
+ // Check if headers have already been sent (for streaming that failed mid-way)
485
+ if (res.headersSent) {
486
+ console.log('[API] Headers already sent, writing error as SSE event');
487
+ res.write(`event: error\ndata: ${JSON.stringify({
488
+ type: 'error',
489
+ error: { type: errorType, message: errorMessage }
490
+ })}\n\n`);
491
+ res.end();
492
+ } else {
493
+ res.status(statusCode).json({
494
+ type: 'error',
495
+ error: {
496
+ type: errorType,
497
+ message: errorMessage
498
+ }
499
+ });
500
+ }
501
+ }
502
+ });
503
+
504
+ /**
505
+ * Catch-all for unsupported endpoints
506
+ */
507
+ app.use('*', (req, res) => {
508
+ res.status(404).json({
509
+ type: 'error',
510
+ error: {
511
+ type: 'not_found_error',
512
+ message: `Endpoint ${req.method} ${req.originalUrl} not found`
513
+ }
514
+ });
515
+ });
516
+
517
+ export default app;
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Token Extractor Module
3
+ * Extracts OAuth tokens from Antigravity's SQLite database
4
+ *
5
+ * The database is automatically updated by Antigravity when tokens refresh,
6
+ * so this approach doesn't require any manual intervention.
7
+ */
8
+
9
+ import { execSync } from 'child_process';
10
+ import {
11
+ TOKEN_REFRESH_INTERVAL_MS,
12
+ ANTIGRAVITY_AUTH_PORT,
13
+ ANTIGRAVITY_DB_PATH
14
+ } from './constants.js';
15
+
16
+ // Cache for the extracted token
17
+ let cachedToken = null;
18
+ let tokenExtractedAt = null;
19
+
20
+ /**
21
+ * Extract token from Antigravity's SQLite database
22
+ * This is the preferred method as the DB is auto-updated
23
+ */
24
+ function extractTokenFromDB() {
25
+ try {
26
+ const result = execSync(
27
+ `sqlite3 "${ANTIGRAVITY_DB_PATH}" "SELECT value FROM ItemTable WHERE key = 'antigravityAuthStatus';"`,
28
+ { encoding: 'utf-8', timeout: 5000 }
29
+ );
30
+
31
+ if (!result || !result.trim()) {
32
+ throw new Error('No auth status found in database');
33
+ }
34
+
35
+ const authData = JSON.parse(result.trim());
36
+ return {
37
+ apiKey: authData.apiKey,
38
+ name: authData.name,
39
+ email: authData.email,
40
+ // Include other fields we might need
41
+ ...authData
42
+ };
43
+ } catch (error) {
44
+ console.error('[Token] Database extraction failed:', error.message);
45
+ throw error;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Extract the chat params from Antigravity's HTML page (fallback method)
51
+ */
52
+ async function extractChatParams() {
53
+ try {
54
+ const response = await fetch(`http://127.0.0.1:${ANTIGRAVITY_AUTH_PORT}/`);
55
+ const html = await response.text();
56
+
57
+ // Find the base64-encoded chatParams in the HTML
58
+ const match = html.match(/window\.chatParams\s*=\s*'([^']+)'/);
59
+ if (!match) {
60
+ throw new Error('Could not find chatParams in Antigravity page');
61
+ }
62
+
63
+ // Decode base64
64
+ const base64Data = match[1];
65
+ const jsonString = Buffer.from(base64Data, 'base64').toString('utf-8');
66
+ const config = JSON.parse(jsonString);
67
+
68
+ return config;
69
+ } catch (error) {
70
+ if (error.code === 'ECONNREFUSED') {
71
+ throw new Error(
72
+ `Cannot connect to Antigravity on port ${ANTIGRAVITY_AUTH_PORT}. ` +
73
+ 'Make sure Antigravity is running.'
74
+ );
75
+ }
76
+ throw error;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Get fresh token data - tries DB first, falls back to HTML page
82
+ */
83
+ async function getTokenData() {
84
+ // Try database first (preferred - always has fresh token)
85
+ try {
86
+ const dbData = extractTokenFromDB();
87
+ if (dbData?.apiKey) {
88
+ console.log('[Token] Got fresh token from SQLite database');
89
+ return dbData;
90
+ }
91
+ } catch (err) {
92
+ console.log('[Token] DB extraction failed, trying HTML page...');
93
+ }
94
+
95
+ // Fallback to HTML page
96
+ try {
97
+ const pageData = await extractChatParams();
98
+ if (pageData?.apiKey) {
99
+ console.log('[Token] Got token from HTML page (may be stale)');
100
+ return pageData;
101
+ }
102
+ } catch (err) {
103
+ console.log('[Token] HTML page extraction failed:', err.message);
104
+ }
105
+
106
+ throw new Error(
107
+ 'Could not extract token from Antigravity. ' +
108
+ 'Make sure Antigravity is running and you are logged in.'
109
+ );
110
+ }
111
+
112
+ /**
113
+ * Check if the cached token needs refresh
114
+ */
115
+ function needsRefresh() {
116
+ if (!cachedToken || !tokenExtractedAt) {
117
+ return true;
118
+ }
119
+ return Date.now() - tokenExtractedAt > TOKEN_REFRESH_INTERVAL_MS;
120
+ }
121
+
122
+ /**
123
+ * Get the current OAuth token (with caching)
124
+ */
125
+ export async function getToken() {
126
+ if (needsRefresh()) {
127
+ const data = await getTokenData();
128
+ cachedToken = data.apiKey;
129
+ tokenExtractedAt = Date.now();
130
+ }
131
+ return cachedToken;
132
+ }
133
+
134
+ /**
135
+ * Force refresh the token (useful if requests start failing)
136
+ */
137
+ export async function forceRefresh() {
138
+ cachedToken = null;
139
+ tokenExtractedAt = null;
140
+ return getToken();
141
+ }
142
+
143
+ export default {
144
+ getToken,
145
+ forceRefresh
146
+ };
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Shared Utility Functions
3
+ *
4
+ * General-purpose helper functions used across multiple modules.
5
+ */
6
+
7
+ /**
8
+ * Format duration in milliseconds to human-readable string
9
+ * @param {number} ms - Duration in milliseconds
10
+ * @returns {string} Human-readable duration (e.g., "1h23m45s")
11
+ */
12
+ export function formatDuration(ms) {
13
+ const seconds = Math.floor(ms / 1000);
14
+ const hours = Math.floor(seconds / 3600);
15
+ const minutes = Math.floor((seconds % 3600) / 60);
16
+ const secs = seconds % 60;
17
+
18
+ if (hours > 0) {
19
+ return `${hours}h${minutes}m${secs}s`;
20
+ } else if (minutes > 0) {
21
+ return `${minutes}m${secs}s`;
22
+ }
23
+ return `${secs}s`;
24
+ }
25
+
26
+ /**
27
+ * Sleep for specified milliseconds
28
+ * @param {number} ms - Duration to sleep in milliseconds
29
+ * @returns {Promise<void>} Resolves after the specified duration
30
+ */
31
+ export function sleep(ms) {
32
+ return new Promise(resolve => setTimeout(resolve, ms));
33
+ }