pi-antigravity-rotator 2.3.0 → 2.3.2

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/src/rotator.ts CHANGED
@@ -21,6 +21,9 @@ import {
21
21
  TOKEN_URL,
22
22
  QUOTA_API_URL,
23
23
  QUOTA_USER_AGENT,
24
+ REQUEST_GOOG_API_CLIENT,
25
+ REQUEST_CLIENT_METADATA,
26
+ ANTIGRAVITY_ENDPOINTS,
24
27
  QUOTA_MODEL_KEYS,
25
28
  resolveQuotaModelKey,
26
29
  resolveDisplayModelKey,
@@ -41,6 +44,7 @@ import { logger } from "./logger.js";
41
44
  import { getUpdateInfo } from "./version-check.js";
42
45
  import { getNotifications } from "./notification-poller.js";
43
46
  import { getConfiguredAdminToken } from "./admin-auth.js";
47
+ import { getProxyExposureWarning } from "./exposure.js";
44
48
  import {
45
49
  getCachedState,
46
50
  setCachedState,
@@ -67,6 +71,20 @@ function projectModelKey(projectId: string, modelKey: string): string {
67
71
  return `${projectId}::${modelKey}`;
68
72
  }
69
73
 
74
+ // Maps each quota pool key to the cheapest upstream model to use for kickstart warmup requests.
75
+ // gemini-3.5-flash and gemini-3.1-pro share the same upstream pool, so both map to gemini-3-flash.
76
+ const KICKSTART_MODEL_FOR_QUOTA_POOL: Record<string, string> = {
77
+ "claude-opus-4-6-thinking": "gpt-oss-120b-medium",
78
+ "gemini-3.5-flash": "gemini-3-flash",
79
+ "gemini-3.1-pro": "gemini-3-flash",
80
+ };
81
+
82
+ // Reverse map: upstream model → the quota pool key it primarily represents (for deduplication).
83
+ const QUOTA_POOL_FOR_KICKSTART_MODEL: Record<string, string> = {
84
+ "gpt-oss-120b-medium": "claude-opus-4-6-thinking",
85
+ "gemini-3-flash": "gemini-3.5-flash",
86
+ };
87
+
70
88
  export class AccountRotator {
71
89
  private accounts: AccountRuntime[] = [];
72
90
  // Per-model active account tracking
@@ -102,6 +120,10 @@ export class AccountRotator {
102
120
  account: string;
103
121
  }> = [];
104
122
  private routingDiagnostics: Record<string, RoutingModelDiagnostics> = {};
123
+ private autoWarmupEnabled = false;
124
+ // Tracks upstream models already warmed-up this poll cycle per account (email → Set<upstreamModel>).
125
+ // Cleared at the start of each pollAllQuotas() to enforce the one-per-cycle guarantee.
126
+ private warmupSentThisCycle = new Map<string, Set<string>>();
105
127
  // Debounced state writer: batches multiple saveState() calls within a 1s window
106
128
  // to a single disk write. Hot paths (markError, recordRequest, etc.) call
107
129
  // scheduleStateSave() instead of saveState() to avoid blocking the event loop.
@@ -267,6 +289,7 @@ export class AccountRotator {
267
289
  this.protectivePauseUntil = state.protectivePauseUntil ?? 0;
268
290
  this.protectivePauseReason = state.protectivePauseReason ?? null;
269
291
  this.allowFreshWindowStarts = state.allowFreshWindowStarts ?? true;
292
+ this.autoWarmupEnabled = state.autoWarmupEnabled ?? false;
270
293
  this.safetyDay = state.safety?.day ?? currentUtcDay();
271
294
  this.projectRequests = state.safety?.projectRequests ?? {};
272
295
  this.projectModelBreakers = state.safety?.projectModelBreakers ?? {};
@@ -372,6 +395,7 @@ export class AccountRotator {
372
395
  protectivePauseUntil: this.protectivePauseUntil,
373
396
  protectivePauseReason: this.protectivePauseReason,
374
397
  allowFreshWindowStarts: this.allowFreshWindowStarts,
398
+ autoWarmupEnabled: this.autoWarmupEnabled,
375
399
  safety: {
376
400
  day: this.safetyDay,
377
401
  projectRequests: { ...this.projectRequests },
@@ -474,6 +498,9 @@ export class AccountRotator {
474
498
  }
475
499
 
476
500
  private async pollAllQuotas(): Promise<void> {
501
+ // Reset per-cycle warmup tracking so each poll cycle allows at most one warmup per upstream model per account.
502
+ this.warmupSentThisCycle.clear();
503
+
477
504
  const available = this.accounts.filter((a) => !a.disabled && !a.flagged);
478
505
  for (const account of available) {
479
506
  try {
@@ -482,6 +509,38 @@ export class AccountRotator {
482
509
  } catch {
483
510
  // Token refresh or quota fetch failed, skip this account
484
511
  }
512
+
513
+ // Auto-warmup: send minimal kickstart requests for idle pools on accounts that have opted
514
+ // in via allowFreshWindowStartsOverride, but only if the operator has enabled the global
515
+ // auto-warmup toggle. "Idle" includes both fresh pools (no active timer) and rolling idle
516
+ // pools (100% quota with resetTime very close to a full 5h or 7d window — timer exists but
517
+ // untouched). Deduplicates by upstream model within this cycle.
518
+ if (this.autoWarmupEnabled && account.allowFreshWindowStartsOverride) {
519
+ const idlePools = account.quota.filter((q) => this.isQuotaIdleForKickstart(q));
520
+ if (idlePools.length > 0) {
521
+ const alreadySent =
522
+ this.warmupSentThisCycle.get(account.config.email) ?? new Set<string>();
523
+ // Build deduplicated upstream model list
524
+ const upstreamToQuotaKey = new Map<string, string>();
525
+ for (const q of idlePools) {
526
+ const upstream = KICKSTART_MODEL_FOR_QUOTA_POOL[q.modelKey] ?? q.modelKey;
527
+ if (!upstreamToQuotaKey.has(upstream)) {
528
+ const primaryKey = QUOTA_POOL_FOR_KICKSTART_MODEL[upstream] ?? q.modelKey;
529
+ upstreamToQuotaKey.set(upstream, primaryKey);
530
+ }
531
+ }
532
+ for (const [upstream, quotaKey] of upstreamToQuotaKey) {
533
+ if (!alreadySent.has(upstream)) {
534
+ alreadySent.add(upstream);
535
+ // Fire-and-forget — errors are handled internally by kickstartTimerForAccount
536
+ void this.kickstartTimerForAccount(account.config.email, quotaKey).catch(
537
+ () => {},
538
+ );
539
+ }
540
+ }
541
+ this.warmupSentThisCycle.set(account.config.email, alreadySent);
542
+ }
543
+ }
485
544
  }
486
545
 
487
546
  if (this.isProtectivePauseActive(Date.now())) {
@@ -683,6 +742,19 @@ export class AccountRotator {
683
742
  return q?.timerType ?? "fresh";
684
743
  }
685
744
 
745
+ // A pool is "idle for kickstart" when it has a fresh pool (no active timer)
746
+ // OR a rolling idle timer (100% quota with resetTime very close to a full 5h or 7d
747
+ // window — the timer exists but the quota is completely untouched). Sending a
748
+ // minimal kickstart request against an idle pool starts the consumption clock.
749
+ private isQuotaIdleForKickstart(q: ModelQuota): boolean {
750
+ if (q.timerType === "fresh") return true;
751
+ if (!q.resetTime || q.percentRemaining !== 100) return false;
752
+ const remaining = new Date(q.resetTime).getTime() - Date.now();
753
+ if (remaining <= 0) return false;
754
+ const within = (target: number) => Math.abs(remaining - target) < 600_000;
755
+ return within(5 * 3600 * 1000) || within(7 * 24 * 3600 * 1000);
756
+ }
757
+
686
758
  // Timer priority for a specific model:
687
759
  // 1 (highest) = 5h timer -> only class with hard quota loss if underused before reset
688
760
  // 2 = 7d timer -> already ticking, keep those long windows moving
@@ -805,7 +877,7 @@ export class AccountRotator {
805
877
  const state = this.modelState.get(modelKey);
806
878
  if (state) {
807
879
  state.requestsOnActiveAccount++;
808
- this.saveState();
880
+ this.scheduleStateSave();
809
881
  }
810
882
  }
811
883
 
@@ -2094,7 +2166,7 @@ export class AccountRotator {
2094
2166
  account.dailyRequestCount++;
2095
2167
  this.projectRequests[account.config.projectId] =
2096
2168
  (this.projectRequests[account.config.projectId] ?? 0) + 1;
2097
- this.saveState();
2169
+ this.scheduleStateSave();
2098
2170
  }
2099
2171
 
2100
2172
  getSafetyJitterMs(account: AccountRuntime): number {
@@ -2228,6 +2300,19 @@ export class AccountRotator {
2228
2300
  return true;
2229
2301
  }
2230
2302
 
2303
+ setAutoWarmup(enabled: boolean): boolean {
2304
+ if (this.autoWarmupEnabled === enabled) return false;
2305
+ this.autoWarmupEnabled = enabled;
2306
+ this.saveState();
2307
+ this.log(
2308
+ enabled
2309
+ ? "Operator enabled auto-warmup; accounts with fresh-window override will automatically receive minimal kickstart requests on each quota poll"
2310
+ : "Operator disabled auto-warmup; no automatic kickstart requests will be sent",
2311
+ "warn",
2312
+ );
2313
+ return true;
2314
+ }
2315
+
2231
2316
  clearModelBreaker(modelKey: string): boolean {
2232
2317
  const now = Date.now();
2233
2318
  const hasModelBreaker = (this.modelBreakers[modelKey] ?? 0) > now;
@@ -2571,6 +2656,11 @@ export class AccountRotator {
2571
2656
  }
2572
2657
  }
2573
2658
 
2659
+ const adminWarning = getConfiguredAdminToken()
2660
+ ? null
2661
+ : `Admin routes are exposed on ${this.config.bindHost}:${this.config.proxyPort} because PI_ROTATOR_ADMIN_TOKEN is not configured.`;
2662
+ const proxyWarning = getProxyExposureWarning(this.config);
2663
+
2574
2664
  return {
2575
2665
  version: updateInfo.currentVersion,
2576
2666
  proxyPort: this.config.proxyPort,
@@ -2588,12 +2678,11 @@ export class AccountRotator {
2588
2678
  : null,
2589
2679
  operatorControls: {
2590
2680
  allowFreshWindowStarts: this.allowFreshWindowStarts,
2681
+ autoWarmupEnabled: this.autoWarmupEnabled,
2591
2682
  },
2592
2683
  security: {
2593
2684
  adminTokenConfigured: !!getConfiguredAdminToken(),
2594
- warning: getConfiguredAdminToken()
2595
- ? null
2596
- : `Admin routes are exposed on ${this.config.bindHost}:${this.config.proxyPort} because PI_ROTATOR_ADMIN_TOKEN is not configured.`,
2685
+ warning: [adminWarning, proxyWarning].filter(Boolean).join(" ") || null,
2597
2686
  bindHost: this.config.bindHost || "0.0.0.0",
2598
2687
  },
2599
2688
  routingDiagnostics,
@@ -2820,7 +2909,7 @@ export class AccountRotator {
2820
2909
  }
2821
2910
 
2822
2911
  setAccountTier(email: string, tier: string): boolean {
2823
- const validTiers = ["unknown", "free", "pro", "ultra"];
2912
+ const validTiers = ["unknown", "free", "plus", "pro", "ultra"];
2824
2913
  if (!validTiers.includes(tier)) return false;
2825
2914
  const account = this.accounts.find((a) => a.config.email === email);
2826
2915
  if (!account) return false;
@@ -3061,4 +3150,180 @@ export class AccountRotator {
3061
3150
  errorCount,
3062
3151
  };
3063
3152
  }
3153
+
3154
+ /**
3155
+ * Send a minimal single-token request to the upstream Antigravity endpoint for a specific
3156
+ * quota pool key on a given account. Uses the cheapest model in that pool to minimise cost.
3157
+ * Applies normal error handling (markExhausted, markFlagged, markError) so the account state
3158
+ * stays consistent with regular traffic.
3159
+ */
3160
+ async kickstartTimerForAccount(
3161
+ email: string,
3162
+ quotaModelKey: string,
3163
+ ): Promise<{ ok: boolean; status: number; upstreamModel: string; error?: string }> {
3164
+ const account = this.accounts.find((a) => a.config.email === email);
3165
+ if (!account) {
3166
+ return { ok: false, status: 404, upstreamModel: "", error: "account not found" };
3167
+ }
3168
+ if (account.disabled || account.flagged) {
3169
+ return {
3170
+ ok: false,
3171
+ status: 409,
3172
+ upstreamModel: "",
3173
+ error: account.disabled ? "account disabled" : "account flagged",
3174
+ };
3175
+ }
3176
+
3177
+ try {
3178
+ await this.ensureValidToken(account);
3179
+ } catch (err) {
3180
+ return {
3181
+ ok: false,
3182
+ status: 401,
3183
+ upstreamModel: "",
3184
+ error: `token refresh failed: ${err instanceof Error ? err.message : String(err)}`,
3185
+ };
3186
+ }
3187
+
3188
+ if (!account.accessToken) {
3189
+ return { ok: false, status: 401, upstreamModel: "", error: "no access token" };
3190
+ }
3191
+
3192
+ const upstreamModel =
3193
+ KICKSTART_MODEL_FOR_QUOTA_POOL[quotaModelKey] ?? quotaModelKey;
3194
+
3195
+ const body = JSON.stringify({
3196
+ project: account.config.projectId,
3197
+ model: upstreamModel,
3198
+ request: {
3199
+ contents: [{ role: "user", parts: [{ text: "." }] }],
3200
+ generationConfig: { maxOutputTokens: 1 },
3201
+ },
3202
+ });
3203
+
3204
+ const endpoint = ANTIGRAVITY_ENDPOINTS[ANTIGRAVITY_ENDPOINTS.length - 1];
3205
+ const url = `${endpoint}/v1internal:streamGenerateContent?alt=sse`;
3206
+
3207
+ const controller = new AbortController();
3208
+ const timeout = setTimeout(() => controller.abort(), 15_000);
3209
+
3210
+ let response: Response;
3211
+ try {
3212
+ response = await fetch(url, {
3213
+ method: "POST",
3214
+ headers: {
3215
+ "Content-Type": "application/json",
3216
+ Authorization: `Bearer ${account.accessToken}`,
3217
+ "User-Agent": QUOTA_USER_AGENT,
3218
+ "X-Goog-Api-Client": REQUEST_GOOG_API_CLIENT,
3219
+ "Client-Metadata": REQUEST_CLIENT_METADATA,
3220
+ },
3221
+ body,
3222
+ signal: controller.signal,
3223
+ });
3224
+ } catch (err) {
3225
+ clearTimeout(timeout);
3226
+ const msg = `kickstart network error: ${err instanceof Error ? err.message : String(err)}`;
3227
+ this.markError(account, msg);
3228
+ return { ok: false, status: 0, upstreamModel, error: msg };
3229
+ }
3230
+ clearTimeout(timeout);
3231
+
3232
+ // Consume and discard the response body to free the connection
3233
+ try {
3234
+ await response.body?.cancel();
3235
+ } catch {
3236
+ // ignore
3237
+ }
3238
+
3239
+ const label = account.config.label || account.config.email;
3240
+
3241
+ if (response.status === 429) {
3242
+ const cooldownMs = 60_000;
3243
+ this.markExhausted(account, quotaModelKey, cooldownMs, "kickstart 429");
3244
+ this.recordProvider429(account, quotaModelKey, cooldownMs);
3245
+ this.log(
3246
+ `${label} [${quotaModelKey}]: kickstart 429 — cooldown ${cooldownMs / 1000}s`,
3247
+ "warn",
3248
+ );
3249
+ return { ok: false, status: 429, upstreamModel };
3250
+ }
3251
+
3252
+ if (response.status === 401 || response.status === 403) {
3253
+ this.markFlagged(
3254
+ account,
3255
+ `kickstart ${response.status} on ${upstreamModel}`,
3256
+ { triggerProtectivePause: false },
3257
+ );
3258
+ return { ok: false, status: response.status, upstreamModel };
3259
+ }
3260
+
3261
+ if (response.status >= 500) {
3262
+ this.markError(account, `kickstart ${response.status} on ${upstreamModel}`);
3263
+ return { ok: false, status: response.status, upstreamModel };
3264
+ }
3265
+
3266
+ if (response.ok) {
3267
+ this.recordRequest(account, quotaModelKey);
3268
+ this.log(
3269
+ `${label} [${quotaModelKey}]: kickstart sent via ${upstreamModel} — timer should start within the next quota poll`,
3270
+ );
3271
+ }
3272
+
3273
+ return { ok: response.ok, status: response.status, upstreamModel };
3274
+ }
3275
+
3276
+ /**
3277
+ * Kickstart all quota pools that are currently idle (no active timer, or rolling
3278
+ * idle with 100% quota and a fresh resetTime) for a given account. Deduplicates by
3279
+ * upstream model so that pools sharing the same upstream (e.g. gemini-3.5-flash and
3280
+ * gemini-3.1-pro → gemini-3-flash) only receive one request.
3281
+ */
3282
+ async kickstartAllFreshTimers(email: string): Promise<{
3283
+ ok: boolean;
3284
+ error?: string;
3285
+ results: Array<{
3286
+ quotaPools: string[];
3287
+ upstreamModel: string;
3288
+ ok: boolean;
3289
+ status: number;
3290
+ }>;
3291
+ }> {
3292
+ const account = this.accounts.find((a) => a.config.email === email);
3293
+ if (!account) {
3294
+ return { ok: false, error: "account not found", results: [] };
3295
+ }
3296
+
3297
+ const idlePools = account.quota.filter((q) => this.isQuotaIdleForKickstart(q));
3298
+ if (idlePools.length === 0) {
3299
+ return { ok: true, results: [] };
3300
+ }
3301
+
3302
+ // Deduplicate: group quota pool keys by their upstream model
3303
+ const upstreamToQuotaPools = new Map<string, string[]>();
3304
+ for (const q of idlePools) {
3305
+ const upstream = KICKSTART_MODEL_FOR_QUOTA_POOL[q.modelKey] ?? q.modelKey;
3306
+ const list = upstreamToQuotaPools.get(upstream) ?? [];
3307
+ list.push(q.modelKey);
3308
+ upstreamToQuotaPools.set(upstream, list);
3309
+ }
3310
+
3311
+ const results: Array<{
3312
+ quotaPools: string[];
3313
+ upstreamModel: string;
3314
+ ok: boolean;
3315
+ status: number;
3316
+ }> = [];
3317
+
3318
+ for (const [upstreamModel, quotaPools] of upstreamToQuotaPools) {
3319
+ // Use the primary quota pool key for this upstream (for recordRequest/markExhausted)
3320
+ const primaryQuotaKey =
3321
+ QUOTA_POOL_FOR_KICKSTART_MODEL[upstreamModel] ?? quotaPools[0];
3322
+ const result = await this.kickstartTimerForAccount(email, primaryQuotaKey);
3323
+ results.push({ quotaPools, upstreamModel, ok: result.ok, status: result.status });
3324
+ }
3325
+
3326
+ const allOk = results.every((r) => r.ok);
3327
+ return { ok: allOk, results };
3328
+ }
3064
3329
  }