commons-proxy 2.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.
Files changed (99) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +757 -0
  3. package/bin/cli.js +146 -0
  4. package/package.json +97 -0
  5. package/public/Complaint Details.pdf +0 -0
  6. package/public/Cyber Crime Portal.pdf +0 -0
  7. package/public/app.js +229 -0
  8. package/public/css/src/input.css +523 -0
  9. package/public/css/style.css +1 -0
  10. package/public/favicon.png +0 -0
  11. package/public/index.html +549 -0
  12. package/public/js/components/account-manager.js +356 -0
  13. package/public/js/components/add-account-modal.js +414 -0
  14. package/public/js/components/claude-config.js +420 -0
  15. package/public/js/components/dashboard/charts.js +605 -0
  16. package/public/js/components/dashboard/filters.js +362 -0
  17. package/public/js/components/dashboard/stats.js +110 -0
  18. package/public/js/components/dashboard.js +236 -0
  19. package/public/js/components/logs-viewer.js +100 -0
  20. package/public/js/components/models.js +36 -0
  21. package/public/js/components/server-config.js +349 -0
  22. package/public/js/config/constants.js +102 -0
  23. package/public/js/data-store.js +375 -0
  24. package/public/js/settings-store.js +58 -0
  25. package/public/js/store.js +99 -0
  26. package/public/js/translations/en.js +367 -0
  27. package/public/js/translations/id.js +412 -0
  28. package/public/js/translations/pt.js +308 -0
  29. package/public/js/translations/tr.js +358 -0
  30. package/public/js/translations/zh.js +373 -0
  31. package/public/js/utils/account-actions.js +189 -0
  32. package/public/js/utils/error-handler.js +96 -0
  33. package/public/js/utils/model-config.js +42 -0
  34. package/public/js/utils/ui-logger.js +143 -0
  35. package/public/js/utils/validators.js +77 -0
  36. package/public/js/utils.js +69 -0
  37. package/public/proxy-server-64.png +0 -0
  38. package/public/views/accounts.html +361 -0
  39. package/public/views/dashboard.html +484 -0
  40. package/public/views/logs.html +97 -0
  41. package/public/views/models.html +331 -0
  42. package/public/views/settings.html +1327 -0
  43. package/src/account-manager/credentials.js +378 -0
  44. package/src/account-manager/index.js +462 -0
  45. package/src/account-manager/onboarding.js +112 -0
  46. package/src/account-manager/rate-limits.js +369 -0
  47. package/src/account-manager/storage.js +160 -0
  48. package/src/account-manager/strategies/base-strategy.js +109 -0
  49. package/src/account-manager/strategies/hybrid-strategy.js +339 -0
  50. package/src/account-manager/strategies/index.js +79 -0
  51. package/src/account-manager/strategies/round-robin-strategy.js +76 -0
  52. package/src/account-manager/strategies/sticky-strategy.js +138 -0
  53. package/src/account-manager/strategies/trackers/health-tracker.js +162 -0
  54. package/src/account-manager/strategies/trackers/index.js +9 -0
  55. package/src/account-manager/strategies/trackers/quota-tracker.js +120 -0
  56. package/src/account-manager/strategies/trackers/token-bucket-tracker.js +155 -0
  57. package/src/auth/database.js +169 -0
  58. package/src/auth/oauth.js +548 -0
  59. package/src/auth/token-extractor.js +117 -0
  60. package/src/cli/accounts.js +648 -0
  61. package/src/cloudcode/index.js +29 -0
  62. package/src/cloudcode/message-handler.js +510 -0
  63. package/src/cloudcode/model-api.js +248 -0
  64. package/src/cloudcode/rate-limit-parser.js +235 -0
  65. package/src/cloudcode/request-builder.js +93 -0
  66. package/src/cloudcode/session-manager.js +47 -0
  67. package/src/cloudcode/sse-parser.js +121 -0
  68. package/src/cloudcode/sse-streamer.js +293 -0
  69. package/src/cloudcode/streaming-handler.js +615 -0
  70. package/src/config.js +125 -0
  71. package/src/constants.js +407 -0
  72. package/src/errors.js +242 -0
  73. package/src/fallback-config.js +29 -0
  74. package/src/format/content-converter.js +193 -0
  75. package/src/format/index.js +20 -0
  76. package/src/format/request-converter.js +255 -0
  77. package/src/format/response-converter.js +120 -0
  78. package/src/format/schema-sanitizer.js +673 -0
  79. package/src/format/signature-cache.js +88 -0
  80. package/src/format/thinking-utils.js +648 -0
  81. package/src/index.js +148 -0
  82. package/src/modules/usage-stats.js +205 -0
  83. package/src/providers/anthropic-provider.js +258 -0
  84. package/src/providers/base-provider.js +157 -0
  85. package/src/providers/cloudcode.js +94 -0
  86. package/src/providers/copilot.js +399 -0
  87. package/src/providers/github-provider.js +287 -0
  88. package/src/providers/google-provider.js +192 -0
  89. package/src/providers/index.js +211 -0
  90. package/src/providers/openai-compatible.js +265 -0
  91. package/src/providers/openai-provider.js +271 -0
  92. package/src/providers/openrouter-provider.js +325 -0
  93. package/src/providers/setup.js +83 -0
  94. package/src/server.js +870 -0
  95. package/src/utils/claude-config.js +245 -0
  96. package/src/utils/helpers.js +51 -0
  97. package/src/utils/logger.js +142 -0
  98. package/src/utils/native-module-helper.js +162 -0
  99. package/src/webui/index.js +1134 -0
package/src/server.js ADDED
@@ -0,0 +1,870 @@
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 path from 'path';
10
+ import { fileURLToPath } from 'url';
11
+ import { sendMessage, sendMessageStream, listModels, getModelQuotas, getSubscriptionTier } from './cloudcode/index.js';
12
+ import { mountWebUI } from './webui/index.js';
13
+ import { config } from './config.js';
14
+
15
+ const __filename = fileURLToPath(import.meta.url);
16
+ const __dirname = path.dirname(__filename);
17
+ import { forceRefresh } from './auth/token-extractor.js';
18
+ import { REQUEST_BODY_LIMIT } from './constants.js';
19
+ import { AccountManager } from './account-manager/index.js';
20
+ import { clearThinkingSignatureCache } from './format/signature-cache.js';
21
+ import { formatDuration } from './utils/helpers.js';
22
+ import { logger } from './utils/logger.js';
23
+ import usageStats from './modules/usage-stats.js';
24
+
25
+ // Parse fallback flag directly from command line args to avoid circular dependency
26
+ const args = process.argv.slice(2);
27
+ const FALLBACK_ENABLED = args.includes('--fallback') || process.env.FALLBACK === 'true';
28
+
29
+ // Parse --strategy flag (format: --strategy=sticky or --strategy sticky)
30
+ let STRATEGY_OVERRIDE = null;
31
+ for (let i = 0; i < args.length; i++) {
32
+ if (args[i].startsWith('--strategy=')) {
33
+ STRATEGY_OVERRIDE = args[i].split('=')[1];
34
+ } else if (args[i] === '--strategy' && args[i + 1]) {
35
+ STRATEGY_OVERRIDE = args[i + 1];
36
+ }
37
+ }
38
+
39
+ const app = express();
40
+
41
+ // Disable x-powered-by header for security
42
+ app.disable('x-powered-by');
43
+
44
+ // Initialize account manager (will be fully initialized on first request or startup)
45
+ export const accountManager = new AccountManager();
46
+
47
+ // Track initialization status
48
+ let isInitialized = false;
49
+ let initError = null;
50
+ let initPromise = null;
51
+
52
+ /**
53
+ * Ensure account manager is initialized (with race condition protection)
54
+ */
55
+ async function ensureInitialized() {
56
+ if (isInitialized) return;
57
+
58
+ // If initialization is already in progress, wait for it
59
+ if (initPromise) return initPromise;
60
+
61
+ initPromise = (async () => {
62
+ try {
63
+ await accountManager.initialize(STRATEGY_OVERRIDE);
64
+ isInitialized = true;
65
+ const status = accountManager.getStatus();
66
+ logger.success(`[Server] Account pool initialized: ${status.summary}`);
67
+ } catch (error) {
68
+ initError = error;
69
+ initPromise = null; // Allow retry on failure
70
+ logger.error('[Server] Failed to initialize account manager:', error.message);
71
+ throw error;
72
+ }
73
+ })();
74
+
75
+ return initPromise;
76
+ }
77
+
78
+ // Middleware
79
+ app.use(cors());
80
+ app.use(express.json({ limit: REQUEST_BODY_LIMIT }));
81
+
82
+ // API Key authentication middleware for /v1/* endpoints
83
+ app.use('/v1', (req, res, next) => {
84
+ // Skip validation if apiKey is not configured
85
+ if (!config.apiKey) {
86
+ return next();
87
+ }
88
+
89
+ const authHeader = req.headers['authorization'];
90
+ const xApiKey = req.headers['x-api-key'];
91
+
92
+ let providedKey = '';
93
+ if (authHeader && authHeader.startsWith('Bearer ')) {
94
+ providedKey = authHeader.substring(7);
95
+ } else if (xApiKey) {
96
+ providedKey = xApiKey;
97
+ }
98
+
99
+ if (!providedKey || providedKey !== config.apiKey) {
100
+ logger.warn(`[API] Unauthorized request from ${req.ip}, invalid API key`);
101
+ return res.status(401).json({
102
+ type: 'error',
103
+ error: {
104
+ type: 'authentication_error',
105
+ message: 'Invalid or missing API key'
106
+ }
107
+ });
108
+ }
109
+
110
+ next();
111
+ });
112
+
113
+ // Setup usage statistics middleware
114
+ usageStats.setupMiddleware(app);
115
+
116
+ /**
117
+ * Silent handler for Claude Code CLI root POST requests
118
+ * Claude Code sends heartbeat/event requests to POST / which we don't need
119
+ * Using app.use instead of app.post for earlier middleware interception
120
+ */
121
+ app.use((req, res, next) => {
122
+ // Handle Claude Code event logging requests silently
123
+ if (req.method === 'POST' && req.path === '/api/event_logging/batch') {
124
+ return res.status(200).json({ status: 'ok' });
125
+ }
126
+ // Handle Claude Code root POST requests silently
127
+ if (req.method === 'POST' && req.path === '/') {
128
+ return res.status(200).json({ status: 'ok' });
129
+ }
130
+ next();
131
+ });
132
+
133
+ // Mount WebUI (optional web interface for account management)
134
+ mountWebUI(app, __dirname, accountManager);
135
+
136
+ /**
137
+ * Parse error message to extract error type, status code, and user-friendly message
138
+ */
139
+ function parseError(error) {
140
+ let errorType = 'api_error';
141
+ let statusCode = 500;
142
+ let errorMessage = error.message;
143
+
144
+ if (error.message.includes('401') || error.message.includes('UNAUTHENTICATED')) {
145
+ errorType = 'authentication_error';
146
+ statusCode = 401;
147
+ errorMessage = 'Authentication failed. Make sure Antigravity is running with a valid token.';
148
+ } else if (error.message.includes('429') || error.message.includes('RESOURCE_EXHAUSTED') || error.message.includes('QUOTA_EXHAUSTED')) {
149
+ errorType = 'invalid_request_error'; // Use invalid_request_error to force client to purge/stop
150
+ statusCode = 400; // Use 400 to ensure client does not retry (429 and 529 trigger retries)
151
+
152
+ // Try to extract the quota reset time from the error
153
+ const resetMatch = error.message.match(/quota will reset after ([\dh\dm\ds]+)/i);
154
+ // Try to extract model from our error format "Rate limited on <model>" or JSON format
155
+ const modelMatch = error.message.match(/Rate limited on ([^.]+)\./) || error.message.match(/"model":\s*"([^"]+)"/);
156
+ const model = modelMatch ? modelMatch[1] : 'the model';
157
+
158
+ if (resetMatch) {
159
+ errorMessage = `You have exhausted your capacity on ${model}. Quota will reset after ${resetMatch[1]}.`;
160
+ } else {
161
+ errorMessage = `You have exhausted your capacity on ${model}. Please wait for your quota to reset.`;
162
+ }
163
+ } else if (error.message.includes('invalid_request_error') || error.message.includes('INVALID_ARGUMENT')) {
164
+ errorType = 'invalid_request_error';
165
+ statusCode = 400;
166
+ const msgMatch = error.message.match(/"message":"([^"]+)"/);
167
+ if (msgMatch) errorMessage = msgMatch[1];
168
+ } else if (error.message.includes('All endpoints failed')) {
169
+ errorType = 'api_error';
170
+ statusCode = 503;
171
+ errorMessage = 'Unable to connect to Claude API. Check that Antigravity is running.';
172
+ } else if (error.message.includes('PERMISSION_DENIED')) {
173
+ errorType = 'permission_error';
174
+ statusCode = 403;
175
+ errorMessage = 'Permission denied. Check your Antigravity license.';
176
+ }
177
+
178
+ return { errorType, statusCode, errorMessage };
179
+ }
180
+
181
+ // Request logging middleware
182
+ app.use((req, res, next) => {
183
+ const start = Date.now();
184
+
185
+ // Log response on finish
186
+ res.on('finish', () => {
187
+ const duration = Date.now() - start;
188
+ const status = res.statusCode;
189
+ const logMsg = `[${req.method}] ${req.path} ${status} (${duration}ms)`;
190
+
191
+ // Skip standard logging for event logging batch unless in debug mode
192
+ if (req.path === '/api/event_logging/batch' || req.path === '/v1/messages/count_tokens') {
193
+ if (logger.isDebugEnabled) {
194
+ logger.debug(logMsg);
195
+ }
196
+ } else {
197
+ // Colorize status code
198
+ if (status >= 500) {
199
+ logger.error(logMsg);
200
+ } else if (status >= 400) {
201
+ logger.warn(logMsg);
202
+ } else {
203
+ logger.info(logMsg);
204
+ }
205
+ }
206
+ });
207
+
208
+ next();
209
+ });
210
+
211
+ /**
212
+ * Silent handler for Claude Code CLI root POST requests
213
+ * Claude Code sends heartbeat/event requests to POST / which we don't need
214
+ */
215
+ app.post('/', (req, res) => {
216
+ res.status(200).json({ status: 'ok' });
217
+ });
218
+
219
+ /**
220
+ * Test endpoint - Clear thinking signature cache
221
+ * Used for testing cold cache scenarios in cross-model tests
222
+ */
223
+ app.post('/test/clear-signature-cache', (req, res) => {
224
+ clearThinkingSignatureCache();
225
+ logger.debug('[Test] Cleared thinking signature cache');
226
+ res.json({ success: true, message: 'Thinking signature cache cleared' });
227
+ });
228
+
229
+ /**
230
+ * Health check endpoint - Detailed status
231
+ * Returns status of all accounts including rate limits and model quotas
232
+ */
233
+ app.get('/health', async (req, res) => {
234
+ try {
235
+ await ensureInitialized();
236
+ const start = Date.now();
237
+
238
+ // Get high-level status first
239
+ const status = accountManager.getStatus();
240
+ const allAccounts = accountManager.getAllAccounts();
241
+
242
+ // Fetch quotas for each account in parallel to get detailed model info
243
+ const accountDetails = await Promise.allSettled(
244
+ allAccounts.map(async (account) => {
245
+ // Check model-specific rate limits
246
+ const activeModelLimits = Object.entries(account.modelRateLimits || {})
247
+ .filter(([_, limit]) => limit.isRateLimited && limit.resetTime > Date.now());
248
+ const isRateLimited = activeModelLimits.length > 0;
249
+ const soonestReset = activeModelLimits.length > 0
250
+ ? Math.min(...activeModelLimits.map(([_, l]) => l.resetTime))
251
+ : null;
252
+
253
+ const baseInfo = {
254
+ email: account.email,
255
+ lastUsed: account.lastUsed ? new Date(account.lastUsed).toISOString() : null,
256
+ modelRateLimits: account.modelRateLimits || {},
257
+ rateLimitCooldownRemaining: soonestReset ? Math.max(0, soonestReset - Date.now()) : 0
258
+ };
259
+
260
+ // Skip invalid accounts for quota check
261
+ if (account.isInvalid) {
262
+ return {
263
+ ...baseInfo,
264
+ status: 'invalid',
265
+ error: account.invalidReason,
266
+ models: {}
267
+ };
268
+ }
269
+
270
+ try {
271
+ const token = await accountManager.getTokenForAccount(account);
272
+ const projectId = account.subscription?.projectId || null;
273
+ const quotas = await getModelQuotas(token, projectId);
274
+
275
+ // Format quotas for readability
276
+ const formattedQuotas = {};
277
+ for (const [modelId, info] of Object.entries(quotas)) {
278
+ formattedQuotas[modelId] = {
279
+ remaining: info.remainingFraction !== null ? `${Math.round(info.remainingFraction * 100)}%` : 'N/A',
280
+ remainingFraction: info.remainingFraction,
281
+ resetTime: info.resetTime || null
282
+ };
283
+ }
284
+
285
+ return {
286
+ ...baseInfo,
287
+ status: isRateLimited ? 'rate-limited' : 'ok',
288
+ models: formattedQuotas
289
+ };
290
+ } catch (error) {
291
+ return {
292
+ ...baseInfo,
293
+ status: 'error',
294
+ error: error.message,
295
+ models: {}
296
+ };
297
+ }
298
+ })
299
+ );
300
+
301
+ // Process results
302
+ const detailedAccounts = accountDetails.map((result, index) => {
303
+ if (result.status === 'fulfilled') {
304
+ return result.value;
305
+ } else {
306
+ const acc = allAccounts[index];
307
+ return {
308
+ email: acc.email,
309
+ status: 'error',
310
+ error: result.reason?.message || 'Unknown error',
311
+ modelRateLimits: acc.modelRateLimits || {}
312
+ };
313
+ }
314
+ });
315
+
316
+ res.json({
317
+ status: 'ok',
318
+ timestamp: new Date().toISOString(),
319
+ latencyMs: Date.now() - start,
320
+ summary: status.summary,
321
+ counts: {
322
+ total: status.total,
323
+ available: status.available,
324
+ rateLimited: status.rateLimited,
325
+ invalid: status.invalid
326
+ },
327
+ accounts: detailedAccounts
328
+ });
329
+
330
+ } catch (error) {
331
+ logger.error('[API] Health check failed:', error);
332
+ res.status(503).json({
333
+ status: 'error',
334
+ error: error.message,
335
+ timestamp: new Date().toISOString()
336
+ });
337
+ }
338
+ });
339
+
340
+ /**
341
+ * Account limits endpoint - fetch quota/limits for all accounts × all models
342
+ * Returns a table showing remaining quota and reset time for each combination
343
+ * Use ?format=table for ASCII table output, default is JSON
344
+ */
345
+ app.get('/account-limits', async (req, res) => {
346
+ try {
347
+ await ensureInitialized();
348
+ const allAccounts = accountManager.getAllAccounts();
349
+ const format = req.query.format || 'json';
350
+ const includeHistory = req.query.includeHistory === 'true';
351
+
352
+ // Fetch quotas for each account in parallel
353
+ const results = await Promise.allSettled(
354
+ allAccounts.map(async (account) => {
355
+ // Skip invalid accounts
356
+ if (account.isInvalid) {
357
+ return {
358
+ email: account.email,
359
+ status: 'invalid',
360
+ error: account.invalidReason,
361
+ models: {}
362
+ };
363
+ }
364
+
365
+ try {
366
+ const token = await accountManager.getTokenForAccount(account);
367
+
368
+ // Use provider-aware quota fetching
369
+ const { getProviderForAccount } = await import('./providers/index.js');
370
+ const provider = getProviderForAccount(account);
371
+
372
+ // Fetch subscription tier and quotas using provider
373
+ const subscription = await provider.getSubscriptionTier(account, token);
374
+ const quotas = await provider.getQuotas(account, token);
375
+
376
+ // Update account object with fresh data
377
+ account.subscription = {
378
+ tier: subscription.tier,
379
+ projectId: subscription.projectId,
380
+ detectedAt: Date.now()
381
+ };
382
+ account.quota = {
383
+ models: quotas.models || quotas,
384
+ lastChecked: Date.now()
385
+ };
386
+
387
+ // Save updated account data to disk (async, don't wait)
388
+ accountManager.saveToDisk().catch(err => {
389
+ logger.error('[Server] Failed to save account data:', err);
390
+ });
391
+
392
+ return {
393
+ email: account.email,
394
+ status: 'ok',
395
+ provider: account.provider || 'google',
396
+ subscription: account.subscription,
397
+ models: quotas.models || quotas
398
+ };
399
+ } catch (error) {
400
+ return {
401
+ email: account.email,
402
+ status: 'error',
403
+ error: error.message,
404
+ provider: account.provider || 'google',
405
+ subscription: account.subscription || { tier: 'unknown', projectId: null },
406
+ models: {}
407
+ };
408
+ }
409
+ })
410
+ );
411
+
412
+ // Process results
413
+ const accountLimits = results.map((result, index) => {
414
+ if (result.status === 'fulfilled') {
415
+ return result.value;
416
+ } else {
417
+ return {
418
+ email: allAccounts[index].email,
419
+ status: 'error',
420
+ error: result.reason?.message || 'Unknown error',
421
+ models: {}
422
+ };
423
+ }
424
+ });
425
+
426
+ // Collect all unique model IDs
427
+ const allModelIds = new Set();
428
+ for (const account of accountLimits) {
429
+ for (const modelId of Object.keys(account.models || {})) {
430
+ allModelIds.add(modelId);
431
+ }
432
+ }
433
+
434
+ const sortedModels = Array.from(allModelIds).sort();
435
+
436
+ // Return ASCII table format
437
+ if (format === 'table') {
438
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
439
+
440
+ // Build table
441
+ const lines = [];
442
+ const timestamp = new Date().toLocaleString();
443
+ lines.push(`Account Limits (${timestamp})`);
444
+
445
+ // Get account status info
446
+ const status = accountManager.getStatus();
447
+ lines.push(`Accounts: ${status.total} total, ${status.available} available, ${status.rateLimited} rate-limited, ${status.invalid} invalid`);
448
+ lines.push('');
449
+
450
+ // Table 1: Account status
451
+ const accColWidth = 25;
452
+ const statusColWidth = 15;
453
+ const lastUsedColWidth = 25;
454
+ const resetColWidth = 25;
455
+
456
+ let accHeader = 'Account'.padEnd(accColWidth) + 'Status'.padEnd(statusColWidth) + 'Last Used'.padEnd(lastUsedColWidth) + 'Quota Reset';
457
+ lines.push(accHeader);
458
+ lines.push('─'.repeat(accColWidth + statusColWidth + lastUsedColWidth + resetColWidth));
459
+
460
+ for (const acc of status.accounts) {
461
+ const shortEmail = acc.email.split('@')[0].slice(0, 22);
462
+ const lastUsed = acc.lastUsed ? new Date(acc.lastUsed).toLocaleString() : 'never';
463
+
464
+ // Get status and error from accountLimits
465
+ const accLimit = accountLimits.find(a => a.email === acc.email);
466
+ let accStatus;
467
+ if (acc.isInvalid) {
468
+ accStatus = 'invalid';
469
+ } else if (accLimit?.status === 'error') {
470
+ accStatus = 'error';
471
+ } else {
472
+ // Count exhausted models (0% or null remaining)
473
+ const models = accLimit?.models || {};
474
+ const modelCount = Object.keys(models).length;
475
+ const exhaustedCount = Object.values(models).filter(
476
+ q => q.remainingFraction === 0 || q.remainingFraction === null
477
+ ).length;
478
+
479
+ if (exhaustedCount === 0) {
480
+ accStatus = 'ok';
481
+ } else {
482
+ accStatus = `(${exhaustedCount}/${modelCount}) limited`;
483
+ }
484
+ }
485
+
486
+ // Get reset time from quota API
487
+ const claudeModel = sortedModels.find(m => m.includes('claude'));
488
+ const quota = claudeModel && accLimit?.models?.[claudeModel];
489
+ const resetTime = quota?.resetTime
490
+ ? new Date(quota.resetTime).toLocaleString()
491
+ : '-';
492
+
493
+ let row = shortEmail.padEnd(accColWidth) + accStatus.padEnd(statusColWidth) + lastUsed.padEnd(lastUsedColWidth) + resetTime;
494
+
495
+ // Add error on next line if present
496
+ if (accLimit?.error) {
497
+ lines.push(row);
498
+ lines.push(' └─ ' + accLimit.error);
499
+ } else {
500
+ lines.push(row);
501
+ }
502
+ }
503
+ lines.push('');
504
+
505
+ // Calculate column widths - need more space for reset time info
506
+ const modelColWidth = Math.max(28, ...sortedModels.map(m => m.length)) + 2;
507
+ const accountColWidth = 30;
508
+
509
+ // Header row
510
+ let header = 'Model'.padEnd(modelColWidth);
511
+ for (const acc of accountLimits) {
512
+ const shortEmail = acc.email.split('@')[0].slice(0, 26);
513
+ header += shortEmail.padEnd(accountColWidth);
514
+ }
515
+ lines.push(header);
516
+ lines.push('─'.repeat(modelColWidth + accountLimits.length * accountColWidth));
517
+
518
+ // Data rows
519
+ for (const modelId of sortedModels) {
520
+ let row = modelId.padEnd(modelColWidth);
521
+ for (const acc of accountLimits) {
522
+ const quota = acc.models?.[modelId];
523
+ let cell;
524
+ if (acc.status !== 'ok' && acc.status !== 'rate-limited') {
525
+ cell = `[${acc.status}]`;
526
+ } else if (!quota) {
527
+ cell = '-';
528
+ } else if (quota.remainingFraction === 0 || quota.remainingFraction === null) {
529
+ // Show reset time for exhausted models
530
+ if (quota.resetTime) {
531
+ const resetMs = new Date(quota.resetTime).getTime() - Date.now();
532
+ if (resetMs > 0) {
533
+ cell = `0% (wait ${formatDuration(resetMs)})`;
534
+ } else {
535
+ cell = '0% (resetting...)';
536
+ }
537
+ } else {
538
+ cell = '0% (exhausted)';
539
+ }
540
+ } else {
541
+ const pct = Math.round(quota.remainingFraction * 100);
542
+ cell = `${pct}%`;
543
+ }
544
+ row += cell.padEnd(accountColWidth);
545
+ }
546
+ lines.push(row);
547
+ }
548
+
549
+ return res.send(lines.join('\n'));
550
+ }
551
+
552
+ // Get account metadata from AccountManager
553
+ const accountStatus = accountManager.getStatus();
554
+ const accountMetadataMap = new Map(
555
+ accountStatus.accounts.map(a => [a.email, a])
556
+ );
557
+
558
+ // Build response data
559
+ const responseData = {
560
+ timestamp: new Date().toLocaleString(),
561
+ totalAccounts: allAccounts.length,
562
+ models: sortedModels,
563
+ modelConfig: config.modelMapping || {},
564
+ accounts: accountLimits.map(acc => {
565
+ // Merge quota data with account metadata
566
+ const metadata = accountMetadataMap.get(acc.email) || {};
567
+ return {
568
+ email: acc.email,
569
+ status: acc.status,
570
+ error: acc.error || null,
571
+ // Include metadata from AccountManager (WebUI needs these)
572
+ source: metadata.source || 'unknown',
573
+ enabled: metadata.enabled !== false,
574
+ projectId: metadata.projectId || null,
575
+ isInvalid: metadata.isInvalid || false,
576
+ invalidReason: metadata.invalidReason || null,
577
+ lastUsed: metadata.lastUsed || null,
578
+ modelRateLimits: metadata.modelRateLimits || {},
579
+ // Subscription data (new)
580
+ subscription: acc.subscription || metadata.subscription || { tier: 'unknown', projectId: null },
581
+ // Quota limits
582
+ limits: Object.fromEntries(
583
+ sortedModels.map(modelId => {
584
+ const quota = acc.models?.[modelId];
585
+ if (!quota) {
586
+ return [modelId, null];
587
+ }
588
+ return [modelId, {
589
+ remaining: quota.remainingFraction !== null
590
+ ? `${Math.round(quota.remainingFraction * 100)}%`
591
+ : 'N/A',
592
+ remainingFraction: quota.remainingFraction,
593
+ resetTime: quota.resetTime || null
594
+ }];
595
+ })
596
+ )
597
+ };
598
+ })
599
+ };
600
+
601
+ // Optionally include usage history (for dashboard performance optimization)
602
+ if (includeHistory) {
603
+ responseData.history = usageStats.getHistory();
604
+ }
605
+
606
+ res.json(responseData);
607
+ } catch (error) {
608
+ res.status(500).json({
609
+ status: 'error',
610
+ error: error.message
611
+ });
612
+ }
613
+ });
614
+
615
+ /**
616
+ * Force token refresh endpoint
617
+ */
618
+ app.post('/refresh-token', async (req, res) => {
619
+ try {
620
+ await ensureInitialized();
621
+ // Clear all caches
622
+ accountManager.clearTokenCache();
623
+ accountManager.clearProjectCache();
624
+ // Force refresh default token
625
+ const token = await forceRefresh();
626
+ res.json({
627
+ status: 'ok',
628
+ message: 'Token caches cleared and refreshed',
629
+ tokenPrefix: token.substring(0, 10) + '...'
630
+ });
631
+ } catch (error) {
632
+ res.status(500).json({
633
+ status: 'error',
634
+ error: error.message
635
+ });
636
+ }
637
+ });
638
+
639
+ /**
640
+ * List models endpoint (OpenAI-compatible format)
641
+ */
642
+ app.get('/v1/models', async (req, res) => {
643
+ try {
644
+ await ensureInitialized();
645
+ const { account } = accountManager.selectAccount();
646
+ if (!account) {
647
+ return res.status(503).json({
648
+ type: 'error',
649
+ error: {
650
+ type: 'api_error',
651
+ message: 'No accounts available'
652
+ }
653
+ });
654
+ }
655
+ const token = await accountManager.getTokenForAccount(account);
656
+ const models = await listModels(token);
657
+ res.json(models);
658
+ } catch (error) {
659
+ logger.error('[API] Error listing models:', error);
660
+ res.status(500).json({
661
+ type: 'error',
662
+ error: {
663
+ type: 'api_error',
664
+ message: error.message
665
+ }
666
+ });
667
+ }
668
+ });
669
+
670
+ /**
671
+ * Count tokens endpoint - Anthropic Messages API compatible
672
+ * Uses local tokenization with official tokenizers (@anthropic-ai/tokenizer for Claude, @lenml/tokenizer-gemini for Gemini)
673
+ */
674
+ app.post('/v1/messages/count_tokens', (req, res) => {
675
+ res.status(501).json({
676
+ type: 'error',
677
+ error: {
678
+ type: 'not_implemented',
679
+ message: 'Token counting is not implemented. Use /v1/messages with max_tokens or configure your client to skip token counting.'
680
+ }
681
+ });
682
+ });
683
+
684
+ /**
685
+ * Main messages endpoint - Anthropic Messages API compatible
686
+ */
687
+
688
+
689
+ /**
690
+ * Anthropic-compatible Messages API
691
+ * POST /v1/messages
692
+ */
693
+ app.post('/v1/messages', async (req, res) => {
694
+ try {
695
+ // Ensure account manager is initialized
696
+ await ensureInitialized();
697
+
698
+ const {
699
+ model,
700
+ messages,
701
+ stream,
702
+ system,
703
+ max_tokens,
704
+ tools,
705
+ tool_choice,
706
+ thinking,
707
+ top_p,
708
+ top_k,
709
+ temperature
710
+ } = req.body;
711
+
712
+ // Resolve model mapping if configured
713
+ let requestedModel = model || 'claude-3-5-sonnet-20241022';
714
+ const modelMapping = config.modelMapping || {};
715
+ if (modelMapping[requestedModel] && modelMapping[requestedModel].mapping) {
716
+ const targetModel = modelMapping[requestedModel].mapping;
717
+ logger.info(`[Server] Mapping model ${requestedModel} -> ${targetModel}`);
718
+ requestedModel = targetModel;
719
+ }
720
+
721
+ const modelId = requestedModel;
722
+
723
+ // Optimistic Retry: If ALL accounts are rate-limited for this model, reset them to force a fresh check.
724
+ // If we have some available accounts, we try them first.
725
+ if (accountManager.isAllRateLimited(modelId)) {
726
+ logger.warn(`[Server] All accounts rate-limited for ${modelId}. Resetting state for optimistic retry.`);
727
+ accountManager.resetAllRateLimits();
728
+ }
729
+
730
+ // Validate required fields
731
+ if (!messages || !Array.isArray(messages)) {
732
+ return res.status(400).json({
733
+ type: 'error',
734
+ error: {
735
+ type: 'invalid_request_error',
736
+ message: 'messages is required and must be an array'
737
+ }
738
+ });
739
+ }
740
+
741
+ // Filter out "count" requests (often automated background checks)
742
+ if (messages.length === 1 && messages[0].content === 'count') {
743
+ return res.json({});
744
+ }
745
+
746
+ // Build the request object
747
+ const request = {
748
+ model: modelId,
749
+ messages,
750
+ max_tokens: max_tokens || 4096,
751
+ stream,
752
+ system,
753
+ tools,
754
+ tool_choice,
755
+ thinking,
756
+ top_p,
757
+ top_k,
758
+ temperature
759
+ };
760
+
761
+ logger.info(`[API] Request for model: ${request.model}, stream: ${!!stream}`);
762
+
763
+ // Debug: Log message structure to diagnose tool_use/tool_result ordering
764
+ if (logger.isDebugEnabled) {
765
+ logger.debug('[API] Message structure:');
766
+ messages.forEach((msg, i) => {
767
+ const contentTypes = Array.isArray(msg.content)
768
+ ? msg.content.map(c => c.type || 'text').join(', ')
769
+ : (typeof msg.content === 'string' ? 'text' : 'unknown');
770
+ logger.debug(` [${i}] ${msg.role}: ${contentTypes}`);
771
+ });
772
+ }
773
+
774
+ if (stream) {
775
+ // Handle streaming response
776
+ res.setHeader('Content-Type', 'text/event-stream');
777
+ res.setHeader('Cache-Control', 'no-cache');
778
+ res.setHeader('Connection', 'keep-alive');
779
+ res.setHeader('X-Accel-Buffering', 'no');
780
+
781
+ // Flush headers immediately to start the stream
782
+ res.flushHeaders();
783
+
784
+ try {
785
+ // Use the streaming generator with account manager
786
+ for await (const event of sendMessageStream(request, accountManager, FALLBACK_ENABLED)) {
787
+ res.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
788
+ // Flush after each event for real-time streaming
789
+ if (res.flush) res.flush();
790
+ }
791
+ res.end();
792
+
793
+ } catch (streamError) {
794
+ logger.error('[API] Stream error:', streamError);
795
+
796
+ const { errorType, errorMessage } = parseError(streamError);
797
+
798
+ res.write(`event: error\ndata: ${JSON.stringify({
799
+ type: 'error',
800
+ error: { type: errorType, message: errorMessage }
801
+ })}\n\n`);
802
+ res.end();
803
+ }
804
+
805
+ } else {
806
+ // Handle non-streaming response
807
+ const response = await sendMessage(request, accountManager, FALLBACK_ENABLED);
808
+ res.json(response);
809
+ }
810
+
811
+ } catch (error) {
812
+ logger.error('[API] Error:', error);
813
+
814
+ let { errorType, statusCode, errorMessage } = parseError(error);
815
+
816
+ // For auth errors, try to refresh token
817
+ if (errorType === 'authentication_error') {
818
+ logger.warn('[API] Token might be expired, attempting refresh...');
819
+ try {
820
+ accountManager.clearProjectCache();
821
+ accountManager.clearTokenCache();
822
+ await forceRefresh();
823
+ errorMessage = 'Token was expired and has been refreshed. Please retry your request.';
824
+ } catch (refreshError) {
825
+ errorMessage = 'Could not refresh token. Make sure Antigravity is running.';
826
+ }
827
+ }
828
+
829
+ logger.warn(`[API] Returning error response: ${statusCode} ${errorType} - ${errorMessage}`);
830
+
831
+ // Check if headers have already been sent (for streaming that failed mid-way)
832
+ if (res.headersSent) {
833
+ logger.warn('[API] Headers already sent, writing error as SSE event');
834
+ res.write(`event: error\ndata: ${JSON.stringify({
835
+ type: 'error',
836
+ error: { type: errorType, message: errorMessage }
837
+ })}\n\n`);
838
+ res.end();
839
+ } else {
840
+ res.status(statusCode).json({
841
+ type: 'error',
842
+ error: {
843
+ type: errorType,
844
+ message: errorMessage
845
+ }
846
+ });
847
+ }
848
+ }
849
+ });
850
+
851
+ /**
852
+ * Catch-all for unsupported endpoints
853
+ */
854
+ usageStats.setupRoutes(app);
855
+
856
+ app.use('*', (req, res) => {
857
+ // Log 404s (use originalUrl since wildcard strips req.path)
858
+ if (logger.isDebugEnabled) {
859
+ logger.debug(`[API] 404 Not Found: ${req.method} ${req.originalUrl}`);
860
+ }
861
+ res.status(404).json({
862
+ type: 'error',
863
+ error: {
864
+ type: 'not_found_error',
865
+ message: `Endpoint ${req.method} ${req.originalUrl} not found`
866
+ }
867
+ });
868
+ });
869
+
870
+ export default app;