antigravity-claude-proxy 1.2.3 → 1.2.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "antigravity-claude-proxy",
3
- "version": "1.2.3",
3
+ "version": "1.2.4",
4
4
  "description": "Proxy server to use Antigravity's Claude models with Claude Code CLI",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -14,6 +14,7 @@ import {
14
14
  import { refreshAccessToken } from '../auth/oauth.js';
15
15
  import { getAuthStatus } from '../auth/database.js';
16
16
  import { logger } from '../utils/logger.js';
17
+ import { isNetworkError } from '../utils/helpers.js';
17
18
 
18
19
  /**
19
20
  * Get OAuth token for an account
@@ -48,6 +49,13 @@ export async function getTokenForAccount(account, tokenCache, onInvalid, onSave)
48
49
  }
49
50
  logger.success(`[AccountManager] Refreshed OAuth token for: ${account.email}`);
50
51
  } catch (error) {
52
+ // Check if it's a transient network error
53
+ if (isNetworkError(error)) {
54
+ logger.warn(`[AccountManager] Failed to refresh token for ${account.email} due to network error: ${error.message}`);
55
+ // Do NOT mark as invalid, just throw so caller knows it failed
56
+ throw new Error(`AUTH_NETWORK_ERROR: ${error.message}`);
57
+ }
58
+
51
59
  logger.error(`[AccountManager] Failed to refresh token for ${account.email}:`, error.message);
52
60
  // Mark account as invalid (credentials need re-auth)
53
61
  if (onInvalid) onInvalid(account.email, error.message);
@@ -81,18 +81,20 @@ export class AccountManager {
81
81
 
82
82
  /**
83
83
  * Check if all accounts are rate-limited
84
+ * @param {string} [modelId] - Optional model ID
84
85
  * @returns {boolean} True if all accounts are rate-limited
85
86
  */
86
- isAllRateLimited() {
87
- return checkAllRateLimited(this.#accounts);
87
+ isAllRateLimited(modelId = null) {
88
+ return checkAllRateLimited(this.#accounts, modelId);
88
89
  }
89
90
 
90
91
  /**
91
92
  * Get list of available (non-rate-limited, non-invalid) accounts
93
+ * @param {string} [modelId] - Optional model ID
92
94
  * @returns {Array<Object>} Array of available account objects
93
95
  */
94
- getAvailableAccounts() {
95
- return getAvailable(this.#accounts);
96
+ getAvailableAccounts(modelId = null) {
97
+ return getAvailable(this.#accounts, modelId);
96
98
  }
97
99
 
98
100
  /**
@@ -127,10 +129,11 @@ export class AccountManager {
127
129
  /**
128
130
  * Pick the next available account (fallback when current is unavailable).
129
131
  * Sets activeIndex to the selected account's index.
132
+ * @param {string} [modelId] - Optional model ID
130
133
  * @returns {Object|null} The next available account or null if none available
131
134
  */
132
- pickNext() {
133
- const { account, newIndex } = selectNext(this.#accounts, this.#currentIndex, () => this.saveToDisk());
135
+ pickNext(modelId = null) {
136
+ const { account, newIndex } = selectNext(this.#accounts, this.#currentIndex, () => this.saveToDisk(), modelId);
134
137
  this.#currentIndex = newIndex;
135
138
  return account;
136
139
  }
@@ -138,10 +141,11 @@ export class AccountManager {
138
141
  /**
139
142
  * Get the current account without advancing the index (sticky selection).
140
143
  * Used for cache continuity - sticks to the same account until rate-limited.
144
+ * @param {string} [modelId] - Optional model ID
141
145
  * @returns {Object|null} The current account or null if unavailable/rate-limited
142
146
  */
143
- getCurrentStickyAccount() {
144
- const { account, newIndex } = getSticky(this.#accounts, this.#currentIndex, () => this.saveToDisk());
147
+ getCurrentStickyAccount(modelId = null) {
148
+ const { account, newIndex } = getSticky(this.#accounts, this.#currentIndex, () => this.saveToDisk(), modelId);
145
149
  this.#currentIndex = newIndex;
146
150
  return account;
147
151
  }
@@ -149,10 +153,11 @@ export class AccountManager {
149
153
  /**
150
154
  * Check if we should wait for the current account's rate limit to reset.
151
155
  * Used for sticky account selection - wait if rate limit is short (≤ threshold).
156
+ * @param {string} [modelId] - Optional model ID
152
157
  * @returns {{shouldWait: boolean, waitMs: number, account: Object|null}}
153
158
  */
154
- shouldWaitForCurrentAccount() {
155
- return shouldWait(this.#accounts, this.#currentIndex);
159
+ shouldWaitForCurrentAccount(modelId = null) {
160
+ return shouldWait(this.#accounts, this.#currentIndex, modelId);
156
161
  }
157
162
 
158
163
  /**
@@ -160,10 +165,11 @@ export class AccountManager {
160
165
  * Prefers the current account for cache continuity, only switches when:
161
166
  * - Current account is rate-limited for > 2 minutes
162
167
  * - Current account is invalid
168
+ * @param {string} [modelId] - Optional model ID
163
169
  * @returns {{account: Object|null, waitMs: number}} Account to use and optional wait time
164
170
  */
165
- pickStickyAccount() {
166
- const { account, waitMs, newIndex } = selectSticky(this.#accounts, this.#currentIndex, () => this.saveToDisk());
171
+ pickStickyAccount(modelId = null) {
172
+ const { account, waitMs, newIndex } = selectSticky(this.#accounts, this.#currentIndex, () => this.saveToDisk(), modelId);
167
173
  this.#currentIndex = newIndex;
168
174
  return { account, waitMs };
169
175
  }
@@ -172,9 +178,10 @@ export class AccountManager {
172
178
  * Mark an account as rate-limited
173
179
  * @param {string} email - Email of the account to mark
174
180
  * @param {number|null} resetMs - Time in ms until rate limit resets (optional)
181
+ * @param {string} [modelId] - Optional model ID to mark specific limit
175
182
  */
176
- markRateLimited(email, resetMs = null) {
177
- markLimited(this.#accounts, email, resetMs, this.#settings);
183
+ markRateLimited(email, resetMs = null, modelId = null) {
184
+ markLimited(this.#accounts, email, resetMs, this.#settings, modelId);
178
185
  this.saveToDisk();
179
186
  }
180
187
 
@@ -190,10 +197,11 @@ export class AccountManager {
190
197
 
191
198
  /**
192
199
  * Get the minimum wait time until any account becomes available
200
+ * @param {string} [modelId] - Optional model ID
193
201
  * @returns {number} Wait time in milliseconds
194
202
  */
195
- getMinWaitTimeMs() {
196
- return getMinWait(this.#accounts);
203
+ getMinWaitTimeMs(modelId = null) {
204
+ return getMinWait(this.#accounts, modelId);
197
205
  }
198
206
 
199
207
  /**
@@ -251,9 +259,16 @@ export class AccountManager {
251
259
  */
252
260
  getStatus() {
253
261
  const available = this.getAvailableAccounts();
254
- const rateLimited = this.#accounts.filter(a => a.isRateLimited);
255
262
  const invalid = this.getInvalidAccounts();
256
263
 
264
+ // Count accounts that have any active model-specific rate limits
265
+ const rateLimited = this.#accounts.filter(a => {
266
+ if (!a.modelRateLimits) return false;
267
+ return Object.values(a.modelRateLimits).some(
268
+ limit => limit.isRateLimited && limit.resetTime > Date.now()
269
+ );
270
+ });
271
+
257
272
  return {
258
273
  total: this.#accounts.length,
259
274
  available: available.length,
@@ -263,8 +278,7 @@ export class AccountManager {
263
278
  accounts: this.#accounts.map(a => ({
264
279
  email: a.email,
265
280
  source: a.source,
266
- isRateLimited: a.isRateLimited,
267
- rateLimitResetTime: a.rateLimitResetTime,
281
+ modelRateLimits: a.modelRateLimits || {},
268
282
  isInvalid: a.isInvalid || false,
269
283
  invalidReason: a.invalidReason || null,
270
284
  lastUsed: a.lastUsed
@@ -2,6 +2,7 @@
2
2
  * Rate Limit Management
3
3
  *
4
4
  * Handles rate limit tracking and state management for accounts.
5
+ * All rate limits are model-specific.
5
6
  */
6
7
 
7
8
  import { DEFAULT_COOLDOWN_MS } from '../constants.js';
@@ -9,24 +10,44 @@ import { formatDuration } from '../utils/helpers.js';
9
10
  import { logger } from '../utils/logger.js';
10
11
 
11
12
  /**
12
- * Check if all accounts are rate-limited
13
+ * Check if all accounts are rate-limited for a specific model
13
14
  *
14
15
  * @param {Array} accounts - Array of account objects
16
+ * @param {string} modelId - Model ID to check rate limits for
15
17
  * @returns {boolean} True if all accounts are rate-limited
16
18
  */
17
- export function isAllRateLimited(accounts) {
19
+ export function isAllRateLimited(accounts, modelId) {
18
20
  if (accounts.length === 0) return true;
19
- return accounts.every(acc => acc.isRateLimited);
21
+ if (!modelId) return false; // No model specified = not rate limited
22
+
23
+ return accounts.every(acc => {
24
+ if (acc.isInvalid) return true; // Invalid accounts count as unavailable
25
+ const modelLimits = acc.modelRateLimits || {};
26
+ const limit = modelLimits[modelId];
27
+ return limit && limit.isRateLimited && limit.resetTime > Date.now();
28
+ });
20
29
  }
21
30
 
22
31
  /**
23
- * Get list of available (non-rate-limited, non-invalid) accounts
32
+ * Get list of available (non-rate-limited, non-invalid) accounts for a model
24
33
  *
25
34
  * @param {Array} accounts - Array of account objects
35
+ * @param {string} [modelId] - Model ID to filter by
26
36
  * @returns {Array} Array of available account objects
27
37
  */
28
- export function getAvailableAccounts(accounts) {
29
- return accounts.filter(acc => !acc.isRateLimited && !acc.isInvalid);
38
+ export function getAvailableAccounts(accounts, modelId = null) {
39
+ return accounts.filter(acc => {
40
+ if (acc.isInvalid) return false;
41
+
42
+ if (modelId && acc.modelRateLimits && acc.modelRateLimits[modelId]) {
43
+ const limit = acc.modelRateLimits[modelId];
44
+ if (limit.isRateLimited && limit.resetTime > Date.now()) {
45
+ return false;
46
+ }
47
+ }
48
+
49
+ return true;
50
+ });
30
51
  }
31
52
 
32
53
  /**
@@ -50,11 +71,15 @@ export function clearExpiredLimits(accounts) {
50
71
  let cleared = 0;
51
72
 
52
73
  for (const account of accounts) {
53
- if (account.isRateLimited && account.rateLimitResetTime && account.rateLimitResetTime <= now) {
54
- account.isRateLimited = false;
55
- account.rateLimitResetTime = null;
56
- cleared++;
57
- logger.success(`[AccountManager] Rate limit expired for: ${account.email}`);
74
+ if (account.modelRateLimits) {
75
+ for (const [modelId, limit] of Object.entries(account.modelRateLimits)) {
76
+ if (limit.isRateLimited && limit.resetTime <= now) {
77
+ limit.isRateLimited = false;
78
+ limit.resetTime = null;
79
+ cleared++;
80
+ logger.success(`[AccountManager] Rate limit expired for: ${account.email} (model: ${modelId})`);
81
+ }
82
+ }
58
83
  }
59
84
  }
60
85
 
@@ -68,31 +93,43 @@ export function clearExpiredLimits(accounts) {
68
93
  */
69
94
  export function resetAllRateLimits(accounts) {
70
95
  for (const account of accounts) {
71
- account.isRateLimited = false;
72
- account.rateLimitResetTime = null;
96
+ if (account.modelRateLimits) {
97
+ for (const key of Object.keys(account.modelRateLimits)) {
98
+ account.modelRateLimits[key] = { isRateLimited: false, resetTime: null };
99
+ }
100
+ }
73
101
  }
74
102
  logger.warn('[AccountManager] Reset all rate limits for optimistic retry');
75
103
  }
76
104
 
77
105
  /**
78
- * Mark an account as rate-limited
106
+ * Mark an account as rate-limited for a specific model
79
107
  *
80
108
  * @param {Array} accounts - Array of account objects
81
109
  * @param {string} email - Email of the account to mark
82
- * @param {number|null} resetMs - Time in ms until rate limit resets (optional)
110
+ * @param {number|null} resetMs - Time in ms until rate limit resets
83
111
  * @param {Object} settings - Settings object with cooldownDurationMs
112
+ * @param {string} modelId - Model ID to mark rate limit for
84
113
  * @returns {boolean} True if account was found and marked
85
114
  */
86
- export function markRateLimited(accounts, email, resetMs = null, settings = {}) {
115
+ export function markRateLimited(accounts, email, resetMs = null, settings = {}, modelId) {
87
116
  const account = accounts.find(a => a.email === email);
88
117
  if (!account) return false;
89
118
 
90
- account.isRateLimited = true;
91
119
  const cooldownMs = resetMs || settings.cooldownDurationMs || DEFAULT_COOLDOWN_MS;
92
- account.rateLimitResetTime = Date.now() + cooldownMs;
120
+ const resetTime = Date.now() + cooldownMs;
121
+
122
+ if (!account.modelRateLimits) {
123
+ account.modelRateLimits = {};
124
+ }
125
+
126
+ account.modelRateLimits[modelId] = {
127
+ isRateLimited: true,
128
+ resetTime: resetTime
129
+ };
93
130
 
94
131
  logger.warn(
95
- `[AccountManager] Rate limited: ${email}. Available in ${formatDuration(cooldownMs)}`
132
+ `[AccountManager] Rate limited: ${email} (model: ${modelId}). Available in ${formatDuration(cooldownMs)}`
96
133
  );
97
134
 
98
135
  return true;
@@ -128,24 +165,28 @@ export function markInvalid(accounts, email, reason = 'Unknown error') {
128
165
  }
129
166
 
130
167
  /**
131
- * Get the minimum wait time until any account becomes available
168
+ * Get the minimum wait time until any account becomes available for a model
132
169
  *
133
170
  * @param {Array} accounts - Array of account objects
171
+ * @param {string} modelId - Model ID to check
134
172
  * @returns {number} Wait time in milliseconds
135
173
  */
136
- export function getMinWaitTimeMs(accounts) {
137
- if (!isAllRateLimited(accounts)) return 0;
174
+ export function getMinWaitTimeMs(accounts, modelId) {
175
+ if (!isAllRateLimited(accounts, modelId)) return 0;
138
176
 
139
177
  const now = Date.now();
140
178
  let minWait = Infinity;
141
179
  let soonestAccount = null;
142
180
 
143
181
  for (const account of accounts) {
144
- if (account.rateLimitResetTime) {
145
- const wait = account.rateLimitResetTime - now;
146
- if (wait > 0 && wait < minWait) {
147
- minWait = wait;
148
- soonestAccount = account;
182
+ if (modelId && account.modelRateLimits && account.modelRateLimits[modelId]) {
183
+ const limit = account.modelRateLimits[modelId];
184
+ if (limit.isRateLimited && limit.resetTime) {
185
+ const wait = limit.resetTime - now;
186
+ if (wait > 0 && wait < minWait) {
187
+ minWait = wait;
188
+ soonestAccount = account;
189
+ }
149
190
  }
150
191
  }
151
192
  }
@@ -2,6 +2,7 @@
2
2
  * Account Selection
3
3
  *
4
4
  * Handles account picking logic (round-robin, sticky) for cache continuity.
5
+ * All rate limit checks are model-specific.
5
6
  */
6
7
 
7
8
  import { MAX_WAIT_BEFORE_ERROR_MS } from '../constants.js';
@@ -9,18 +10,38 @@ import { formatDuration } from '../utils/helpers.js';
9
10
  import { logger } from '../utils/logger.js';
10
11
  import { clearExpiredLimits, getAvailableAccounts } from './rate-limits.js';
11
12
 
13
+ /**
14
+ * Check if an account is usable for a specific model
15
+ * @param {Object} account - Account object
16
+ * @param {string} modelId - Model ID to check
17
+ * @returns {boolean} True if account is usable
18
+ */
19
+ function isAccountUsable(account, modelId) {
20
+ if (!account || account.isInvalid) return false;
21
+
22
+ if (modelId && account.modelRateLimits && account.modelRateLimits[modelId]) {
23
+ const limit = account.modelRateLimits[modelId];
24
+ if (limit.isRateLimited && limit.resetTime > Date.now()) {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ return true;
30
+ }
31
+
12
32
  /**
13
33
  * Pick the next available account (fallback when current is unavailable).
14
34
  *
15
35
  * @param {Array} accounts - Array of account objects
16
36
  * @param {number} currentIndex - Current account index
17
37
  * @param {Function} onSave - Callback to save changes
38
+ * @param {string} [modelId] - Model ID to check rate limits for
18
39
  * @returns {{account: Object|null, newIndex: number}} The next available account and new index
19
40
  */
20
- export function pickNext(accounts, currentIndex, onSave) {
41
+ export function pickNext(accounts, currentIndex, onSave, modelId = null) {
21
42
  clearExpiredLimits(accounts);
22
43
 
23
- const available = getAvailableAccounts(accounts);
44
+ const available = getAvailableAccounts(accounts, modelId);
24
45
  if (available.length === 0) {
25
46
  return { account: null, newIndex: currentIndex };
26
47
  }
@@ -36,7 +57,7 @@ export function pickNext(accounts, currentIndex, onSave) {
36
57
  const idx = (index + i) % accounts.length;
37
58
  const account = accounts[idx];
38
59
 
39
- if (!account.isRateLimited && !account.isInvalid) {
60
+ if (isAccountUsable(account, modelId)) {
40
61
  account.lastUsed = Date.now();
41
62
 
42
63
  const position = idx + 1;
@@ -59,9 +80,10 @@ export function pickNext(accounts, currentIndex, onSave) {
59
80
  * @param {Array} accounts - Array of account objects
60
81
  * @param {number} currentIndex - Current account index
61
82
  * @param {Function} onSave - Callback to save changes
83
+ * @param {string} [modelId] - Model ID to check rate limits for
62
84
  * @returns {{account: Object|null, newIndex: number}} The current account and index
63
85
  */
64
- export function getCurrentStickyAccount(accounts, currentIndex, onSave) {
86
+ export function getCurrentStickyAccount(accounts, currentIndex, onSave, modelId = null) {
65
87
  clearExpiredLimits(accounts);
66
88
 
67
89
  if (accounts.length === 0) {
@@ -77,8 +99,7 @@ export function getCurrentStickyAccount(accounts, currentIndex, onSave) {
77
99
  // Get current account directly (activeIndex = current account)
78
100
  const account = accounts[index];
79
101
 
80
- // Return if available
81
- if (account && !account.isRateLimited && !account.isInvalid) {
102
+ if (isAccountUsable(account, modelId)) {
82
103
  account.lastUsed = Date.now();
83
104
  // Trigger save (don't await to avoid blocking)
84
105
  if (onSave) onSave();
@@ -93,9 +114,10 @@ export function getCurrentStickyAccount(accounts, currentIndex, onSave) {
93
114
  *
94
115
  * @param {Array} accounts - Array of account objects
95
116
  * @param {number} currentIndex - Current account index
117
+ * @param {string} [modelId] - Model ID to check rate limits for
96
118
  * @returns {{shouldWait: boolean, waitMs: number, account: Object|null}}
97
119
  */
98
- export function shouldWaitForCurrentAccount(accounts, currentIndex) {
120
+ export function shouldWaitForCurrentAccount(accounts, currentIndex, modelId = null) {
99
121
  if (accounts.length === 0) {
100
122
  return { shouldWait: false, waitMs: 0, account: null };
101
123
  }
@@ -113,15 +135,21 @@ export function shouldWaitForCurrentAccount(accounts, currentIndex) {
113
135
  return { shouldWait: false, waitMs: 0, account: null };
114
136
  }
115
137
 
116
- if (account.isRateLimited && account.rateLimitResetTime) {
117
- const waitMs = account.rateLimitResetTime - Date.now();
138
+ let waitMs = 0;
118
139
 
119
- // If wait time is within threshold, recommend waiting
120
- if (waitMs > 0 && waitMs <= MAX_WAIT_BEFORE_ERROR_MS) {
121
- return { shouldWait: true, waitMs, account };
140
+ // Check model-specific limit
141
+ if (modelId && account.modelRateLimits && account.modelRateLimits[modelId]) {
142
+ const limit = account.modelRateLimits[modelId];
143
+ if (limit.isRateLimited && limit.resetTime) {
144
+ waitMs = limit.resetTime - Date.now();
122
145
  }
123
146
  }
124
147
 
148
+ // If wait time is within threshold, recommend waiting
149
+ if (waitMs > 0 && waitMs <= MAX_WAIT_BEFORE_ERROR_MS) {
150
+ return { shouldWait: true, waitMs, account };
151
+ }
152
+
125
153
  return { shouldWait: false, waitMs: 0, account };
126
154
  }
127
155
 
@@ -132,21 +160,22 @@ export function shouldWaitForCurrentAccount(accounts, currentIndex) {
132
160
  * @param {Array} accounts - Array of account objects
133
161
  * @param {number} currentIndex - Current account index
134
162
  * @param {Function} onSave - Callback to save changes
163
+ * @param {string} [modelId] - Model ID to check rate limits for
135
164
  * @returns {{account: Object|null, waitMs: number, newIndex: number}}
136
165
  */
137
- export function pickStickyAccount(accounts, currentIndex, onSave) {
166
+ export function pickStickyAccount(accounts, currentIndex, onSave, modelId = null) {
138
167
  // First try to get the current sticky account
139
- const { account: stickyAccount, newIndex: stickyIndex } = getCurrentStickyAccount(accounts, currentIndex, onSave);
168
+ const { account: stickyAccount, newIndex: stickyIndex } = getCurrentStickyAccount(accounts, currentIndex, onSave, modelId);
140
169
  if (stickyAccount) {
141
170
  return { account: stickyAccount, waitMs: 0, newIndex: stickyIndex };
142
171
  }
143
172
 
144
173
  // Current account is rate-limited or invalid.
145
174
  // CHECK IF OTHERS ARE AVAILABLE before deciding to wait.
146
- const available = getAvailableAccounts(accounts);
175
+ const available = getAvailableAccounts(accounts, modelId);
147
176
  if (available.length > 0) {
148
177
  // Found a free account! Switch immediately.
149
- const { account: nextAccount, newIndex } = pickNext(accounts, currentIndex, onSave);
178
+ const { account: nextAccount, newIndex } = pickNext(accounts, currentIndex, onSave, modelId);
150
179
  if (nextAccount) {
151
180
  logger.info(`[AccountManager] Switched to new account (failover): ${nextAccount.email}`);
152
181
  return { account: nextAccount, waitMs: 0, newIndex };
@@ -154,14 +183,14 @@ export function pickStickyAccount(accounts, currentIndex, onSave) {
154
183
  }
155
184
 
156
185
  // No other accounts available. Now checking if we should wait for current account.
157
- const waitInfo = shouldWaitForCurrentAccount(accounts, currentIndex);
186
+ const waitInfo = shouldWaitForCurrentAccount(accounts, currentIndex, modelId);
158
187
  if (waitInfo.shouldWait) {
159
188
  logger.info(`[AccountManager] Waiting ${formatDuration(waitInfo.waitMs)} for sticky account: ${waitInfo.account.email}`);
160
189
  return { account: null, waitMs: waitInfo.waitMs, newIndex: currentIndex };
161
190
  }
162
191
 
163
192
  // Current account unavailable for too long/invalid, and no others available?
164
- const { account: nextAccount, newIndex } = pickNext(accounts, currentIndex, onSave);
193
+ const { account: nextAccount, newIndex } = pickNext(accounts, currentIndex, onSave, modelId);
165
194
  if (nextAccount) {
166
195
  logger.info(`[AccountManager] Switched to new account for cache: ${nextAccount.email}`);
167
196
  }
@@ -26,12 +26,11 @@ export async function loadAccounts(configPath = ACCOUNT_CONFIG_PATH) {
26
26
 
27
27
  const accounts = (config.accounts || []).map(acc => ({
28
28
  ...acc,
29
- isRateLimited: acc.isRateLimited || false,
30
- rateLimitResetTime: acc.rateLimitResetTime || null,
31
29
  lastUsed: acc.lastUsed || null,
32
30
  // Reset invalid flag on startup - give accounts a fresh chance to refresh
33
31
  isInvalid: false,
34
- invalidReason: null
32
+ invalidReason: null,
33
+ modelRateLimits: acc.modelRateLimits || {}
35
34
  }));
36
35
 
37
36
  const settings = config.settings || {};
@@ -69,9 +68,8 @@ export function loadDefaultAccount(dbPath) {
69
68
  const account = {
70
69
  email: authData.email || 'default@antigravity',
71
70
  source: 'database',
72
- isRateLimited: false,
73
- rateLimitResetTime: null,
74
- lastUsed: null
71
+ lastUsed: null,
72
+ modelRateLimits: {}
75
73
  };
76
74
 
77
75
  const tokenCache = new Map();
@@ -114,10 +112,9 @@ export async function saveAccounts(configPath, accounts, settings, activeIndex)
114
112
  apiKey: acc.source === 'manual' ? acc.apiKey : undefined,
115
113
  projectId: acc.projectId || undefined,
116
114
  addedAt: acc.addedAt || undefined,
117
- isRateLimited: acc.isRateLimited,
118
- rateLimitResetTime: acc.rateLimitResetTime,
119
115
  isInvalid: acc.isInvalid || false,
120
116
  invalidReason: acc.invalidReason || null,
117
+ modelRateLimits: acc.modelRateLimits || {},
121
118
  lastUsed: acc.lastUsed
122
119
  })),
123
120
  settings: settings,
@@ -138,8 +138,7 @@ function saveAccounts(accounts, settings = {}) {
138
138
  projectId: acc.projectId,
139
139
  addedAt: acc.addedAt || new Date().toISOString(),
140
140
  lastUsed: acc.lastUsed || null,
141
- isRateLimited: acc.isRateLimited || false,
142
- rateLimitResetTime: acc.rateLimitResetTime || null
141
+ modelRateLimits: acc.modelRateLimits || {}
143
142
  })),
144
143
  settings: {
145
144
  cooldownDurationMs: 60000,
@@ -168,7 +167,11 @@ function displayAccounts(accounts) {
168
167
 
169
168
  console.log(`\n${accounts.length} account(s) saved:`);
170
169
  accounts.forEach((acc, i) => {
171
- const status = acc.isRateLimited ? ' (rate-limited)' : '';
170
+ // Check for any active model-specific rate limits
171
+ const hasActiveLimit = Object.values(acc.modelRateLimits || {}).some(
172
+ limit => limit.isRateLimited && limit.resetTime > Date.now()
173
+ );
174
+ const status = hasActiveLimit ? ' (rate-limited)' : '';
172
175
  console.log(` ${i + 1}. ${acc.email}${status}`);
173
176
  });
174
177
  }
@@ -218,8 +221,7 @@ async function addAccount(existingAccounts) {
218
221
  refreshToken: result.refreshToken,
219
222
  projectId: result.projectId,
220
223
  addedAt: new Date().toISOString(),
221
- isRateLimited: false,
222
- rateLimitResetTime: null
224
+ modelRateLimits: {}
223
225
  };
224
226
  } catch (error) {
225
227
  console.error(`\n✗ Authentication failed: ${error.message}`);
@@ -280,7 +282,7 @@ async function interactiveAdd(rl) {
280
282
  if (accounts.length > 0) {
281
283
  displayAccounts(accounts);
282
284
 
283
- const choice = await rl.question('\n(a)dd new, (r)emove existing, or (f)resh start? [a/r/f]: ');
285
+ const choice = await rl.question('\n(a)dd new, (r)emove existing, (f)resh start, or (e)xit? [a/r/f/e]: ');
284
286
  const c = choice.toLowerCase();
285
287
 
286
288
  if (c === 'r') {
@@ -291,36 +293,32 @@ async function interactiveAdd(rl) {
291
293
  accounts.length = 0;
292
294
  } else if (c === 'a') {
293
295
  console.log('\nAdding to existing accounts.');
296
+ } else if (c === 'e') {
297
+ console.log('\nExiting...');
298
+ return; // Exit cleanly
294
299
  } else {
295
300
  console.log('\nInvalid choice, defaulting to add.');
296
301
  }
297
302
  }
298
303
 
299
- // Add accounts loop
300
- while (accounts.length < MAX_ACCOUNTS) {
301
- const newAccount = await addAccount(accounts);
302
- if (newAccount) {
303
- accounts.push(newAccount);
304
- // Auto-save after each successful add to prevent data loss
305
- saveAccounts(accounts);
306
- } else if (accounts.length > 0) {
307
- // Even if newAccount is null (duplicate update), save the updated accounts
308
- saveAccounts(accounts);
309
- }
310
-
311
- if (accounts.length >= MAX_ACCOUNTS) {
312
- console.log(`\nMaximum of ${MAX_ACCOUNTS} accounts reached.`);
313
- break;
314
- }
304
+ // Add single account
305
+ if (accounts.length >= MAX_ACCOUNTS) {
306
+ console.log(`\nMaximum of ${MAX_ACCOUNTS} accounts reached.`);
307
+ return;
308
+ }
315
309
 
316
- const addMore = await rl.question('\nAdd another account? [y/N]: ');
317
- if (addMore.toLowerCase() !== 'y') {
318
- break;
319
- }
310
+ const newAccount = await addAccount(accounts);
311
+ if (newAccount) {
312
+ accounts.push(newAccount);
313
+ saveAccounts(accounts);
314
+ } else if (accounts.length > 0) {
315
+ // Even if newAccount is null (duplicate update), save the updated accounts
316
+ saveAccounts(accounts);
320
317
  }
321
318
 
322
319
  if (accounts.length > 0) {
323
320
  displayAccounts(accounts);
321
+ console.log('\nTo add more accounts, run this command again.');
324
322
  } else {
325
323
  console.log('\nNo accounts to save.');
326
324
  }
@@ -431,6 +429,8 @@ async function main() {
431
429
  }
432
430
  } finally {
433
431
  rl.close();
432
+ // Force exit to prevent hanging
433
+ process.exit(0);
434
434
  }
435
435
  }
436
436
 
@@ -13,28 +13,12 @@ import {
13
13
  } from '../constants.js';
14
14
  import { convertGoogleToAnthropic } from '../format/index.js';
15
15
  import { isRateLimitError, isAuthError } from '../errors.js';
16
- import { formatDuration, sleep } from '../utils/helpers.js';
16
+ import { formatDuration, sleep, isNetworkError } from '../utils/helpers.js';
17
17
  import { logger } from '../utils/logger.js';
18
18
  import { parseResetTime } from './rate-limit-parser.js';
19
19
  import { buildCloudCodeRequest, buildHeaders } from './request-builder.js';
20
20
  import { parseThinkingSSEResponse } from './sse-parser.js';
21
21
 
22
- /**
23
- * Check if an error is a rate limit error (429 or RESOURCE_EXHAUSTED)
24
- * @deprecated Use isRateLimitError from errors.js instead
25
- */
26
- function is429Error(error) {
27
- return isRateLimitError(error);
28
- }
29
-
30
- /**
31
- * Check if an error is an auth-invalid error (credentials need re-authentication)
32
- * @deprecated Use isAuthError from errors.js instead
33
- */
34
- function isAuthInvalidError(error) {
35
- return isAuthError(error);
36
- }
37
-
38
22
  /**
39
23
  * Send a non-streaming request to Cloud Code with multi-account support
40
24
  * Uses SSE endpoint for thinking models (non-streaming doesn't return thinking blocks)
@@ -59,7 +43,7 @@ export async function sendMessage(anthropicRequest, accountManager) {
59
43
 
60
44
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
61
45
  // Use sticky account selection for cache continuity
62
- const { account: stickyAccount, waitMs } = accountManager.pickStickyAccount();
46
+ const { account: stickyAccount, waitMs } = accountManager.pickStickyAccount(model);
63
47
  let account = stickyAccount;
64
48
 
65
49
  // Handle waiting for sticky account
@@ -67,19 +51,19 @@ export async function sendMessage(anthropicRequest, accountManager) {
67
51
  logger.info(`[CloudCode] Waiting ${formatDuration(waitMs)} for sticky account...`);
68
52
  await sleep(waitMs);
69
53
  accountManager.clearExpiredLimits();
70
- account = accountManager.getCurrentStickyAccount();
54
+ account = accountManager.getCurrentStickyAccount(model);
71
55
  }
72
56
 
73
57
  // Handle all accounts rate-limited
74
58
  if (!account) {
75
- if (accountManager.isAllRateLimited()) {
76
- const allWaitMs = accountManager.getMinWaitTimeMs();
59
+ if (accountManager.isAllRateLimited(model)) {
60
+ const allWaitMs = accountManager.getMinWaitTimeMs(model);
77
61
  const resetTime = new Date(Date.now() + allWaitMs).toISOString();
78
62
 
79
63
  // If wait time is too long (> 2 minutes), throw error immediately
80
64
  if (allWaitMs > MAX_WAIT_BEFORE_ERROR_MS) {
81
65
  throw new Error(
82
- `RESOURCE_EXHAUSTED: Rate limited. Quota will reset after ${formatDuration(allWaitMs)}. Next available: ${resetTime}`
66
+ `RESOURCE_EXHAUSTED: Rate limited on ${model}. Quota will reset after ${formatDuration(allWaitMs)}. Next available: ${resetTime}`
83
67
  );
84
68
  }
85
69
 
@@ -88,7 +72,7 @@ export async function sendMessage(anthropicRequest, accountManager) {
88
72
  logger.warn(`[CloudCode] All ${accountCount} account(s) rate-limited. Waiting ${formatDuration(allWaitMs)}...`);
89
73
  await sleep(allWaitMs);
90
74
  accountManager.clearExpiredLimits();
91
- account = accountManager.pickNext();
75
+ account = accountManager.pickNext(model);
92
76
  }
93
77
 
94
78
  if (!account) {
@@ -163,7 +147,7 @@ export async function sendMessage(anthropicRequest, accountManager) {
163
147
  return convertGoogleToAnthropic(data, anthropicRequest.model);
164
148
 
165
149
  } catch (endpointError) {
166
- if (is429Error(endpointError)) {
150
+ if (isRateLimitError(endpointError)) {
167
151
  throw endpointError; // Re-throw to trigger account switch
168
152
  }
169
153
  logger.warn(`[CloudCode] Error at ${endpoint}:`, endpointError.message);
@@ -176,19 +160,19 @@ export async function sendMessage(anthropicRequest, accountManager) {
176
160
  // If all endpoints returned 429, mark account as rate-limited
177
161
  if (lastError.is429) {
178
162
  logger.warn(`[CloudCode] All endpoints rate-limited for ${account.email}`);
179
- accountManager.markRateLimited(account.email, lastError.resetMs);
163
+ accountManager.markRateLimited(account.email, lastError.resetMs, model);
180
164
  throw new Error(`Rate limited: ${lastError.errorText}`);
181
165
  }
182
166
  throw lastError;
183
167
  }
184
168
 
185
169
  } catch (error) {
186
- if (is429Error(error)) {
170
+ if (isRateLimitError(error)) {
187
171
  // Rate limited - already marked, continue to next account
188
172
  logger.info(`[CloudCode] Account ${account.email} rate-limited, trying next...`);
189
173
  continue;
190
174
  }
191
- if (isAuthInvalidError(error)) {
175
+ if (isAuthError(error)) {
192
176
  // Auth invalid - already marked, continue to next account
193
177
  logger.warn(`[CloudCode] Account ${account.email} has invalid credentials, trying next...`);
194
178
  continue;
@@ -197,10 +181,17 @@ export async function sendMessage(anthropicRequest, accountManager) {
197
181
  // UNLESS it's a 500 error, then we treat it as a "soft" failure for this account and try the next one
198
182
  if (error.message.includes('API error 5') || error.message.includes('500') || error.message.includes('503')) {
199
183
  logger.warn(`[CloudCode] Account ${account.email} failed with 5xx error, trying next...`);
200
- accountManager.pickNext(); // Force advance to next account
184
+ accountManager.pickNext(model); // Force advance to next account
201
185
  continue;
202
186
  }
203
187
 
188
+ if (isNetworkError(error)) {
189
+ logger.warn(`[CloudCode] Network error for ${account.email}, trying next account... (${error.message})`);
190
+ await sleep(1000); // Brief pause before retry
191
+ accountManager.pickNext(model); // Advance to next account
192
+ continue;
193
+ }
194
+
204
195
  throw error;
205
196
  }
206
197
  }
@@ -4,9 +4,19 @@
4
4
  * Handles model listing and quota retrieval from the Cloud Code API.
5
5
  */
6
6
 
7
- import { ANTIGRAVITY_ENDPOINT_FALLBACKS, ANTIGRAVITY_HEADERS } from '../constants.js';
7
+ import { ANTIGRAVITY_ENDPOINT_FALLBACKS, ANTIGRAVITY_HEADERS, getModelFamily } from '../constants.js';
8
8
  import { logger } from '../utils/logger.js';
9
9
 
10
+ /**
11
+ * Check if a model is supported (Claude or Gemini)
12
+ * @param {string} modelId - Model ID to check
13
+ * @returns {boolean} True if model is supported
14
+ */
15
+ function isSupportedModel(modelId) {
16
+ const family = getModelFamily(modelId);
17
+ return family === 'claude' || family === 'gemini';
18
+ }
19
+
10
20
  /**
11
21
  * List available models in Anthropic API format
12
22
  * Fetches models dynamically from the Cloud Code API
@@ -20,7 +30,9 @@ export async function listModels(token) {
20
30
  return { object: 'list', data: [] };
21
31
  }
22
32
 
23
- const modelList = Object.entries(data.models).map(([modelId, modelData]) => ({
33
+ const modelList = Object.entries(data.models)
34
+ .filter(([modelId]) => isSupportedModel(modelId))
35
+ .map(([modelId, modelData]) => ({
24
36
  id: modelId,
25
37
  object: 'model',
26
38
  created: Math.floor(Date.now() / 1000),
@@ -85,6 +97,9 @@ export async function getModelQuotas(token) {
85
97
 
86
98
  const quotas = {};
87
99
  for (const [modelId, modelData] of Object.entries(data.models)) {
100
+ // Only include Claude and Gemini models
101
+ if (!isSupportedModel(modelId)) continue;
102
+
88
103
  if (modelData.quotaInfo) {
89
104
  quotas[modelId] = {
90
105
  remainingFraction: modelData.quotaInfo.remainingFraction ?? null,
@@ -11,27 +11,12 @@ import {
11
11
  MAX_WAIT_BEFORE_ERROR_MS
12
12
  } from '../constants.js';
13
13
  import { isRateLimitError, isAuthError } from '../errors.js';
14
- import { formatDuration, sleep } from '../utils/helpers.js';
14
+ import { formatDuration, sleep, isNetworkError } from '../utils/helpers.js';
15
15
  import { logger } from '../utils/logger.js';
16
16
  import { parseResetTime } from './rate-limit-parser.js';
17
17
  import { buildCloudCodeRequest, buildHeaders } from './request-builder.js';
18
18
  import { streamSSEResponse } from './sse-streamer.js';
19
19
 
20
- /**
21
- * Check if an error is a rate limit error (429 or RESOURCE_EXHAUSTED)
22
- * @deprecated Use isRateLimitError from errors.js instead
23
- */
24
- function is429Error(error) {
25
- return isRateLimitError(error);
26
- }
27
-
28
- /**
29
- * Check if an error is an auth-invalid error (credentials need re-authentication)
30
- * @deprecated Use isAuthError from errors.js instead
31
- */
32
- function isAuthInvalidError(error) {
33
- return isAuthError(error);
34
- }
35
20
 
36
21
  /**
37
22
  * Send a streaming request to Cloud Code with multi-account support
@@ -56,7 +41,7 @@ export async function* sendMessageStream(anthropicRequest, accountManager) {
56
41
 
57
42
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
58
43
  // Use sticky account selection for cache continuity
59
- const { account: stickyAccount, waitMs } = accountManager.pickStickyAccount();
44
+ const { account: stickyAccount, waitMs } = accountManager.pickStickyAccount(model);
60
45
  let account = stickyAccount;
61
46
 
62
47
  // Handle waiting for sticky account
@@ -64,19 +49,19 @@ export async function* sendMessageStream(anthropicRequest, accountManager) {
64
49
  logger.info(`[CloudCode] Waiting ${formatDuration(waitMs)} for sticky account...`);
65
50
  await sleep(waitMs);
66
51
  accountManager.clearExpiredLimits();
67
- account = accountManager.getCurrentStickyAccount();
52
+ account = accountManager.getCurrentStickyAccount(model);
68
53
  }
69
54
 
70
55
  // Handle all accounts rate-limited
71
56
  if (!account) {
72
- if (accountManager.isAllRateLimited()) {
73
- const allWaitMs = accountManager.getMinWaitTimeMs();
57
+ if (accountManager.isAllRateLimited(model)) {
58
+ const allWaitMs = accountManager.getMinWaitTimeMs(model);
74
59
  const resetTime = new Date(Date.now() + allWaitMs).toISOString();
75
60
 
76
61
  // If wait time is too long (> 2 minutes), throw error immediately
77
62
  if (allWaitMs > MAX_WAIT_BEFORE_ERROR_MS) {
78
63
  throw new Error(
79
- `RESOURCE_EXHAUSTED: Rate limited. Quota will reset after ${formatDuration(allWaitMs)}. Next available: ${resetTime}`
64
+ `RESOURCE_EXHAUSTED: Rate limited on ${model}. Quota will reset after ${formatDuration(allWaitMs)}. Next available: ${resetTime}`
80
65
  );
81
66
  }
82
67
 
@@ -85,7 +70,7 @@ export async function* sendMessageStream(anthropicRequest, accountManager) {
85
70
  logger.warn(`[CloudCode] All ${accountCount} account(s) rate-limited. Waiting ${formatDuration(allWaitMs)}...`);
86
71
  await sleep(allWaitMs);
87
72
  accountManager.clearExpiredLimits();
88
- account = accountManager.pickNext();
73
+ account = accountManager.pickNext(model);
89
74
  }
90
75
 
91
76
  if (!account) {
@@ -153,7 +138,7 @@ export async function* sendMessageStream(anthropicRequest, accountManager) {
153
138
  return;
154
139
 
155
140
  } catch (endpointError) {
156
- if (is429Error(endpointError)) {
141
+ if (isRateLimitError(endpointError)) {
157
142
  throw endpointError; // Re-throw to trigger account switch
158
143
  }
159
144
  logger.warn(`[CloudCode] Stream error at ${endpoint}:`, endpointError.message);
@@ -166,19 +151,19 @@ export async function* sendMessageStream(anthropicRequest, accountManager) {
166
151
  // If all endpoints returned 429, mark account as rate-limited
167
152
  if (lastError.is429) {
168
153
  logger.warn(`[CloudCode] All endpoints rate-limited for ${account.email}`);
169
- accountManager.markRateLimited(account.email, lastError.resetMs);
154
+ accountManager.markRateLimited(account.email, lastError.resetMs, model);
170
155
  throw new Error(`Rate limited: ${lastError.errorText}`);
171
156
  }
172
157
  throw lastError;
173
158
  }
174
159
 
175
160
  } catch (error) {
176
- if (is429Error(error)) {
161
+ if (isRateLimitError(error)) {
177
162
  // Rate limited - already marked, continue to next account
178
163
  logger.info(`[CloudCode] Account ${account.email} rate-limited, trying next...`);
179
164
  continue;
180
165
  }
181
- if (isAuthInvalidError(error)) {
166
+ if (isAuthError(error)) {
182
167
  // Auth invalid - already marked, continue to next account
183
168
  logger.warn(`[CloudCode] Account ${account.email} has invalid credentials, trying next...`);
184
169
  continue;
@@ -187,10 +172,17 @@ export async function* sendMessageStream(anthropicRequest, accountManager) {
187
172
  // UNLESS it's a 500 error, then we treat it as a "soft" failure for this account and try the next one
188
173
  if (error.message.includes('API error 5') || error.message.includes('500') || error.message.includes('503')) {
189
174
  logger.warn(`[CloudCode] Account ${account.email} failed with 5xx stream error, trying next...`);
190
- accountManager.pickNext(); // Force advance to next account
175
+ accountManager.pickNext(model); // Force advance to next account
191
176
  continue;
192
177
  }
193
178
 
179
+ if (isNetworkError(error)) {
180
+ logger.warn(`[CloudCode] Network error for ${account.email} (stream), trying next account... (${error.message})`);
181
+ await sleep(1000); // Brief pause before retry
182
+ accountManager.pickNext(model); // Advance to next account
183
+ continue;
184
+ }
185
+
194
186
  throw error;
195
187
  }
196
188
  }
@@ -37,6 +37,8 @@ export function convertContentToParts(content, isClaudeModel = false, isGeminiMo
37
37
  const parts = [];
38
38
 
39
39
  for (const block of content) {
40
+ if (!block) continue;
41
+
40
42
  if (block.type === 'text') {
41
43
  // Skip empty text blocks - they cause API errors
42
44
  if (block.text && block.text.trim()) {
@@ -152,6 +152,16 @@ export function convertAnthropicToGoogle(anthropicRequest) {
152
152
  if (thinkingBudget) {
153
153
  thinkingConfig.thinking_budget = thinkingBudget;
154
154
  logger.debug(`[RequestConverter] Claude thinking enabled with budget: ${thinkingBudget}`);
155
+
156
+ // Validate max_tokens > thinking_budget as required by the API
157
+ const currentMaxTokens = googleRequest.generationConfig.maxOutputTokens;
158
+ if (currentMaxTokens && currentMaxTokens <= thinkingBudget) {
159
+ // Bump max_tokens to allow for some response content
160
+ // Default to budget + 8192 (standard output buffer)
161
+ const adjustedMaxTokens = thinkingBudget + 8192;
162
+ logger.warn(`[RequestConverter] max_tokens (${currentMaxTokens}) <= thinking_budget (${thinkingBudget}). Adjusting to ${adjustedMaxTokens} to satisfy API requirements`);
163
+ googleRequest.generationConfig.maxOutputTokens = adjustedMaxTokens;
164
+ }
155
165
  } else {
156
166
  logger.debug('[RequestConverter] Claude thinking enabled (no budget specified)');
157
167
  }
package/src/index.js CHANGED
@@ -30,29 +30,47 @@ app.listen(PORT, () => {
30
30
  // Clear console for a clean start
31
31
  console.clear();
32
32
 
33
+ const border = '║';
34
+ // align for 2-space indent (60 chars), align4 for 4-space indent (58 chars)
35
+ const align = (text) => text + ' '.repeat(Math.max(0, 60 - text.length));
36
+ const align4 = (text) => text + ' '.repeat(Math.max(0, 58 - text.length));
37
+
38
+ // Build Control section dynamically
39
+ let controlSection = '║ Control: ║\n';
40
+ if (!isDebug) {
41
+ controlSection += '║ --debug Enable debug logging ║\n';
42
+ }
43
+ controlSection += '║ Ctrl+C Stop server ║';
44
+
45
+ // Build status section if debug mode is active
46
+ let statusSection = '';
47
+ if (isDebug) {
48
+ statusSection = '║ ║\n';
49
+ statusSection += '║ Active Modes: ║\n';
50
+ statusSection += '║ ✓ Debug mode enabled ║\n';
51
+ }
52
+
33
53
  logger.log(`
34
54
  ╔══════════════════════════════════════════════════════════════╗
35
55
  ║ Antigravity Claude Proxy Server ║
36
56
  ╠══════════════════════════════════════════════════════════════╣
37
57
  ║ ║
38
- Server running at: http://localhost:${PORT}
39
- ║ ║
40
- ║ Control: ║
41
- ║ --debug Enable debug logging ║
42
- ║ Ctrl+C Stop server ║
58
+ ${border} ${align(`Server running at: http://localhost:${PORT}`)}${border}
59
+ ${statusSection}║ ║
60
+ ${controlSection}
43
61
  ║ ║
44
62
  ║ Endpoints: ║
45
- ║ POST /v1/messages - Anthropic Messages API
46
- ║ GET /v1/models - List available models
47
- ║ GET /health - Health check
48
- ║ GET /account-limits - Account status & quotas
49
- ║ POST /refresh-token - Force token refresh
63
+ ║ POST /v1/messages - Anthropic Messages API
64
+ ║ GET /v1/models - List available models
65
+ ║ GET /health - Health check
66
+ ║ GET /account-limits - Account status & quotas
67
+ ║ POST /refresh-token - Force token refresh
50
68
  ║ ║
51
- Configuration: ║
52
- Storage: ${CONFIG_DIR}
69
+ ${border} ${align(`Configuration:`)}${border}
70
+ ${border} ${align4(`Storage: ${CONFIG_DIR}`)}${border}
53
71
  ║ ║
54
72
  ║ Usage with Claude Code: ║
55
- export ANTHROPIC_BASE_URL=http://localhost:${PORT}
73
+ ${border} ${align4(`export ANTHROPIC_BASE_URL=http://localhost:${PORT}`)}${border}
56
74
  ║ export ANTHROPIC_API_KEY=dummy ║
57
75
  ║ claude ║
58
76
  ║ ║
package/src/server.js CHANGED
@@ -70,8 +70,9 @@ function parseError(error) {
70
70
  statusCode = 400; // Use 400 to ensure client does not retry (429 and 529 trigger retries)
71
71
 
72
72
  // Try to extract the quota reset time from the error
73
- const resetMatch = error.message.match(/quota will reset after (\d+h\d+m\d+s|\d+m\d+s|\d+s)/i);
74
- const modelMatch = error.message.match(/"model":\s*"([^"]+)"/);
73
+ const resetMatch = error.message.match(/quota will reset after ([\dh\dm\ds]+)/i);
74
+ // Try to extract model from our error format "Rate limited on <model>" or JSON format
75
+ const modelMatch = error.message.match(/Rate limited on ([^.]+)\./) || error.message.match(/"model":\s*"([^"]+)"/);
75
76
  const model = modelMatch ? modelMatch[1] : 'the model';
76
77
 
77
78
  if (resetMatch) {
@@ -111,22 +112,107 @@ app.use((req, res, next) => {
111
112
  });
112
113
 
113
114
  /**
114
- * Health check endpoint
115
+ * Health check endpoint - Detailed status
116
+ * Returns status of all accounts including rate limits and model quotas
115
117
  */
116
118
  app.get('/health', async (req, res) => {
117
119
  try {
118
120
  await ensureInitialized();
121
+ const start = Date.now();
122
+
123
+ // Get high-level status first
119
124
  const status = accountManager.getStatus();
125
+ const allAccounts = accountManager.getAllAccounts();
126
+
127
+ // Fetch quotas for each account in parallel to get detailed model info
128
+ const accountDetails = await Promise.allSettled(
129
+ allAccounts.map(async (account) => {
130
+ // Check model-specific rate limits
131
+ const activeModelLimits = Object.entries(account.modelRateLimits || {})
132
+ .filter(([_, limit]) => limit.isRateLimited && limit.resetTime > Date.now());
133
+ const isRateLimited = activeModelLimits.length > 0;
134
+ const soonestReset = activeModelLimits.length > 0
135
+ ? Math.min(...activeModelLimits.map(([_, l]) => l.resetTime))
136
+ : null;
137
+
138
+ const baseInfo = {
139
+ email: account.email,
140
+ lastUsed: account.lastUsed ? new Date(account.lastUsed).toISOString() : null,
141
+ modelRateLimits: account.modelRateLimits || {},
142
+ rateLimitCooldownRemaining: soonestReset ? Math.max(0, soonestReset - Date.now()) : 0
143
+ };
144
+
145
+ // Skip invalid accounts for quota check
146
+ if (account.isInvalid) {
147
+ return {
148
+ ...baseInfo,
149
+ status: 'invalid',
150
+ error: account.invalidReason,
151
+ models: {}
152
+ };
153
+ }
154
+
155
+ try {
156
+ const token = await accountManager.getTokenForAccount(account);
157
+ const quotas = await getModelQuotas(token);
158
+
159
+ // Format quotas for readability
160
+ const formattedQuotas = {};
161
+ for (const [modelId, info] of Object.entries(quotas)) {
162
+ formattedQuotas[modelId] = {
163
+ remaining: info.remainingFraction !== null ? `${Math.round(info.remainingFraction * 100)}%` : 'N/A',
164
+ remainingFraction: info.remainingFraction,
165
+ resetTime: info.resetTime || null
166
+ };
167
+ }
168
+
169
+ return {
170
+ ...baseInfo,
171
+ status: isRateLimited ? 'rate-limited' : 'ok',
172
+ models: formattedQuotas
173
+ };
174
+ } catch (error) {
175
+ return {
176
+ ...baseInfo,
177
+ status: 'error',
178
+ error: error.message,
179
+ models: {}
180
+ };
181
+ }
182
+ })
183
+ );
184
+
185
+ // Process results
186
+ const detailedAccounts = accountDetails.map((result, index) => {
187
+ if (result.status === 'fulfilled') {
188
+ return result.value;
189
+ } else {
190
+ const acc = allAccounts[index];
191
+ return {
192
+ email: acc.email,
193
+ status: 'error',
194
+ error: result.reason?.message || 'Unknown error',
195
+ modelRateLimits: acc.modelRateLimits || {}
196
+ };
197
+ }
198
+ });
120
199
 
121
200
  res.json({
122
201
  status: 'ok',
123
- accounts: status.summary,
124
- available: status.available,
125
- rateLimited: status.rateLimited,
126
- invalid: status.invalid,
127
- timestamp: new Date().toISOString()
202
+ timestamp: new Date().toISOString(),
203
+ latencyMs: Date.now() - start,
204
+ summary: status.summary,
205
+ counts: {
206
+ total: status.total,
207
+ available: status.available,
208
+ rateLimited: status.rateLimited,
209
+ invalid: status.invalid
210
+ },
211
+ accounts: detailedAccounts
128
212
  });
213
+
129
214
  } catch (error) {
215
+ logger.error('[API] Health check failed:', error);
130
216
  res.status(503).json({
131
217
  status: 'error',
132
218
  error: error.message,
@@ -236,11 +322,21 @@ app.get('/account-limits', async (req, res) => {
236
322
  let accStatus;
237
323
  if (acc.isInvalid) {
238
324
  accStatus = 'invalid';
239
- } else if (acc.isRateLimited) {
240
- const remaining = acc.rateLimitResetTime ? acc.rateLimitResetTime - Date.now() : 0;
241
- accStatus = remaining > 0 ? `limited (${formatDuration(remaining)})` : 'rate-limited';
325
+ } else if (accLimit?.status === 'error') {
326
+ accStatus = 'error';
242
327
  } else {
243
- accStatus = accLimit?.status || 'ok';
328
+ // Count exhausted models (0% or null remaining)
329
+ const models = accLimit?.models || {};
330
+ const modelCount = Object.keys(models).length;
331
+ const exhaustedCount = Object.values(models).filter(
332
+ q => q.remainingFraction === 0 || q.remainingFraction === null
333
+ ).length;
334
+
335
+ if (exhaustedCount === 0) {
336
+ accStatus = 'ok';
337
+ } else {
338
+ accStatus = `(${exhaustedCount}/${modelCount}) limited`;
339
+ }
244
340
  }
245
341
 
246
342
  // Get reset time from quota API
@@ -262,14 +358,14 @@ app.get('/account-limits', async (req, res) => {
262
358
  }
263
359
  lines.push('');
264
360
 
265
- // Calculate column widths
266
- const modelColWidth = Math.max(25, ...sortedModels.map(m => m.length)) + 2;
267
- const accountColWidth = 22;
361
+ // Calculate column widths - need more space for reset time info
362
+ const modelColWidth = Math.max(28, ...sortedModels.map(m => m.length)) + 2;
363
+ const accountColWidth = 30;
268
364
 
269
365
  // Header row
270
366
  let header = 'Model'.padEnd(modelColWidth);
271
367
  for (const acc of accountLimits) {
272
- const shortEmail = acc.email.split('@')[0].slice(0, 18);
368
+ const shortEmail = acc.email.split('@')[0].slice(0, 26);
273
369
  header += shortEmail.padEnd(accountColWidth);
274
370
  }
275
371
  lines.push(header);
@@ -281,12 +377,22 @@ app.get('/account-limits', async (req, res) => {
281
377
  for (const acc of accountLimits) {
282
378
  const quota = acc.models?.[modelId];
283
379
  let cell;
284
- if (acc.status !== 'ok') {
380
+ if (acc.status !== 'ok' && acc.status !== 'rate-limited') {
285
381
  cell = `[${acc.status}]`;
286
382
  } else if (!quota) {
287
383
  cell = '-';
288
- } else if (quota.remainingFraction === null) {
289
- cell = '0% (exhausted)';
384
+ } else if (quota.remainingFraction === 0 || quota.remainingFraction === null) {
385
+ // Show reset time for exhausted models
386
+ if (quota.resetTime) {
387
+ const resetMs = new Date(quota.resetTime).getTime() - Date.now();
388
+ if (resetMs > 0) {
389
+ cell = `0% (wait ${formatDuration(resetMs)})`;
390
+ } else {
391
+ cell = '0% (resetting...)';
392
+ }
393
+ } else {
394
+ cell = '0% (exhausted)';
395
+ }
290
396
  } else {
291
397
  const pct = Math.round(quota.remainingFraction * 100);
292
398
  cell = `${pct}%`;
@@ -404,17 +510,17 @@ app.post('/v1/messages/count_tokens', (req, res) => {
404
510
  /**
405
511
  * Main messages endpoint - Anthropic Messages API compatible
406
512
  */
513
+
514
+
515
+ /**
516
+ * Anthropic-compatible Messages API
517
+ * POST /v1/messages
518
+ */
407
519
  app.post('/v1/messages', async (req, res) => {
408
520
  try {
409
521
  // Ensure account manager is initialized
410
522
  await ensureInitialized();
411
523
 
412
- // Optimistic Retry: If ALL accounts are rate-limited, reset them to force a fresh check.
413
- // If we have some available accounts, we try them first.
414
- if (accountManager.isAllRateLimited()) {
415
- logger.warn('[Server] All accounts rate-limited. Resetting state for optimistic retry.');
416
- accountManager.resetAllRateLimits();
417
- }
418
524
 
419
525
  const {
420
526
  model,
@@ -430,6 +536,14 @@ app.post('/v1/messages', async (req, res) => {
430
536
  temperature
431
537
  } = req.body;
432
538
 
539
+ // Optimistic Retry: If ALL accounts are rate-limited for this model, reset them to force a fresh check.
540
+ // If we have some available accounts, we try them first.
541
+ const modelId = model || 'claude-3-5-sonnet-20241022';
542
+ if (accountManager.isAllRateLimited(modelId)) {
543
+ logger.warn(`[Server] All accounts rate-limited for ${modelId}. Resetting state for optimistic retry.`);
544
+ accountManager.resetAllRateLimits();
545
+ }
546
+
433
547
  // Validate required fields
434
548
  if (!messages || !Array.isArray(messages)) {
435
549
  return res.status(400).json({
@@ -23,6 +23,7 @@ export function formatDuration(ms) {
23
23
  return `${secs}s`;
24
24
  }
25
25
 
26
+
26
27
  /**
27
28
  * Sleep for specified milliseconds
28
29
  * @param {number} ms - Duration to sleep in milliseconds
@@ -31,3 +32,49 @@ export function formatDuration(ms) {
31
32
  export function sleep(ms) {
32
33
  return new Promise(resolve => setTimeout(resolve, ms));
33
34
  }
35
+
36
+ /**
37
+ * Check if an error is a network error (transient)
38
+ * @param {Error} error - The error to check
39
+ * @returns {boolean} True if it is a network error
40
+ */
41
+ export function isNetworkError(error) {
42
+ const msg = error.message.toLowerCase();
43
+ return (
44
+ msg.includes('fetch failed') ||
45
+ msg.includes('network error') ||
46
+ msg.includes('econnreset') ||
47
+ msg.includes('etimedout') ||
48
+ msg.includes('socket hang up') ||
49
+ msg.includes('timeout')
50
+ );
51
+ }
52
+
53
+ /**
54
+ * Check if an error is an authentication error (permanent until fixed)
55
+ * @param {Error} error - The error to check
56
+ * @returns {boolean} True if it is an auth error
57
+ */
58
+ export function isAuthError(error) {
59
+ const msg = error.message.toLowerCase();
60
+ return (
61
+ msg.includes('401') ||
62
+ msg.includes('unauthenticated') ||
63
+ msg.includes('invalid_grant') ||
64
+ msg.includes('invalid_client')
65
+ );
66
+ }
67
+
68
+ /**
69
+ * Check if an error is a rate limit error
70
+ * @param {Error} error - The error to check
71
+ * @returns {boolean} True if it is a rate limit error
72
+ */
73
+ export function isRateLimitError(error) {
74
+ const msg = error.message.toLowerCase();
75
+ return (
76
+ msg.includes('429') ||
77
+ msg.includes('resource_exhausted') ||
78
+ msg.includes('quota_exhausted')
79
+ );
80
+ }