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/LICENSE +21 -0
- package/README.md +289 -0
- package/bin/cli.js +109 -0
- package/package.json +54 -0
- package/src/account-manager.js +633 -0
- package/src/accounts-cli.js +437 -0
- package/src/cloudcode-client.js +1018 -0
- package/src/constants.js +164 -0
- package/src/errors.js +159 -0
- package/src/format-converter.js +731 -0
- package/src/index.js +40 -0
- package/src/oauth.js +346 -0
- package/src/server.js +517 -0
- package/src/token-extractor.js +146 -0
- package/src/utils/helpers.js +33 -0
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
|
+
}
|