pi-antigravity-rotator 2.3.0 → 2.3.1
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/CHANGELOG.md +5 -0
- package/README.md +1 -1
- package/package.json +2 -2
- package/src/dashboard.ts +35 -0
- package/src/proxy.ts +29 -0
- package/src/rotator.ts +261 -0
- package/src/static/dashboard.js +1966 -928
- package/src/types.ts +2 -0
- package/tools/telemetry-receiver/receiver.js +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [2.3.1] - 2026-06-30
|
|
6
|
+
|
|
7
|
+
### Improved
|
|
8
|
+
- **Accurate Quota Forecast**: Replaced the simple arithmetic average of pool quota with a weighted calculation based on each account's Tier capacity (Ultra/Pro/Plus/Free). The Dashboard's time remaining and percentage estimates are now mathematically accurate for mixed-tier deployments. (PR [#14](https://github.com/tuxevil/pi-antigravity-rotator/pull/14) by [@josenicomaia](https://github.com/josenicomaia))
|
|
9
|
+
|
|
5
10
|
## [2.3.0] - 2026-06-22
|
|
6
11
|
|
|
7
12
|
### Added
|
package/README.md
CHANGED
|
@@ -161,7 +161,7 @@ The dashboard shows:
|
|
|
161
161
|
- **Token Usage & Savings** -- Interactive chart (`1h`, `2h`, `4h`, `8h`, `12h`, `1d`, `7d`, `1m`) showing token consumption by model, with estimated USD savings and `CSV`/`JSON` export options.
|
|
162
162
|
- **Activity Heatmap** -- 60-day responsive GitHub-style contribution grid showing request intensity hour by hour.
|
|
163
163
|
- **Latency (p50/p95)** -- Real-time median and 95th percentile tracking for Time-to-First-Byte (TTFB) and Total Duration per model.
|
|
164
|
-
- **Quota Forecast** -- Predictive modeling showing when each model's quota will run out
|
|
164
|
+
- **Quota Forecast** -- Predictive modeling showing when each model's quota will run out, using **tier-weighted capacity** algorithms for pinpoint accuracy across mixed-tier pools.
|
|
165
165
|
- **Searchable Request Log** -- Live feed of the last 200 requests with exact timestamps, models, masked accounts, status codes, and latency.
|
|
166
166
|
- **Account Cards** -- Sorted by total quota. Shows status (`active`, `ready`, `cooldown`, `flagged`, `disabled`), quota bars with timers, and precise error messages.
|
|
167
167
|
- **Web-based Account Management** -- Add, remove, and manage account credentials and tier configurations directly from the UI without touching JSON files.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-antigravity-rotator",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.1",
|
|
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",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"start": "tsx src/cli.ts start",
|
|
12
12
|
"login": "tsx src/cli.ts login",
|
|
13
13
|
"typecheck": "tsc --noEmit",
|
|
14
|
-
"test": "node --import tsx/esm --test \"test/**/*.test.ts\"",
|
|
14
|
+
"test": "PI_ROTATOR_DIR=/tmp/pi-rotator-test node --import tsx/esm --test \"test/**/*.test.ts\"",
|
|
15
15
|
"lint": "eslint src test",
|
|
16
16
|
"coverage": "c8 --reporter=text --reporter=html npm test",
|
|
17
17
|
"check": "npm run typecheck && npm test && npm run lint"
|
package/src/dashboard.ts
CHANGED
|
@@ -197,6 +197,41 @@ export function serveClearBreakerApi(
|
|
|
197
197
|
res.end(JSON.stringify({ ok: true }));
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
+
export function serveKickstartApi(
|
|
201
|
+
res: ServerResponse,
|
|
202
|
+
rotator: AccountRotator,
|
|
203
|
+
email: string,
|
|
204
|
+
modelKey?: string,
|
|
205
|
+
): void {
|
|
206
|
+
if (modelKey) {
|
|
207
|
+
rotator.kickstartTimerForAccount(email, modelKey).then((result) => {
|
|
208
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
209
|
+
res.end(JSON.stringify(result));
|
|
210
|
+
}).catch((err) => {
|
|
211
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
212
|
+
res.end(JSON.stringify({ ok: false, error: String(err) }));
|
|
213
|
+
});
|
|
214
|
+
} else {
|
|
215
|
+
rotator.kickstartAllFreshTimers(email).then((result) => {
|
|
216
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
217
|
+
res.end(JSON.stringify(result));
|
|
218
|
+
}).catch((err) => {
|
|
219
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
220
|
+
res.end(JSON.stringify({ ok: false, error: String(err), results: [] }));
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function serveAutoWarmupApi(
|
|
226
|
+
res: ServerResponse,
|
|
227
|
+
rotator: AccountRotator,
|
|
228
|
+
enabled: boolean,
|
|
229
|
+
): void {
|
|
230
|
+
const changed = rotator.setAutoWarmup(enabled);
|
|
231
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
232
|
+
res.end(JSON.stringify({ ok: true, changed, autoWarmupEnabled: enabled }));
|
|
233
|
+
}
|
|
234
|
+
|
|
200
235
|
const DASHBOARD_HTML = `<!DOCTYPE html>
|
|
201
236
|
<html lang="en">
|
|
202
237
|
<head>
|
package/src/proxy.ts
CHANGED
|
@@ -33,6 +33,8 @@ import {
|
|
|
33
33
|
serveAccountFreshWindowStartsApi,
|
|
34
34
|
serveClearInFlightApi,
|
|
35
35
|
serveClearBreakerApi,
|
|
36
|
+
serveKickstartApi,
|
|
37
|
+
serveAutoWarmupApi,
|
|
36
38
|
serveStaticCss,
|
|
37
39
|
serveStaticJs,
|
|
38
40
|
} from "./dashboard.js";
|
|
@@ -1728,6 +1730,33 @@ export function startProxy(
|
|
|
1728
1730
|
return;
|
|
1729
1731
|
}
|
|
1730
1732
|
|
|
1733
|
+
if (method === "POST" && url.startsWith("/api/kickstart/")) {
|
|
1734
|
+
if (!requireAdmin(req, res)) return;
|
|
1735
|
+
const rest = url.slice("/api/kickstart/".length);
|
|
1736
|
+
const firstSlash = rest.indexOf("/");
|
|
1737
|
+
if (firstSlash >= 0) {
|
|
1738
|
+
// /api/kickstart/:email/:modelKey
|
|
1739
|
+
const email = decodeURIComponent(rest.slice(0, firstSlash));
|
|
1740
|
+
const modelKey = decodeURIComponent(rest.slice(firstSlash + 1));
|
|
1741
|
+
serveKickstartApi(res, rotator, email, modelKey);
|
|
1742
|
+
} else {
|
|
1743
|
+
// /api/kickstart/:email — kickstart all fresh timers
|
|
1744
|
+
const email = decodeURIComponent(rest);
|
|
1745
|
+
serveKickstartApi(res, rotator, email);
|
|
1746
|
+
}
|
|
1747
|
+
return;
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
if (
|
|
1751
|
+
method === "POST" &&
|
|
1752
|
+
(url === "/api/settings/auto-warmup/on" ||
|
|
1753
|
+
url === "/api/settings/auto-warmup/off")
|
|
1754
|
+
) {
|
|
1755
|
+
if (!requireAdmin(req, res)) return;
|
|
1756
|
+
serveAutoWarmupApi(res, rotator, url.endsWith("/on"));
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1731
1760
|
if (method === "POST" && pathname === "/api/self-update") {
|
|
1732
1761
|
if (!requireAdmin(req, res)) return;
|
|
1733
1762
|
trackFeature("selfUpdate");
|
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,
|
|
@@ -67,6 +70,20 @@ function projectModelKey(projectId: string, modelKey: string): string {
|
|
|
67
70
|
return `${projectId}::${modelKey}`;
|
|
68
71
|
}
|
|
69
72
|
|
|
73
|
+
// Maps each quota pool key to the cheapest upstream model to use for kickstart warmup requests.
|
|
74
|
+
// gemini-3.5-flash and gemini-3.1-pro share the same upstream pool, so both map to gemini-3-flash.
|
|
75
|
+
const KICKSTART_MODEL_FOR_QUOTA_POOL: Record<string, string> = {
|
|
76
|
+
"claude-opus-4-6-thinking": "gpt-oss-120b-medium",
|
|
77
|
+
"gemini-3.5-flash": "gemini-3-flash",
|
|
78
|
+
"gemini-3.1-pro": "gemini-3-flash",
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// Reverse map: upstream model → the quota pool key it primarily represents (for deduplication).
|
|
82
|
+
const QUOTA_POOL_FOR_KICKSTART_MODEL: Record<string, string> = {
|
|
83
|
+
"gpt-oss-120b-medium": "claude-opus-4-6-thinking",
|
|
84
|
+
"gemini-3-flash": "gemini-3.5-flash",
|
|
85
|
+
};
|
|
86
|
+
|
|
70
87
|
export class AccountRotator {
|
|
71
88
|
private accounts: AccountRuntime[] = [];
|
|
72
89
|
// Per-model active account tracking
|
|
@@ -102,6 +119,10 @@ export class AccountRotator {
|
|
|
102
119
|
account: string;
|
|
103
120
|
}> = [];
|
|
104
121
|
private routingDiagnostics: Record<string, RoutingModelDiagnostics> = {};
|
|
122
|
+
private autoWarmupEnabled = false;
|
|
123
|
+
// Tracks upstream models already warmed-up this poll cycle per account (email → Set<upstreamModel>).
|
|
124
|
+
// Cleared at the start of each pollAllQuotas() to enforce the one-per-cycle guarantee.
|
|
125
|
+
private warmupSentThisCycle = new Map<string, Set<string>>();
|
|
105
126
|
// Debounced state writer: batches multiple saveState() calls within a 1s window
|
|
106
127
|
// to a single disk write. Hot paths (markError, recordRequest, etc.) call
|
|
107
128
|
// scheduleStateSave() instead of saveState() to avoid blocking the event loop.
|
|
@@ -267,6 +288,7 @@ export class AccountRotator {
|
|
|
267
288
|
this.protectivePauseUntil = state.protectivePauseUntil ?? 0;
|
|
268
289
|
this.protectivePauseReason = state.protectivePauseReason ?? null;
|
|
269
290
|
this.allowFreshWindowStarts = state.allowFreshWindowStarts ?? true;
|
|
291
|
+
this.autoWarmupEnabled = state.autoWarmupEnabled ?? false;
|
|
270
292
|
this.safetyDay = state.safety?.day ?? currentUtcDay();
|
|
271
293
|
this.projectRequests = state.safety?.projectRequests ?? {};
|
|
272
294
|
this.projectModelBreakers = state.safety?.projectModelBreakers ?? {};
|
|
@@ -372,6 +394,7 @@ export class AccountRotator {
|
|
|
372
394
|
protectivePauseUntil: this.protectivePauseUntil,
|
|
373
395
|
protectivePauseReason: this.protectivePauseReason,
|
|
374
396
|
allowFreshWindowStarts: this.allowFreshWindowStarts,
|
|
397
|
+
autoWarmupEnabled: this.autoWarmupEnabled,
|
|
375
398
|
safety: {
|
|
376
399
|
day: this.safetyDay,
|
|
377
400
|
projectRequests: { ...this.projectRequests },
|
|
@@ -474,6 +497,9 @@ export class AccountRotator {
|
|
|
474
497
|
}
|
|
475
498
|
|
|
476
499
|
private async pollAllQuotas(): Promise<void> {
|
|
500
|
+
// Reset per-cycle warmup tracking so each poll cycle allows at most one warmup per upstream model per account.
|
|
501
|
+
this.warmupSentThisCycle.clear();
|
|
502
|
+
|
|
477
503
|
const available = this.accounts.filter((a) => !a.disabled && !a.flagged);
|
|
478
504
|
for (const account of available) {
|
|
479
505
|
try {
|
|
@@ -482,6 +508,38 @@ export class AccountRotator {
|
|
|
482
508
|
} catch {
|
|
483
509
|
// Token refresh or quota fetch failed, skip this account
|
|
484
510
|
}
|
|
511
|
+
|
|
512
|
+
// Auto-warmup: send minimal kickstart requests for idle pools on accounts that have opted
|
|
513
|
+
// in via allowFreshWindowStartsOverride, but only if the operator has enabled the global
|
|
514
|
+
// auto-warmup toggle. "Idle" includes both fresh pools (no active timer) and rolling idle
|
|
515
|
+
// pools (100% quota with resetTime very close to a full 5h or 7d window — timer exists but
|
|
516
|
+
// untouched). Deduplicates by upstream model within this cycle.
|
|
517
|
+
if (this.autoWarmupEnabled && account.allowFreshWindowStartsOverride) {
|
|
518
|
+
const idlePools = account.quota.filter((q) => this.isQuotaIdleForKickstart(q));
|
|
519
|
+
if (idlePools.length > 0) {
|
|
520
|
+
const alreadySent =
|
|
521
|
+
this.warmupSentThisCycle.get(account.config.email) ?? new Set<string>();
|
|
522
|
+
// Build deduplicated upstream model list
|
|
523
|
+
const upstreamToQuotaKey = new Map<string, string>();
|
|
524
|
+
for (const q of idlePools) {
|
|
525
|
+
const upstream = KICKSTART_MODEL_FOR_QUOTA_POOL[q.modelKey] ?? q.modelKey;
|
|
526
|
+
if (!upstreamToQuotaKey.has(upstream)) {
|
|
527
|
+
const primaryKey = QUOTA_POOL_FOR_KICKSTART_MODEL[upstream] ?? q.modelKey;
|
|
528
|
+
upstreamToQuotaKey.set(upstream, primaryKey);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
for (const [upstream, quotaKey] of upstreamToQuotaKey) {
|
|
532
|
+
if (!alreadySent.has(upstream)) {
|
|
533
|
+
alreadySent.add(upstream);
|
|
534
|
+
// Fire-and-forget — errors are handled internally by kickstartTimerForAccount
|
|
535
|
+
void this.kickstartTimerForAccount(account.config.email, quotaKey).catch(
|
|
536
|
+
() => {},
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
this.warmupSentThisCycle.set(account.config.email, alreadySent);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
485
543
|
}
|
|
486
544
|
|
|
487
545
|
if (this.isProtectivePauseActive(Date.now())) {
|
|
@@ -683,6 +741,19 @@ export class AccountRotator {
|
|
|
683
741
|
return q?.timerType ?? "fresh";
|
|
684
742
|
}
|
|
685
743
|
|
|
744
|
+
// A pool is "idle for kickstart" when it has a fresh pool (no active timer)
|
|
745
|
+
// OR a rolling idle timer (100% quota with resetTime very close to a full 5h or 7d
|
|
746
|
+
// window — the timer exists but the quota is completely untouched). Sending a
|
|
747
|
+
// minimal kickstart request against an idle pool starts the consumption clock.
|
|
748
|
+
private isQuotaIdleForKickstart(q: ModelQuota): boolean {
|
|
749
|
+
if (q.timerType === "fresh") return true;
|
|
750
|
+
if (!q.resetTime || q.percentRemaining !== 100) return false;
|
|
751
|
+
const remaining = new Date(q.resetTime).getTime() - Date.now();
|
|
752
|
+
if (remaining <= 0) return false;
|
|
753
|
+
const within = (target: number) => Math.abs(remaining - target) < 600_000;
|
|
754
|
+
return within(5 * 3600 * 1000) || within(7 * 24 * 3600 * 1000);
|
|
755
|
+
}
|
|
756
|
+
|
|
686
757
|
// Timer priority for a specific model:
|
|
687
758
|
// 1 (highest) = 5h timer -> only class with hard quota loss if underused before reset
|
|
688
759
|
// 2 = 7d timer -> already ticking, keep those long windows moving
|
|
@@ -2228,6 +2299,19 @@ export class AccountRotator {
|
|
|
2228
2299
|
return true;
|
|
2229
2300
|
}
|
|
2230
2301
|
|
|
2302
|
+
setAutoWarmup(enabled: boolean): boolean {
|
|
2303
|
+
if (this.autoWarmupEnabled === enabled) return false;
|
|
2304
|
+
this.autoWarmupEnabled = enabled;
|
|
2305
|
+
this.saveState();
|
|
2306
|
+
this.log(
|
|
2307
|
+
enabled
|
|
2308
|
+
? "Operator enabled auto-warmup; accounts with fresh-window override will automatically receive minimal kickstart requests on each quota poll"
|
|
2309
|
+
: "Operator disabled auto-warmup; no automatic kickstart requests will be sent",
|
|
2310
|
+
"warn",
|
|
2311
|
+
);
|
|
2312
|
+
return true;
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2231
2315
|
clearModelBreaker(modelKey: string): boolean {
|
|
2232
2316
|
const now = Date.now();
|
|
2233
2317
|
const hasModelBreaker = (this.modelBreakers[modelKey] ?? 0) > now;
|
|
@@ -2588,6 +2672,7 @@ export class AccountRotator {
|
|
|
2588
2672
|
: null,
|
|
2589
2673
|
operatorControls: {
|
|
2590
2674
|
allowFreshWindowStarts: this.allowFreshWindowStarts,
|
|
2675
|
+
autoWarmupEnabled: this.autoWarmupEnabled,
|
|
2591
2676
|
},
|
|
2592
2677
|
security: {
|
|
2593
2678
|
adminTokenConfigured: !!getConfiguredAdminToken(),
|
|
@@ -3061,4 +3146,180 @@ export class AccountRotator {
|
|
|
3061
3146
|
errorCount,
|
|
3062
3147
|
};
|
|
3063
3148
|
}
|
|
3149
|
+
|
|
3150
|
+
/**
|
|
3151
|
+
* Send a minimal single-token request to the upstream Antigravity endpoint for a specific
|
|
3152
|
+
* quota pool key on a given account. Uses the cheapest model in that pool to minimise cost.
|
|
3153
|
+
* Applies normal error handling (markExhausted, markFlagged, markError) so the account state
|
|
3154
|
+
* stays consistent with regular traffic.
|
|
3155
|
+
*/
|
|
3156
|
+
async kickstartTimerForAccount(
|
|
3157
|
+
email: string,
|
|
3158
|
+
quotaModelKey: string,
|
|
3159
|
+
): Promise<{ ok: boolean; status: number; upstreamModel: string; error?: string }> {
|
|
3160
|
+
const account = this.accounts.find((a) => a.config.email === email);
|
|
3161
|
+
if (!account) {
|
|
3162
|
+
return { ok: false, status: 404, upstreamModel: "", error: "account not found" };
|
|
3163
|
+
}
|
|
3164
|
+
if (account.disabled || account.flagged) {
|
|
3165
|
+
return {
|
|
3166
|
+
ok: false,
|
|
3167
|
+
status: 409,
|
|
3168
|
+
upstreamModel: "",
|
|
3169
|
+
error: account.disabled ? "account disabled" : "account flagged",
|
|
3170
|
+
};
|
|
3171
|
+
}
|
|
3172
|
+
|
|
3173
|
+
try {
|
|
3174
|
+
await this.ensureValidToken(account);
|
|
3175
|
+
} catch (err) {
|
|
3176
|
+
return {
|
|
3177
|
+
ok: false,
|
|
3178
|
+
status: 401,
|
|
3179
|
+
upstreamModel: "",
|
|
3180
|
+
error: `token refresh failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
3181
|
+
};
|
|
3182
|
+
}
|
|
3183
|
+
|
|
3184
|
+
if (!account.accessToken) {
|
|
3185
|
+
return { ok: false, status: 401, upstreamModel: "", error: "no access token" };
|
|
3186
|
+
}
|
|
3187
|
+
|
|
3188
|
+
const upstreamModel =
|
|
3189
|
+
KICKSTART_MODEL_FOR_QUOTA_POOL[quotaModelKey] ?? quotaModelKey;
|
|
3190
|
+
|
|
3191
|
+
const body = JSON.stringify({
|
|
3192
|
+
project: account.config.projectId,
|
|
3193
|
+
model: upstreamModel,
|
|
3194
|
+
request: {
|
|
3195
|
+
contents: [{ role: "user", parts: [{ text: "." }] }],
|
|
3196
|
+
generationConfig: { maxOutputTokens: 1 },
|
|
3197
|
+
},
|
|
3198
|
+
});
|
|
3199
|
+
|
|
3200
|
+
const endpoint = ANTIGRAVITY_ENDPOINTS[ANTIGRAVITY_ENDPOINTS.length - 1];
|
|
3201
|
+
const url = `${endpoint}/v1internal:streamGenerateContent?alt=sse`;
|
|
3202
|
+
|
|
3203
|
+
const controller = new AbortController();
|
|
3204
|
+
const timeout = setTimeout(() => controller.abort(), 15_000);
|
|
3205
|
+
|
|
3206
|
+
let response: Response;
|
|
3207
|
+
try {
|
|
3208
|
+
response = await fetch(url, {
|
|
3209
|
+
method: "POST",
|
|
3210
|
+
headers: {
|
|
3211
|
+
"Content-Type": "application/json",
|
|
3212
|
+
Authorization: `Bearer ${account.accessToken}`,
|
|
3213
|
+
"User-Agent": QUOTA_USER_AGENT,
|
|
3214
|
+
"X-Goog-Api-Client": REQUEST_GOOG_API_CLIENT,
|
|
3215
|
+
"Client-Metadata": REQUEST_CLIENT_METADATA,
|
|
3216
|
+
},
|
|
3217
|
+
body,
|
|
3218
|
+
signal: controller.signal,
|
|
3219
|
+
});
|
|
3220
|
+
} catch (err) {
|
|
3221
|
+
clearTimeout(timeout);
|
|
3222
|
+
const msg = `kickstart network error: ${err instanceof Error ? err.message : String(err)}`;
|
|
3223
|
+
this.markError(account, msg);
|
|
3224
|
+
return { ok: false, status: 0, upstreamModel, error: msg };
|
|
3225
|
+
}
|
|
3226
|
+
clearTimeout(timeout);
|
|
3227
|
+
|
|
3228
|
+
// Consume and discard the response body to free the connection
|
|
3229
|
+
try {
|
|
3230
|
+
await response.body?.cancel();
|
|
3231
|
+
} catch {
|
|
3232
|
+
// ignore
|
|
3233
|
+
}
|
|
3234
|
+
|
|
3235
|
+
const label = account.config.label || account.config.email;
|
|
3236
|
+
|
|
3237
|
+
if (response.status === 429) {
|
|
3238
|
+
const cooldownMs = 60_000;
|
|
3239
|
+
this.markExhausted(account, quotaModelKey, cooldownMs, "kickstart 429");
|
|
3240
|
+
this.recordProvider429(account, quotaModelKey, cooldownMs);
|
|
3241
|
+
this.log(
|
|
3242
|
+
`${label} [${quotaModelKey}]: kickstart 429 — cooldown ${cooldownMs / 1000}s`,
|
|
3243
|
+
"warn",
|
|
3244
|
+
);
|
|
3245
|
+
return { ok: false, status: 429, upstreamModel };
|
|
3246
|
+
}
|
|
3247
|
+
|
|
3248
|
+
if (response.status === 401 || response.status === 403) {
|
|
3249
|
+
this.markFlagged(
|
|
3250
|
+
account,
|
|
3251
|
+
`kickstart ${response.status} on ${upstreamModel}`,
|
|
3252
|
+
{ triggerProtectivePause: false },
|
|
3253
|
+
);
|
|
3254
|
+
return { ok: false, status: response.status, upstreamModel };
|
|
3255
|
+
}
|
|
3256
|
+
|
|
3257
|
+
if (response.status >= 500) {
|
|
3258
|
+
this.markError(account, `kickstart ${response.status} on ${upstreamModel}`);
|
|
3259
|
+
return { ok: false, status: response.status, upstreamModel };
|
|
3260
|
+
}
|
|
3261
|
+
|
|
3262
|
+
if (response.ok) {
|
|
3263
|
+
this.recordRequest(account, quotaModelKey);
|
|
3264
|
+
this.log(
|
|
3265
|
+
`${label} [${quotaModelKey}]: kickstart sent via ${upstreamModel} — timer should start within the next quota poll`,
|
|
3266
|
+
);
|
|
3267
|
+
}
|
|
3268
|
+
|
|
3269
|
+
return { ok: response.ok, status: response.status, upstreamModel };
|
|
3270
|
+
}
|
|
3271
|
+
|
|
3272
|
+
/**
|
|
3273
|
+
* Kickstart all quota pools that are currently idle (no active timer, or rolling
|
|
3274
|
+
* idle with 100% quota and a fresh resetTime) for a given account. Deduplicates by
|
|
3275
|
+
* upstream model so that pools sharing the same upstream (e.g. gemini-3.5-flash and
|
|
3276
|
+
* gemini-3.1-pro → gemini-3-flash) only receive one request.
|
|
3277
|
+
*/
|
|
3278
|
+
async kickstartAllFreshTimers(email: string): Promise<{
|
|
3279
|
+
ok: boolean;
|
|
3280
|
+
error?: string;
|
|
3281
|
+
results: Array<{
|
|
3282
|
+
quotaPools: string[];
|
|
3283
|
+
upstreamModel: string;
|
|
3284
|
+
ok: boolean;
|
|
3285
|
+
status: number;
|
|
3286
|
+
}>;
|
|
3287
|
+
}> {
|
|
3288
|
+
const account = this.accounts.find((a) => a.config.email === email);
|
|
3289
|
+
if (!account) {
|
|
3290
|
+
return { ok: false, error: "account not found", results: [] };
|
|
3291
|
+
}
|
|
3292
|
+
|
|
3293
|
+
const idlePools = account.quota.filter((q) => this.isQuotaIdleForKickstart(q));
|
|
3294
|
+
if (idlePools.length === 0) {
|
|
3295
|
+
return { ok: true, results: [] };
|
|
3296
|
+
}
|
|
3297
|
+
|
|
3298
|
+
// Deduplicate: group quota pool keys by their upstream model
|
|
3299
|
+
const upstreamToQuotaPools = new Map<string, string[]>();
|
|
3300
|
+
for (const q of idlePools) {
|
|
3301
|
+
const upstream = KICKSTART_MODEL_FOR_QUOTA_POOL[q.modelKey] ?? q.modelKey;
|
|
3302
|
+
const list = upstreamToQuotaPools.get(upstream) ?? [];
|
|
3303
|
+
list.push(q.modelKey);
|
|
3304
|
+
upstreamToQuotaPools.set(upstream, list);
|
|
3305
|
+
}
|
|
3306
|
+
|
|
3307
|
+
const results: Array<{
|
|
3308
|
+
quotaPools: string[];
|
|
3309
|
+
upstreamModel: string;
|
|
3310
|
+
ok: boolean;
|
|
3311
|
+
status: number;
|
|
3312
|
+
}> = [];
|
|
3313
|
+
|
|
3314
|
+
for (const [upstreamModel, quotaPools] of upstreamToQuotaPools) {
|
|
3315
|
+
// Use the primary quota pool key for this upstream (for recordRequest/markExhausted)
|
|
3316
|
+
const primaryQuotaKey =
|
|
3317
|
+
QUOTA_POOL_FOR_KICKSTART_MODEL[upstreamModel] ?? quotaPools[0];
|
|
3318
|
+
const result = await this.kickstartTimerForAccount(email, primaryQuotaKey);
|
|
3319
|
+
results.push({ quotaPools, upstreamModel, ok: result.ok, status: result.status });
|
|
3320
|
+
}
|
|
3321
|
+
|
|
3322
|
+
const allOk = results.every((r) => r.ok);
|
|
3323
|
+
return { ok: allOk, results };
|
|
3324
|
+
}
|
|
3064
3325
|
}
|