pi-antigravity-rotator 1.3.3 → 1.3.5

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/README.md CHANGED
@@ -134,7 +134,7 @@ Each model maintains its own active account. When the proxy needs to rotate a mo
134
134
  | 2 | `7d` | Long reset window is already active for this model | Already ticking, so it is still worth using |
135
135
  | 3 (last) | `fresh` | No active reset window is known for this model yet | Save untouched quota for later if other timed pools exist |
136
136
 
137
- Within the same priority tier, the account with the most remaining quota for that model wins.
137
+ Within the same priority tier, the account with the most remaining quota for that model wins. If multiple accounts tie on priority and quota, rotation advances circularly from the current account so equal candidates share traffic instead of always favoring the first configured match.
138
138
 
139
139
  Timer meanings:
140
140
 
@@ -148,7 +148,7 @@ Three mechanisms trigger rotation, scoped to the specific model:
148
148
 
149
149
  1. **Quota-based** (primary) -- Polls the Google quota API every 5 minutes. When a model's remaining quota drops by `rotateOnQuotaDrop` percentage points (default: 20%), that model rotates to the next account. Other models stay on their current accounts.
150
150
 
151
- 2. **Request-count** (fallback) -- After `requestsPerRotation` successful requests (default: 5), the rotator asks for a rotation on the model that served that request. By default this fallback is only used when quota data for that model is still unknown.
151
+ 2. **Request-count** (fallback) -- Before forwarding a request, the rotator checks how many requests the current account has already served for that specific model and rotates once it reaches `requestsPerRotation` (default: 5). By default this fallback is only used when quota data for that model is still unknown.
152
152
 
153
153
  3. **429 failover** (reactive) -- On rate limit, the account is marked exhausted with a parsed retry cooldown and the affected model immediately switches.
154
154
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-antigravity-rotator",
3
- "version": "1.3.3",
3
+ "version": "1.3.5",
4
4
  "description": "Multi-account rotation proxy for Google Antigravity with per-model routing, real-time quota tracking, and infringement detection",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/rotator.ts CHANGED
@@ -78,6 +78,7 @@ export class AccountRotator {
78
78
  this.modelState.set(model, {
79
79
  activeAccountIndex: Math.min(idx, this.accounts.length - 1),
80
80
  quotaAtRotationStart: -1,
81
+ requestsOnActiveAccount: 0,
81
82
  });
82
83
  }
83
84
  }
@@ -340,6 +341,7 @@ export class AccountRotator {
340
341
  let best: AccountRuntime | null = null;
341
342
  let bestPriority = Infinity;
342
343
  let bestQuota = -2;
344
+ let bestDistance = Infinity;
343
345
 
344
346
  for (let i = 0; i < this.accounts.length; i++) {
345
347
  if (i === excludeIdx) continue;
@@ -351,16 +353,46 @@ export class AccountRotator {
351
353
  if (!this.isFreshWindowAllowed(account, modelKey)) continue;
352
354
 
353
355
  const priority = this.getModelTimerPriority(account, modelKey);
354
- if (priority < bestPriority || (priority === bestPriority && quota > bestQuota)) {
356
+ const distance =
357
+ excludeIdx >= 0 ? (i - excludeIdx + this.accounts.length) % this.accounts.length : i + 1;
358
+ if (
359
+ priority < bestPriority ||
360
+ (priority === bestPriority && quota > bestQuota) ||
361
+ (priority === bestPriority && quota === bestQuota && distance < bestDistance)
362
+ ) {
355
363
  best = account;
356
364
  bestPriority = priority;
357
365
  bestQuota = quota;
366
+ bestDistance = distance;
358
367
  }
359
368
  }
360
369
 
361
370
  return best;
362
371
  }
363
372
 
373
+ private countModelAssignment(modelKey: string): void {
374
+ const state = this.modelState.get(modelKey);
375
+ if (state) {
376
+ state.requestsOnActiveAccount++;
377
+ }
378
+ }
379
+
380
+ private shouldRotateBeforeRequest(account: AccountRuntime, modelKey: string, state: ModelRotationState | null): boolean {
381
+ return (
382
+ !!state &&
383
+ this.shouldUseRequestCountRotation(account, modelKey) &&
384
+ state.requestsOnActiveAccount >= this.config.requestsPerRotation
385
+ );
386
+ }
387
+
388
+ private async rotateModelForRequest(modelKey: string, now: number = Date.now(), excludeIdx?: number): Promise<AccountRuntime | null> {
389
+ const account = await this.rotateModel(modelKey, now, excludeIdx);
390
+ if (account) {
391
+ this.countModelAssignment(modelKey);
392
+ }
393
+ return account;
394
+ }
395
+
364
396
  // =========================================================================
365
397
  // Account Selection (per-model)
366
398
  // =========================================================================
@@ -380,12 +412,26 @@ export class AccountRotator {
380
412
  if (current && this.isAvailable(current, now)) {
381
413
  // Check if this account has quota for the requested model
382
414
  if (modelKey) {
415
+ if (this.shouldRotateBeforeRequest(current, modelKey, state ?? null)) {
416
+ this.log(
417
+ `${current.config.label || current.config.email} [${modelKey}]: hit rotation threshold (${this.config.requestsPerRotation})`,
418
+ );
419
+ const rotated = await this.rotateModelForRequest(modelKey, now, idx);
420
+ if (rotated) {
421
+ current.requestsSinceRotation = 0;
422
+ return rotated;
423
+ }
424
+ this.log(
425
+ `${current.config.label || current.config.email} [${modelKey}]: threshold reached but no replacement is available, staying`,
426
+ "warn",
427
+ );
428
+ }
383
429
  const quota = this.getModelQuota(current, modelKey);
384
430
  if (quota === 0) {
385
431
  this.log(
386
432
  `${current.config.label || current.config.email} [${modelKey}]: 0% quota, skipping`,
387
433
  );
388
- return this.rotateModel(modelKey);
434
+ return this.rotateModelForRequest(modelKey);
389
435
  }
390
436
  if (!this.isFreshWindowAllowed(current, modelKey)) {
391
437
  const label = current.config.label || current.config.email;
@@ -395,12 +441,13 @@ export class AccountRotator {
395
441
  : `${label} [${modelKey}]: fresh window blocked by operator toggle`,
396
442
  "warn",
397
443
  );
398
- return this.rotateModel(modelKey);
444
+ return this.rotateModelForRequest(modelKey);
399
445
  }
400
446
  }
401
447
  this.startRequest(current);
402
448
  try {
403
449
  await this.ensureValidToken(current);
450
+ if (modelKey) this.countModelAssignment(modelKey);
404
451
  return current;
405
452
  } catch (err) {
406
453
  this.finishRequest(current);
@@ -410,7 +457,7 @@ export class AccountRotator {
410
457
 
411
458
  // Current unavailable, or no per-model assignment yet
412
459
  if (modelKey) {
413
- return this.rotateModel(modelKey, now, state ? idx : -1);
460
+ return this.rotateModelForRequest(modelKey, now, state ? idx : -1);
414
461
  }
415
462
  return this.rotateDefault();
416
463
  }
@@ -430,6 +477,7 @@ export class AccountRotator {
430
477
  this.modelState.set(modelKey, {
431
478
  activeAccountIndex: newIdx,
432
479
  quotaAtRotationStart: quota,
480
+ requestsOnActiveAccount: 0,
433
481
  });
434
482
  this.log(
435
483
  `[${modelKey}] Rotated to ${best.config.label || best.config.email} [${timerType}] (quota: ${quota >= 0 ? quota + "%" : "unknown"})`,
@@ -532,17 +580,8 @@ export class AccountRotator {
532
580
  account.consecutiveErrors = 0;
533
581
  account.lastError = null;
534
582
 
535
- const shouldRotate =
536
- this.shouldUseRequestCountRotation(account, model) &&
537
- account.requestsSinceRotation >= this.config.requestsPerRotation;
538
- if (shouldRotate) {
539
- account.requestsSinceRotation = 0;
540
- this.log(
541
- `${account.config.label || account.config.email}: hit rotation threshold (${this.config.requestsPerRotation})`,
542
- );
543
- }
544
583
  this.saveState();
545
- return shouldRotate;
584
+ return false;
546
585
  }
547
586
 
548
587
  // Mark an account as exhausted (429 or quota exceeded)
package/src/types.ts CHANGED
@@ -117,6 +117,7 @@ export interface AccountRuntime {
117
117
  export interface ModelRotationState {
118
118
  activeAccountIndex: number;
119
119
  quotaAtRotationStart: number; // quota % when this account became active for this model
120
+ requestsOnActiveAccount: number;
120
121
  }
121
122
 
122
123
  // Persisted state across restarts