@kamel-ahmed/proxy-claude 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.
Files changed (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +622 -0
  3. package/bin/cli.js +124 -0
  4. package/package.json +80 -0
  5. package/public/app.js +228 -0
  6. package/public/css/src/input.css +523 -0
  7. package/public/css/style.css +1 -0
  8. package/public/favicon.svg +10 -0
  9. package/public/index.html +381 -0
  10. package/public/js/components/account-manager.js +245 -0
  11. package/public/js/components/claude-config.js +420 -0
  12. package/public/js/components/dashboard/charts.js +589 -0
  13. package/public/js/components/dashboard/filters.js +362 -0
  14. package/public/js/components/dashboard/stats.js +110 -0
  15. package/public/js/components/dashboard.js +236 -0
  16. package/public/js/components/logs-viewer.js +100 -0
  17. package/public/js/components/models.js +36 -0
  18. package/public/js/components/server-config.js +349 -0
  19. package/public/js/config/constants.js +102 -0
  20. package/public/js/data-store.js +386 -0
  21. package/public/js/settings-store.js +58 -0
  22. package/public/js/store.js +78 -0
  23. package/public/js/translations/en.js +351 -0
  24. package/public/js/translations/id.js +396 -0
  25. package/public/js/translations/pt.js +287 -0
  26. package/public/js/translations/tr.js +342 -0
  27. package/public/js/translations/zh.js +357 -0
  28. package/public/js/utils/account-actions.js +189 -0
  29. package/public/js/utils/error-handler.js +96 -0
  30. package/public/js/utils/model-config.js +42 -0
  31. package/public/js/utils/validators.js +77 -0
  32. package/public/js/utils.js +69 -0
  33. package/public/views/accounts.html +329 -0
  34. package/public/views/dashboard.html +484 -0
  35. package/public/views/logs.html +97 -0
  36. package/public/views/models.html +331 -0
  37. package/public/views/settings.html +1329 -0
  38. package/src/account-manager/credentials.js +243 -0
  39. package/src/account-manager/index.js +380 -0
  40. package/src/account-manager/onboarding.js +117 -0
  41. package/src/account-manager/rate-limits.js +237 -0
  42. package/src/account-manager/storage.js +136 -0
  43. package/src/account-manager/strategies/base-strategy.js +104 -0
  44. package/src/account-manager/strategies/hybrid-strategy.js +195 -0
  45. package/src/account-manager/strategies/index.js +79 -0
  46. package/src/account-manager/strategies/round-robin-strategy.js +76 -0
  47. package/src/account-manager/strategies/sticky-strategy.js +138 -0
  48. package/src/account-manager/strategies/trackers/health-tracker.js +162 -0
  49. package/src/account-manager/strategies/trackers/index.js +8 -0
  50. package/src/account-manager/strategies/trackers/token-bucket-tracker.js +121 -0
  51. package/src/auth/database.js +169 -0
  52. package/src/auth/oauth.js +419 -0
  53. package/src/auth/token-extractor.js +117 -0
  54. package/src/cli/accounts.js +512 -0
  55. package/src/cli/refresh.js +201 -0
  56. package/src/cli/setup.js +338 -0
  57. package/src/cloudcode/index.js +29 -0
  58. package/src/cloudcode/message-handler.js +386 -0
  59. package/src/cloudcode/model-api.js +248 -0
  60. package/src/cloudcode/rate-limit-parser.js +181 -0
  61. package/src/cloudcode/request-builder.js +93 -0
  62. package/src/cloudcode/session-manager.js +47 -0
  63. package/src/cloudcode/sse-parser.js +121 -0
  64. package/src/cloudcode/sse-streamer.js +293 -0
  65. package/src/cloudcode/streaming-handler.js +492 -0
  66. package/src/config.js +107 -0
  67. package/src/constants.js +278 -0
  68. package/src/errors.js +238 -0
  69. package/src/fallback-config.js +29 -0
  70. package/src/format/content-converter.js +193 -0
  71. package/src/format/index.js +20 -0
  72. package/src/format/request-converter.js +248 -0
  73. package/src/format/response-converter.js +120 -0
  74. package/src/format/schema-sanitizer.js +673 -0
  75. package/src/format/signature-cache.js +88 -0
  76. package/src/format/thinking-utils.js +558 -0
  77. package/src/index.js +146 -0
  78. package/src/modules/usage-stats.js +205 -0
  79. package/src/server.js +861 -0
  80. package/src/utils/claude-config.js +245 -0
  81. package/src/utils/helpers.js +51 -0
  82. package/src/utils/logger.js +142 -0
  83. package/src/utils/native-module-helper.js +162 -0
  84. package/src/webui/index.js +707 -0
package/src/server.js ADDED
@@ -0,0 +1,861 @@
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
+ // Fetch subscription tier first to get project ID
369
+ const subscription = await getSubscriptionTier(token);
370
+
371
+ // Then fetch quotas with project ID for accurate quota info
372
+ const quotas = await getModelQuotas(token, subscription.projectId);
373
+
374
+ // Update account object with fresh data
375
+ account.subscription = {
376
+ tier: subscription.tier,
377
+ projectId: subscription.projectId,
378
+ detectedAt: Date.now()
379
+ };
380
+ account.quota = {
381
+ models: quotas,
382
+ lastChecked: Date.now()
383
+ };
384
+
385
+ // Save updated account data to disk (async, don't wait)
386
+ accountManager.saveToDisk().catch(err => {
387
+ logger.error('[Server] Failed to save account data:', err);
388
+ });
389
+
390
+ return {
391
+ email: account.email,
392
+ status: 'ok',
393
+ subscription: account.subscription,
394
+ models: quotas
395
+ };
396
+ } catch (error) {
397
+ return {
398
+ email: account.email,
399
+ status: 'error',
400
+ error: error.message,
401
+ subscription: account.subscription || { tier: 'unknown', projectId: null },
402
+ models: {}
403
+ };
404
+ }
405
+ })
406
+ );
407
+
408
+ // Process results
409
+ const accountLimits = results.map((result, index) => {
410
+ if (result.status === 'fulfilled') {
411
+ return result.value;
412
+ } else {
413
+ return {
414
+ email: allAccounts[index].email,
415
+ status: 'error',
416
+ error: result.reason?.message || 'Unknown error',
417
+ models: {}
418
+ };
419
+ }
420
+ });
421
+
422
+ // Collect all unique model IDs
423
+ const allModelIds = new Set();
424
+ for (const account of accountLimits) {
425
+ for (const modelId of Object.keys(account.models || {})) {
426
+ allModelIds.add(modelId);
427
+ }
428
+ }
429
+
430
+ const sortedModels = Array.from(allModelIds).sort();
431
+
432
+ // Return ASCII table format
433
+ if (format === 'table') {
434
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
435
+
436
+ // Build table
437
+ const lines = [];
438
+ const timestamp = new Date().toLocaleString();
439
+ lines.push(`Account Limits (${timestamp})`);
440
+
441
+ // Get account status info
442
+ const status = accountManager.getStatus();
443
+ lines.push(`Accounts: ${status.total} total, ${status.available} available, ${status.rateLimited} rate-limited, ${status.invalid} invalid`);
444
+ lines.push('');
445
+
446
+ // Table 1: Account status
447
+ const accColWidth = 25;
448
+ const statusColWidth = 15;
449
+ const lastUsedColWidth = 25;
450
+ const resetColWidth = 25;
451
+
452
+ let accHeader = 'Account'.padEnd(accColWidth) + 'Status'.padEnd(statusColWidth) + 'Last Used'.padEnd(lastUsedColWidth) + 'Quota Reset';
453
+ lines.push(accHeader);
454
+ lines.push('─'.repeat(accColWidth + statusColWidth + lastUsedColWidth + resetColWidth));
455
+
456
+ for (const acc of status.accounts) {
457
+ const shortEmail = acc.email.split('@')[0].slice(0, 22);
458
+ const lastUsed = acc.lastUsed ? new Date(acc.lastUsed).toLocaleString() : 'never';
459
+
460
+ // Get status and error from accountLimits
461
+ const accLimit = accountLimits.find(a => a.email === acc.email);
462
+ let accStatus;
463
+ if (acc.isInvalid) {
464
+ accStatus = 'invalid';
465
+ } else if (accLimit?.status === 'error') {
466
+ accStatus = 'error';
467
+ } else {
468
+ // Count exhausted models (0% or null remaining)
469
+ const models = accLimit?.models || {};
470
+ const modelCount = Object.keys(models).length;
471
+ const exhaustedCount = Object.values(models).filter(
472
+ q => q.remainingFraction === 0 || q.remainingFraction === null
473
+ ).length;
474
+
475
+ if (exhaustedCount === 0) {
476
+ accStatus = 'ok';
477
+ } else {
478
+ accStatus = `(${exhaustedCount}/${modelCount}) limited`;
479
+ }
480
+ }
481
+
482
+ // Get reset time from quota API
483
+ const claudeModel = sortedModels.find(m => m.includes('claude'));
484
+ const quota = claudeModel && accLimit?.models?.[claudeModel];
485
+ const resetTime = quota?.resetTime
486
+ ? new Date(quota.resetTime).toLocaleString()
487
+ : '-';
488
+
489
+ let row = shortEmail.padEnd(accColWidth) + accStatus.padEnd(statusColWidth) + lastUsed.padEnd(lastUsedColWidth) + resetTime;
490
+
491
+ // Add error on next line if present
492
+ if (accLimit?.error) {
493
+ lines.push(row);
494
+ lines.push(' └─ ' + accLimit.error);
495
+ } else {
496
+ lines.push(row);
497
+ }
498
+ }
499
+ lines.push('');
500
+
501
+ // Calculate column widths - need more space for reset time info
502
+ const modelColWidth = Math.max(28, ...sortedModels.map(m => m.length)) + 2;
503
+ const accountColWidth = 30;
504
+
505
+ // Header row
506
+ let header = 'Model'.padEnd(modelColWidth);
507
+ for (const acc of accountLimits) {
508
+ const shortEmail = acc.email.split('@')[0].slice(0, 26);
509
+ header += shortEmail.padEnd(accountColWidth);
510
+ }
511
+ lines.push(header);
512
+ lines.push('─'.repeat(modelColWidth + accountLimits.length * accountColWidth));
513
+
514
+ // Data rows
515
+ for (const modelId of sortedModels) {
516
+ let row = modelId.padEnd(modelColWidth);
517
+ for (const acc of accountLimits) {
518
+ const quota = acc.models?.[modelId];
519
+ let cell;
520
+ if (acc.status !== 'ok' && acc.status !== 'rate-limited') {
521
+ cell = `[${acc.status}]`;
522
+ } else if (!quota) {
523
+ cell = '-';
524
+ } else if (quota.remainingFraction === 0 || quota.remainingFraction === null) {
525
+ // Show reset time for exhausted models
526
+ if (quota.resetTime) {
527
+ const resetMs = new Date(quota.resetTime).getTime() - Date.now();
528
+ if (resetMs > 0) {
529
+ cell = `0% (wait ${formatDuration(resetMs)})`;
530
+ } else {
531
+ cell = '0% (resetting...)';
532
+ }
533
+ } else {
534
+ cell = '0% (exhausted)';
535
+ }
536
+ } else {
537
+ const pct = Math.round(quota.remainingFraction * 100);
538
+ cell = `${pct}%`;
539
+ }
540
+ row += cell.padEnd(accountColWidth);
541
+ }
542
+ lines.push(row);
543
+ }
544
+
545
+ return res.send(lines.join('\n'));
546
+ }
547
+
548
+ // Get account metadata from AccountManager
549
+ const accountStatus = accountManager.getStatus();
550
+ const accountMetadataMap = new Map(
551
+ accountStatus.accounts.map(a => [a.email, a])
552
+ );
553
+
554
+ // Build response data
555
+ const responseData = {
556
+ timestamp: new Date().toLocaleString(),
557
+ totalAccounts: allAccounts.length,
558
+ models: sortedModels,
559
+ modelConfig: config.modelMapping || {},
560
+ accounts: accountLimits.map(acc => {
561
+ // Merge quota data with account metadata
562
+ const metadata = accountMetadataMap.get(acc.email) || {};
563
+ return {
564
+ email: acc.email,
565
+ status: acc.status,
566
+ error: acc.error || null,
567
+ // Include metadata from AccountManager (WebUI needs these)
568
+ source: metadata.source || 'unknown',
569
+ enabled: metadata.enabled !== false,
570
+ projectId: metadata.projectId || null,
571
+ isInvalid: metadata.isInvalid || false,
572
+ invalidReason: metadata.invalidReason || null,
573
+ lastUsed: metadata.lastUsed || null,
574
+ modelRateLimits: metadata.modelRateLimits || {},
575
+ // Subscription data (new)
576
+ subscription: acc.subscription || metadata.subscription || { tier: 'unknown', projectId: null },
577
+ // Quota limits
578
+ limits: Object.fromEntries(
579
+ sortedModels.map(modelId => {
580
+ const quota = acc.models?.[modelId];
581
+ if (!quota) {
582
+ return [modelId, null];
583
+ }
584
+ return [modelId, {
585
+ remaining: quota.remainingFraction !== null
586
+ ? `${Math.round(quota.remainingFraction * 100)}%`
587
+ : 'N/A',
588
+ remainingFraction: quota.remainingFraction,
589
+ resetTime: quota.resetTime || null
590
+ }];
591
+ })
592
+ )
593
+ };
594
+ })
595
+ };
596
+
597
+ // Optionally include usage history (for dashboard performance optimization)
598
+ if (includeHistory) {
599
+ responseData.history = usageStats.getHistory();
600
+ }
601
+
602
+ res.json(responseData);
603
+ } catch (error) {
604
+ res.status(500).json({
605
+ status: 'error',
606
+ error: error.message
607
+ });
608
+ }
609
+ });
610
+
611
+ /**
612
+ * Force token refresh endpoint
613
+ */
614
+ app.post('/refresh-token', async (req, res) => {
615
+ try {
616
+ await ensureInitialized();
617
+ // Clear all caches
618
+ accountManager.clearTokenCache();
619
+ accountManager.clearProjectCache();
620
+ // Force refresh default token
621
+ const token = await forceRefresh();
622
+ res.json({
623
+ status: 'ok',
624
+ message: 'Token caches cleared and refreshed',
625
+ tokenPrefix: token.substring(0, 10) + '...'
626
+ });
627
+ } catch (error) {
628
+ res.status(500).json({
629
+ status: 'error',
630
+ error: error.message
631
+ });
632
+ }
633
+ });
634
+
635
+ /**
636
+ * List models endpoint (OpenAI-compatible format)
637
+ */
638
+ app.get('/v1/models', async (req, res) => {
639
+ try {
640
+ await ensureInitialized();
641
+ const account = accountManager.pickNext();
642
+ if (!account) {
643
+ return res.status(503).json({
644
+ type: 'error',
645
+ error: {
646
+ type: 'api_error',
647
+ message: 'No accounts available'
648
+ }
649
+ });
650
+ }
651
+ const token = await accountManager.getTokenForAccount(account);
652
+ const models = await listModels(token);
653
+ res.json(models);
654
+ } catch (error) {
655
+ logger.error('[API] Error listing models:', error);
656
+ res.status(500).json({
657
+ type: 'error',
658
+ error: {
659
+ type: 'api_error',
660
+ message: error.message
661
+ }
662
+ });
663
+ }
664
+ });
665
+
666
+ /**
667
+ * Count tokens endpoint - Anthropic Messages API compatible
668
+ * Uses local tokenization with official tokenizers (@anthropic-ai/tokenizer for Claude, @lenml/tokenizer-gemini for Gemini)
669
+ */
670
+ app.post('/v1/messages/count_tokens', (req, res) => {
671
+ res.status(501).json({
672
+ type: 'error',
673
+ error: {
674
+ type: 'not_implemented',
675
+ message: 'Token counting is not implemented. Use /v1/messages with max_tokens or configure your client to skip token counting.'
676
+ }
677
+ });
678
+ });
679
+
680
+ /**
681
+ * Main messages endpoint - Anthropic Messages API compatible
682
+ */
683
+
684
+
685
+ /**
686
+ * Anthropic-compatible Messages API
687
+ * POST /v1/messages
688
+ */
689
+ app.post('/v1/messages', async (req, res) => {
690
+ try {
691
+ // Ensure account manager is initialized
692
+ await ensureInitialized();
693
+
694
+ const {
695
+ model,
696
+ messages,
697
+ stream,
698
+ system,
699
+ max_tokens,
700
+ tools,
701
+ tool_choice,
702
+ thinking,
703
+ top_p,
704
+ top_k,
705
+ temperature
706
+ } = req.body;
707
+
708
+ // Resolve model mapping if configured
709
+ let requestedModel = model || 'claude-3-5-sonnet-20241022';
710
+ const modelMapping = config.modelMapping || {};
711
+ if (modelMapping[requestedModel] && modelMapping[requestedModel].mapping) {
712
+ const targetModel = modelMapping[requestedModel].mapping;
713
+ logger.info(`[Server] Mapping model ${requestedModel} -> ${targetModel}`);
714
+ requestedModel = targetModel;
715
+ }
716
+
717
+ const modelId = requestedModel;
718
+
719
+ // Optimistic Retry: If ALL accounts are rate-limited for this model, reset them to force a fresh check.
720
+ // If we have some available accounts, we try them first.
721
+ if (accountManager.isAllRateLimited(modelId)) {
722
+ logger.warn(`[Server] All accounts rate-limited for ${modelId}. Resetting state for optimistic retry.`);
723
+ accountManager.resetAllRateLimits();
724
+ }
725
+
726
+ // Validate required fields
727
+ if (!messages || !Array.isArray(messages)) {
728
+ return res.status(400).json({
729
+ type: 'error',
730
+ error: {
731
+ type: 'invalid_request_error',
732
+ message: 'messages is required and must be an array'
733
+ }
734
+ });
735
+ }
736
+
737
+ // Build the request object
738
+ const request = {
739
+ model: modelId,
740
+ messages,
741
+ max_tokens: max_tokens || 4096,
742
+ stream,
743
+ system,
744
+ tools,
745
+ tool_choice,
746
+ thinking,
747
+ top_p,
748
+ top_k,
749
+ temperature
750
+ };
751
+
752
+ logger.info(`[API] Request for model: ${request.model}, stream: ${!!stream}`);
753
+
754
+ // Debug: Log message structure to diagnose tool_use/tool_result ordering
755
+ if (logger.isDebugEnabled) {
756
+ logger.debug('[API] Message structure:');
757
+ messages.forEach((msg, i) => {
758
+ const contentTypes = Array.isArray(msg.content)
759
+ ? msg.content.map(c => c.type || 'text').join(', ')
760
+ : (typeof msg.content === 'string' ? 'text' : 'unknown');
761
+ logger.debug(` [${i}] ${msg.role}: ${contentTypes}`);
762
+ });
763
+ }
764
+
765
+ if (stream) {
766
+ // Handle streaming response
767
+ res.setHeader('Content-Type', 'text/event-stream');
768
+ res.setHeader('Cache-Control', 'no-cache');
769
+ res.setHeader('Connection', 'keep-alive');
770
+ res.setHeader('X-Accel-Buffering', 'no');
771
+
772
+ // Flush headers immediately to start the stream
773
+ res.flushHeaders();
774
+
775
+ try {
776
+ // Use the streaming generator with account manager
777
+ for await (const event of sendMessageStream(request, accountManager, FALLBACK_ENABLED)) {
778
+ res.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
779
+ // Flush after each event for real-time streaming
780
+ if (res.flush) res.flush();
781
+ }
782
+ res.end();
783
+
784
+ } catch (streamError) {
785
+ logger.error('[API] Stream error:', streamError);
786
+
787
+ const { errorType, errorMessage } = parseError(streamError);
788
+
789
+ res.write(`event: error\ndata: ${JSON.stringify({
790
+ type: 'error',
791
+ error: { type: errorType, message: errorMessage }
792
+ })}\n\n`);
793
+ res.end();
794
+ }
795
+
796
+ } else {
797
+ // Handle non-streaming response
798
+ const response = await sendMessage(request, accountManager, FALLBACK_ENABLED);
799
+ res.json(response);
800
+ }
801
+
802
+ } catch (error) {
803
+ logger.error('[API] Error:', error);
804
+
805
+ let { errorType, statusCode, errorMessage } = parseError(error);
806
+
807
+ // For auth errors, try to refresh token
808
+ if (errorType === 'authentication_error') {
809
+ logger.warn('[API] Token might be expired, attempting refresh...');
810
+ try {
811
+ accountManager.clearProjectCache();
812
+ accountManager.clearTokenCache();
813
+ await forceRefresh();
814
+ errorMessage = 'Token was expired and has been refreshed. Please retry your request.';
815
+ } catch (refreshError) {
816
+ errorMessage = 'Could not refresh token. Make sure Antigravity is running.';
817
+ }
818
+ }
819
+
820
+ logger.warn(`[API] Returning error response: ${statusCode} ${errorType} - ${errorMessage}`);
821
+
822
+ // Check if headers have already been sent (for streaming that failed mid-way)
823
+ if (res.headersSent) {
824
+ logger.warn('[API] Headers already sent, writing error as SSE event');
825
+ res.write(`event: error\ndata: ${JSON.stringify({
826
+ type: 'error',
827
+ error: { type: errorType, message: errorMessage }
828
+ })}\n\n`);
829
+ res.end();
830
+ } else {
831
+ res.status(statusCode).json({
832
+ type: 'error',
833
+ error: {
834
+ type: errorType,
835
+ message: errorMessage
836
+ }
837
+ });
838
+ }
839
+ }
840
+ });
841
+
842
+ /**
843
+ * Catch-all for unsupported endpoints
844
+ */
845
+ usageStats.setupRoutes(app);
846
+
847
+ app.use('*', (req, res) => {
848
+ // Log 404s (use originalUrl since wildcard strips req.path)
849
+ if (logger.isDebugEnabled) {
850
+ logger.debug(`[API] 404 Not Found: ${req.method} ${req.originalUrl}`);
851
+ }
852
+ res.status(404).json({
853
+ type: 'error',
854
+ error: {
855
+ type: 'not_found_error',
856
+ message: `Endpoint ${req.method} ${req.originalUrl} not found`
857
+ }
858
+ });
859
+ });
860
+
861
+ export default app;