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/proxy.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  import {
4
4
  createServer,
5
5
  type IncomingMessage,
6
+ type Server,
6
7
  type ServerResponse,
7
8
  } from "node:http";
8
9
  import { Readable } from "node:stream";
@@ -33,6 +34,8 @@ import {
33
34
  serveAccountFreshWindowStartsApi,
34
35
  serveClearInFlightApi,
35
36
  serveClearBreakerApi,
37
+ serveKickstartApi,
38
+ serveAutoWarmupApi,
36
39
  serveStaticCss,
37
40
  serveStaticJs,
38
41
  } from "./dashboard.js";
@@ -120,6 +123,8 @@ export type RotationOutcome<T> =
120
123
  errorText: string;
121
124
  retryAfterMs?: number;
122
125
  endpoint?: string;
126
+ context?: RotationAttemptContext;
127
+ totalMs?: number;
123
128
  };
124
129
 
125
130
  /**
@@ -240,6 +245,266 @@ function capCooldown(ms: number): number {
240
245
  return Math.min(ms, MAX_COOLDOWN_MS);
241
246
  }
242
247
 
248
+ type UpstreamActionDecision =
249
+ | { kind: "retry" }
250
+ | {
251
+ kind: "fail";
252
+ status: number;
253
+ errorText: string;
254
+ retryAfterMs?: number;
255
+ endpoint?: string;
256
+ context: RotationAttemptContext;
257
+ totalMs: number;
258
+ actionKind: Exclude<UpstreamAction["kind"], "success">;
259
+ providerResourceExhausted?: boolean;
260
+ noReplacementReason?: string;
261
+ };
262
+
263
+ type UpstreamFailureDecision = Extract<UpstreamActionDecision, { kind: "fail" }>;
264
+ type UpstreamFailureExtra = Partial<
265
+ Pick<
266
+ UpstreamFailureDecision,
267
+ | "retryAfterMs"
268
+ | "endpoint"
269
+ | "providerResourceExhausted"
270
+ | "noReplacementReason"
271
+ >
272
+ >;
273
+
274
+ type UpstreamActionHandlerOptions = {
275
+ action: Exclude<UpstreamAction, { kind: "success" }>;
276
+ rotator: AccountRotator;
277
+ account: AccountRuntime;
278
+ model: string;
279
+ modelKey: string;
280
+ label: string;
281
+ context: RotationAttemptContext;
282
+ logRequestEnd: (status: string | number, extra?: string) => void;
283
+ rotateAndRelease: () => Promise<AccountRuntime | null>;
284
+ writeLog: (message: string, level?: "info" | "warn" | "error") => void;
285
+ recordFailureAttempt?: (statusCode: number) => void;
286
+ };
287
+
288
+ function buildFailureDecision(
289
+ options: UpstreamActionHandlerOptions,
290
+ status: number,
291
+ errorText: string,
292
+ extra: UpstreamFailureExtra = {},
293
+ ): UpstreamFailureDecision {
294
+ return {
295
+ kind: "fail",
296
+ status,
297
+ errorText,
298
+ context: options.context,
299
+ totalMs: Date.now() - options.context.requestStartMs,
300
+ actionKind: options.action.kind,
301
+ endpoint: "endpoint" in options.action ? options.action.endpoint : undefined,
302
+ ...extra,
303
+ };
304
+ }
305
+
306
+ function buildNoReplacementDecision(
307
+ options: UpstreamActionHandlerOptions,
308
+ reason: string,
309
+ ): UpstreamFailureDecision {
310
+ const retryAfterMs = options.rotator.getRetryAfterMs(options.model);
311
+ if (retryAfterMs > 0) {
312
+ return buildFailureDecision(
313
+ options,
314
+ 429,
315
+ `All accounts cooling down or model circuit breaker active: ${reason}`,
316
+ { retryAfterMs, noReplacementReason: reason },
317
+ );
318
+ }
319
+ return buildFailureDecision(
320
+ options,
321
+ 503,
322
+ `All accounts exhausted or disabled: ${reason}`,
323
+ { noReplacementReason: reason },
324
+ );
325
+ }
326
+
327
+ async function handleUpstreamAccountAction(
328
+ options: UpstreamActionHandlerOptions,
329
+ ): Promise<UpstreamActionDecision> {
330
+ const {
331
+ action,
332
+ rotator,
333
+ account,
334
+ model,
335
+ modelKey,
336
+ label,
337
+ logRequestEnd,
338
+ rotateAndRelease,
339
+ writeLog,
340
+ recordFailureAttempt,
341
+ } = options;
342
+
343
+ if (action.kind === "rate-limited") {
344
+ writeLog(
345
+ `[${label}] 429 rate limited${action.providerResourceExhausted ? " (RESOURCE_EXHAUSTED)" : ""}, cooldown ${Math.ceil(action.cooldownMs / 1000)}s. Error text: ${action.errorText.slice(0, 300)}`,
346
+ "warn",
347
+ );
348
+ recordFailureAttempt?.(429);
349
+ rotator.markExhausted(
350
+ account,
351
+ model,
352
+ action.cooldownMs,
353
+ action.errorText.slice(0, 300),
354
+ );
355
+ rotator.recordProvider429(account, model, action.cooldownMs);
356
+ logRequestEnd(
357
+ 429,
358
+ `cooldownMs=${action.cooldownMs}${action.providerResourceExhausted ? " resourceExhausted=true" : ""} endpoint=${action.endpoint}`,
359
+ );
360
+ return buildFailureDecision(options, 429, action.errorText, {
361
+ retryAfterMs: action.cooldownMs,
362
+ providerResourceExhausted: action.providerResourceExhausted,
363
+ });
364
+ }
365
+
366
+ if (action.kind === "flagged-401") {
367
+ writeLog(
368
+ `[${label}] BLOCKED (401): ${action.errorText.slice(0, 200)}`,
369
+ "error",
370
+ );
371
+ const lower401 = action.errorText.toLowerCase();
372
+ const matched401 = FLAG_PATTERNS.filter((p) => lower401.includes(p));
373
+ const ctx401 = rotator.getFlagContext(account, modelKey);
374
+ reportFlagEvent({
375
+ flagHttpStatus: 401,
376
+ flagPatternsMatched:
377
+ matched401.length > 0 ? matched401 : ["blocked_401" as FlagPattern],
378
+ model: modelKey,
379
+ timerType: ctx401.timerType as FlagEventData["timerType"],
380
+ accountQuotaPercent: ctx401.accountQuotaPercent,
381
+ wasProAccount: ctx401.wasProAccount,
382
+ accountTotalRequests: account.totalRequests,
383
+ accountRequestsLastHour: ctx401.accountRequestsLastHour,
384
+ accountConcurrentAtFlag: account.inFlightRequests,
385
+ poolSize: ctx401.poolSize,
386
+ poolHealthyCount: ctx401.poolHealthyCount,
387
+ protectivePauseTriggered: false,
388
+ uptimeSeconds: ctx401.uptimeSeconds,
389
+ timeSinceLastFlagSeconds: -1,
390
+ });
391
+ rotator.markFlagged(
392
+ account,
393
+ `Account blocked (401): ${action.errorText.slice(0, 300)}`,
394
+ );
395
+ logRequestEnd(401, `endpoint=${action.endpoint}`);
396
+ const nextAccount = await rotateAndRelease();
397
+ if (!nextAccount) {
398
+ return buildNoReplacementDecision(
399
+ options,
400
+ `no replacement account remained after ${label} was flagged with 401`,
401
+ );
402
+ }
403
+ return { kind: "retry" };
404
+ }
405
+
406
+ if (action.kind === "flagged-403") {
407
+ writeLog(`[${label}] FLAGGED: ${action.errorText.slice(0, 200)}`, "error");
408
+ recordFailureAttempt?.(403);
409
+ logRequestEnd(403, `endpoint=${action.endpoint}`);
410
+ const lower = action.errorText.toLowerCase();
411
+ const matchedPatterns = FLAG_PATTERNS.filter((p) => lower.includes(p));
412
+ const ctx403 = rotator.getFlagContext(account, modelKey);
413
+ reportFlagEvent({
414
+ flagHttpStatus: 403,
415
+ flagPatternsMatched: matchedPatterns,
416
+ model: modelKey,
417
+ timerType: ctx403.timerType as FlagEventData["timerType"],
418
+ accountQuotaPercent: ctx403.accountQuotaPercent,
419
+ wasProAccount: ctx403.wasProAccount,
420
+ accountTotalRequests: account.totalRequests,
421
+ accountRequestsLastHour: ctx403.accountRequestsLastHour,
422
+ accountConcurrentAtFlag: account.inFlightRequests,
423
+ poolSize: ctx403.poolSize,
424
+ poolHealthyCount: ctx403.poolHealthyCount,
425
+ protectivePauseTriggered: false,
426
+ uptimeSeconds: ctx403.uptimeSeconds,
427
+ timeSinceLastFlagSeconds: -1,
428
+ });
429
+ rotator.markFlagged(account, action.errorText.slice(0, 300));
430
+ const nextAccount = await rotateAndRelease();
431
+ if (!nextAccount) {
432
+ return buildNoReplacementDecision(
433
+ options,
434
+ `no replacement account remained after ${label} was flagged with 403`,
435
+ );
436
+ }
437
+ return { kind: "retry" };
438
+ }
439
+
440
+ if (action.kind === "forbidden") {
441
+ writeLog(`[${label}] 403: ${action.errorText.slice(0, 200)}`, "warn");
442
+ logRequestEnd(403, `endpoint=${action.endpoint}`);
443
+ return buildFailureDecision(options, 403, action.errorText);
444
+ }
445
+
446
+ if (action.kind === "not-found") {
447
+ writeLog(
448
+ `[${label}] 404 from ${action.endpoint}: ${action.errorText.slice(0, 200)}`,
449
+ "warn",
450
+ );
451
+ logRequestEnd(404, `endpoint=${action.endpoint}`);
452
+ return buildFailureDecision(options, 404, action.errorText);
453
+ }
454
+
455
+ if (action.kind === "bad-request") {
456
+ writeLog(
457
+ `[${label}] 400 Bad Request from ${action.endpoint}: ${action.errorText.slice(0, 500)}`,
458
+ "warn",
459
+ );
460
+ logRequestEnd(400, `endpoint=${action.endpoint}`);
461
+ return buildFailureDecision(options, 400, action.errorText);
462
+ }
463
+
464
+ if (action.kind === "server-error-503") {
465
+ writeLog(
466
+ `[${label}] Server error 503: ${action.errorText.slice(0, 200)}`,
467
+ "warn",
468
+ );
469
+ recordFailureAttempt?.(503);
470
+ logRequestEnd(503, `endpoint=${action.endpoint}`);
471
+ return buildFailureDecision(options, 503, action.errorText);
472
+ }
473
+
474
+ writeLog(
475
+ `[${label}] Server error ${action.httpStatus}: ${action.errorText.slice(0, 200)}`,
476
+ "warn",
477
+ );
478
+ recordFailureAttempt?.(action.httpStatus);
479
+ logRequestEnd(action.httpStatus, `endpoint=${action.endpoint}`);
480
+ rotator.markError(
481
+ account,
482
+ `${action.httpStatus}: ${action.errorText.slice(0, 200)}`,
483
+ );
484
+ const nextAccount = await rotateAndRelease();
485
+ if (!nextAccount) {
486
+ return buildNoReplacementDecision(
487
+ options,
488
+ `no replacement account remained after ${label} failed with ${action.httpStatus}`,
489
+ );
490
+ }
491
+ return { kind: "retry" };
492
+ }
493
+
494
+ function failureDecisionToOutcome<T>(
495
+ decision: UpstreamFailureDecision,
496
+ ): RotationOutcome<T> {
497
+ return {
498
+ ok: false,
499
+ status: decision.status,
500
+ errorText: decision.errorText,
501
+ retryAfterMs: decision.retryAfterMs,
502
+ endpoint: decision.endpoint,
503
+ context: decision.context,
504
+ totalMs: decision.totalMs,
505
+ };
506
+ }
507
+
243
508
  function formatError(err: unknown): string {
244
509
  if (!(err instanceof Error)) return String(err);
245
510
  const cause = err.cause;
@@ -654,21 +919,29 @@ export async function withRotation<T>(
654
919
  context: RotationAttemptContext,
655
920
  ) => Promise<T>,
656
921
  ): Promise<RotationOutcome<T>> {
657
- const sendNoAccountsAvailable = (reason: string): RotationOutcome<T> => {
922
+ const sendNoAccountsAvailable = (
923
+ reason: string,
924
+ context?: RotationAttemptContext,
925
+ ): RotationOutcome<T> => {
658
926
  log(`[${model}] No healthy account available: ${reason}`, rotator, "warn");
659
927
  const retryAfterMs = rotator.getRetryAfterMs(model);
928
+ const contextFields = context
929
+ ? { context, totalMs: Date.now() - context.requestStartMs }
930
+ : {};
660
931
  if (retryAfterMs > 0) {
661
932
  return {
662
933
  ok: false,
663
934
  status: 429,
664
935
  errorText: `All accounts cooling down or model circuit breaker active: ${reason}`,
665
936
  retryAfterMs,
937
+ ...contextFields,
666
938
  };
667
939
  }
668
940
  return {
669
941
  ok: false,
670
942
  status: 503,
671
943
  errorText: `All accounts exhausted or disabled: ${reason}`,
944
+ ...contextFields,
672
945
  };
673
946
  };
674
947
 
@@ -754,186 +1027,21 @@ export async function withRotation<T>(
754
1027
  modelKey,
755
1028
  );
756
1029
 
757
- if (action.kind === "rate-limited") {
758
- log(
759
- `[${label}] 429 rate limited${action.providerResourceExhausted ? " (RESOURCE_EXHAUSTED)" : ""}, cooldown ${Math.ceil(action.cooldownMs / 1000)}s. Error text: ${action.errorText.slice(0, 300)}`,
1030
+ if (action.kind !== "success") {
1031
+ const decision = await handleUpstreamAccountAction({
1032
+ action,
760
1033
  rotator,
761
- "warn",
762
- );
763
- rotator.markExhausted(
764
1034
  account,
765
1035
  model,
766
- action.cooldownMs,
767
- action.errorText.slice(0, 300),
768
- );
769
- rotator.recordProvider429(account, model, action.cooldownMs);
770
- logRequestEnd(
771
- 429,
772
- `cooldownMs=${action.cooldownMs}${action.providerResourceExhausted ? " resourceExhausted=true" : ""}`,
773
- );
774
- return {
775
- ok: false,
776
- status: 429,
777
- errorText: action.errorText,
778
- retryAfterMs: action.cooldownMs,
779
- endpoint,
780
- };
781
- }
782
-
783
- if (action.kind === "flagged-401") {
784
- log(
785
- `[${label}] BLOCKED (401): ${action.errorText.slice(0, 200)}`,
786
- rotator,
787
- "error",
788
- );
789
- const lower401 = action.errorText.toLowerCase();
790
- const matched401 = FLAG_PATTERNS.filter((p) => lower401.includes(p));
791
- const ctx401 = rotator.getFlagContext(account, modelKey);
792
- reportFlagEvent({
793
- flagHttpStatus: 401,
794
- flagPatternsMatched:
795
- matched401.length > 0 ? matched401 : ["blocked_401" as FlagPattern],
796
- model: modelKey,
797
- timerType: ctx401.timerType as FlagEventData["timerType"],
798
- accountQuotaPercent: ctx401.accountQuotaPercent,
799
- wasProAccount: ctx401.wasProAccount,
800
- accountTotalRequests: account.totalRequests,
801
- accountRequestsLastHour: ctx401.accountRequestsLastHour,
802
- accountConcurrentAtFlag: account.inFlightRequests,
803
- poolSize: ctx401.poolSize,
804
- poolHealthyCount: ctx401.poolHealthyCount,
805
- protectivePauseTriggered: false,
806
- uptimeSeconds: ctx401.uptimeSeconds,
807
- timeSinceLastFlagSeconds: -1,
808
- });
809
- rotator.markFlagged(
810
- account,
811
- `Account blocked (401): ${action.errorText.slice(0, 300)}`,
812
- );
813
- logRequestEnd(401);
814
- const nextAccount = await rotateAndRelease();
815
- if (!nextAccount) {
816
- return sendNoAccountsAvailable(
817
- `no replacement account remained after ${label} was flagged with 401`,
818
- );
819
- }
820
- continue;
821
- }
822
-
823
- if (action.kind === "flagged-403") {
824
- log(
825
- `[${label}] FLAGGED: ${action.errorText.slice(0, 200)}`,
826
- rotator,
827
- "error",
828
- );
829
- const lower = action.errorText.toLowerCase();
830
- const matchedPatterns = FLAG_PATTERNS.filter((p) => lower.includes(p));
831
- const ctx403 = rotator.getFlagContext(account, modelKey);
832
- reportFlagEvent({
833
- flagHttpStatus: 403,
834
- flagPatternsMatched: matchedPatterns,
835
- model: modelKey,
836
- timerType: ctx403.timerType as FlagEventData["timerType"],
837
- accountQuotaPercent: ctx403.accountQuotaPercent,
838
- wasProAccount: ctx403.wasProAccount,
839
- accountTotalRequests: account.totalRequests,
840
- accountRequestsLastHour: ctx403.accountRequestsLastHour,
841
- accountConcurrentAtFlag: account.inFlightRequests,
842
- poolSize: ctx403.poolSize,
843
- poolHealthyCount: ctx403.poolHealthyCount,
844
- protectivePauseTriggered: false,
845
- uptimeSeconds: ctx403.uptimeSeconds,
846
- timeSinceLastFlagSeconds: -1,
1036
+ modelKey,
1037
+ label,
1038
+ context,
1039
+ logRequestEnd,
1040
+ rotateAndRelease,
1041
+ writeLog: (message, level = "info") => log(message, rotator, level),
847
1042
  });
848
- rotator.markFlagged(account, action.errorText.slice(0, 300));
849
- logRequestEnd(403);
850
- const nextAccount = await rotateAndRelease();
851
- if (!nextAccount) {
852
- return sendNoAccountsAvailable(
853
- `no replacement account remained after ${label} was flagged with 403`,
854
- );
855
- }
856
- continue;
857
- }
858
-
859
- if (action.kind === "forbidden") {
860
- log(
861
- `[${label}] 403: ${action.errorText.slice(0, 200)}`,
862
- rotator,
863
- "warn",
864
- );
865
- logRequestEnd(403);
866
- return {
867
- ok: false,
868
- status: 403,
869
- errorText: action.errorText,
870
- endpoint,
871
- };
872
- }
873
-
874
- if (action.kind === "not-found") {
875
- log(
876
- `[${label}] 404 from ${action.endpoint}: ${action.errorText.slice(0, 200)}`,
877
- rotator,
878
- "warn",
879
- );
880
- logRequestEnd(404, `endpoint=${action.endpoint}`);
881
- return {
882
- ok: false,
883
- status: 404,
884
- errorText: action.errorText,
885
- endpoint,
886
- };
887
- }
888
-
889
- if (action.kind === "bad-request") {
890
- log(
891
- `[${label}] 400 Bad Request from ${action.endpoint}: ${action.errorText.slice(0, 500)}`,
892
- rotator,
893
- "warn",
894
- );
895
- logRequestEnd(400, `endpoint=${action.endpoint}`);
896
- return {
897
- ok: false,
898
- status: 400,
899
- errorText: action.errorText,
900
- endpoint,
901
- };
902
- }
903
-
904
- if (action.kind === "server-error-503") {
905
- log(
906
- `[${label}] Server error 503: ${action.errorText.slice(0, 200)}`,
907
- rotator,
908
- "warn",
909
- );
910
- logRequestEnd(503, `endpoint=${action.endpoint}`);
911
- return {
912
- ok: false,
913
- status: 503,
914
- errorText: action.errorText,
915
- endpoint,
916
- };
917
- }
918
-
919
- if (action.kind === "rotate-on-5xx") {
920
- log(
921
- `[${label}] Server error ${action.httpStatus}: ${action.errorText.slice(0, 200)}`,
922
- rotator,
923
- "warn",
924
- );
925
- logRequestEnd(action.httpStatus, `endpoint=${action.endpoint}`);
926
- rotator.markError(
927
- account,
928
- `${action.httpStatus}: ${action.errorText.slice(0, 200)}`,
929
- );
930
- const nextAccount = await rotateAndRelease();
931
- if (!nextAccount) {
932
- return sendNoAccountsAvailable(
933
- `no replacement account remained after ${label} failed with ${action.httpStatus}`,
934
- );
935
- }
936
- continue;
1043
+ if (decision.kind === "retry") continue;
1044
+ return failureDecisionToOutcome(decision);
937
1045
  }
938
1046
 
939
1047
  // success
@@ -1079,6 +1187,69 @@ async function handleProxyRequest(
1079
1187
  }
1080
1188
  return nextAccount;
1081
1189
  };
1190
+ const sendFailureDecision = (decision: UpstreamFailureDecision): void => {
1191
+ if (decision.noReplacementReason) {
1192
+ sendNoAccountsAvailable(decision.noReplacementReason);
1193
+ return;
1194
+ }
1195
+
1196
+ const label = decision.context.label;
1197
+ if (decision.actionKind === "rate-limited") {
1198
+ const retryAfterMs = decision.retryAfterMs ?? 0;
1199
+ res.writeHead(429, {
1200
+ "Content-Type": "application/json",
1201
+ "Retry-After": String(Math.ceil(retryAfterMs / 1000)),
1202
+ });
1203
+ res.end(
1204
+ JSON.stringify({
1205
+ error: decision.providerResourceExhausted
1206
+ ? "Resource exhausted"
1207
+ : "Rate limited",
1208
+ reason: decision.providerResourceExhausted
1209
+ ? `${label} hit provider RESOURCE_EXHAUSTED; not retrying another account to avoid pool-wide hammering`
1210
+ : `${label} was rate limited; not retrying another account for account-safety`,
1211
+ model: body.model,
1212
+ account: label,
1213
+ retryAfterMs,
1214
+ }),
1215
+ );
1216
+ return;
1217
+ }
1218
+
1219
+ if (decision.actionKind === "forbidden") {
1220
+ res.writeHead(403, { "Content-Type": "application/json" });
1221
+ res.end(decision.errorText || JSON.stringify({ error: "Forbidden" }));
1222
+ return;
1223
+ }
1224
+
1225
+ if (decision.actionKind === "not-found") {
1226
+ res.writeHead(404, { "Content-Type": "application/json" });
1227
+ res.end(decision.errorText || JSON.stringify({ error: "Not found" }));
1228
+ return;
1229
+ }
1230
+
1231
+ if (decision.actionKind === "bad-request") {
1232
+ res.writeHead(400, { "Content-Type": "application/json" });
1233
+ res.end(decision.errorText || JSON.stringify({ error: "Bad request" }));
1234
+ return;
1235
+ }
1236
+
1237
+ if (decision.actionKind === "server-error-503") {
1238
+ res.writeHead(503, { "Content-Type": "application/json" });
1239
+ res.end(
1240
+ decision.errorText ||
1241
+ JSON.stringify({
1242
+ error: "Server unavailable",
1243
+ account: label,
1244
+ model: body.model,
1245
+ }),
1246
+ );
1247
+ return;
1248
+ }
1249
+
1250
+ res.writeHead(decision.status, { "Content-Type": "application/json" });
1251
+ res.end(decision.errorText || JSON.stringify({ error: "Upstream error" }));
1252
+ };
1082
1253
 
1083
1254
  for (let attempt = 0; attempt < MAX_ENDPOINT_RETRIES; attempt++) {
1084
1255
  const account = await rotator.getActiveAccount(body.model);
@@ -1154,197 +1325,34 @@ async function handleProxyRequest(
1154
1325
  modelKey,
1155
1326
  );
1156
1327
 
1157
- if (action.kind === "rate-limited") {
1158
- proxyLog(
1159
- `[${label}] 429 rate limited${action.providerResourceExhausted ? " (RESOURCE_EXHAUSTED)" : ""}, cooldown ${Math.ceil(action.cooldownMs / 1000)}s. Error text: ${action.errorText.slice(0, 300)}`,
1160
- "warn",
1161
- );
1162
- recordOutcome(429);
1163
- logRequestEnd(
1164
- 429,
1165
- `cooldownMs=${action.cooldownMs}${action.providerResourceExhausted ? " resourceExhausted=true" : ""} endpoint=${endpoint}`,
1166
- );
1167
- rotator.markExhausted(account, body.model, action.cooldownMs);
1168
- rotator.recordProvider429(account, body.model, action.cooldownMs);
1169
-
1170
- // Safety first: do NOT immediately retry another account on 429.
1171
- // Provider-side 429s can represent daily/request buckets or shared project pressure;
1172
- // cascading retries burn the full pool and increase ban/flag risk.
1173
- res.writeHead(429, {
1174
- "Content-Type": "application/json",
1175
- "Retry-After": String(Math.ceil(action.cooldownMs / 1000)),
1176
- });
1177
- res.end(
1178
- JSON.stringify({
1179
- error: action.providerResourceExhausted
1180
- ? "Resource exhausted"
1181
- : "Rate limited",
1182
- reason: action.providerResourceExhausted
1183
- ? `${label} hit provider RESOURCE_EXHAUSTED; not retrying another account to avoid pool-wide hammering`
1184
- : `${label} was rate limited; not retrying another account for account-safety`,
1185
- model: body.model,
1186
- account: label,
1187
- retryAfterMs: action.cooldownMs,
1188
- }),
1189
- );
1190
- return;
1191
- }
1192
-
1193
- if (action.kind === "flagged-401") {
1194
- proxyLog(
1195
- `[${label}] BLOCKED (401): ${action.errorText.slice(0, 200)}`,
1196
- "error",
1197
- );
1198
-
1199
- // Telemetry: report flag event BEFORE markFlagged (which may trigger protective pause)
1200
- const lower401 = action.errorText.toLowerCase();
1201
- const matched401 = FLAG_PATTERNS.filter((p) => lower401.includes(p));
1202
- const ctx401 = rotator.getFlagContext(account, modelKey);
1203
- reportFlagEvent({
1204
- flagHttpStatus: 401,
1205
- flagPatternsMatched:
1206
- matched401.length > 0 ? matched401 : ["blocked_401" as FlagPattern],
1207
- model: modelKey,
1208
- timerType: ctx401.timerType as FlagEventData["timerType"],
1209
- accountQuotaPercent: ctx401.accountQuotaPercent,
1210
- wasProAccount: ctx401.wasProAccount,
1211
- accountTotalRequests: account.totalRequests,
1212
- accountRequestsLastHour: ctx401.accountRequestsLastHour,
1213
- accountConcurrentAtFlag: account.inFlightRequests,
1214
- poolSize: ctx401.poolSize,
1215
- poolHealthyCount: ctx401.poolHealthyCount,
1216
- protectivePauseTriggered: false, // not yet — markFlagged decides
1217
- uptimeSeconds: ctx401.uptimeSeconds,
1218
- timeSinceLastFlagSeconds: -1, // filled by reporter
1219
- });
1220
-
1221
- rotator.markFlagged(
1328
+ if (action.kind !== "success") {
1329
+ const context: RotationAttemptContext = {
1222
1330
  account,
1223
- `Account blocked (401): ${action.errorText.slice(0, 300)}`,
1224
- );
1225
- logRequestEnd(401, `endpoint=${endpoint}`);
1226
- const nextAccount = await rotateAndRelease();
1227
- if (!nextAccount) {
1228
- sendNoAccountsAvailable(
1229
- `no replacement account remained after ${label} was flagged with 401`,
1230
- );
1231
- return;
1232
- }
1233
- continue;
1234
- }
1235
-
1236
- if (action.kind === "flagged-403") {
1237
- proxyLog(
1238
- `[${label}] FLAGGED: ${action.errorText.slice(0, 200)}`,
1239
- "error",
1240
- );
1241
- recordOutcome(403);
1242
- logRequestEnd(403, `endpoint=${endpoint}`);
1243
-
1244
- const matchedPatterns = FLAG_PATTERNS.filter((p) =>
1245
- action.errorText.toLowerCase().includes(p),
1246
- );
1247
- const ctx403 = rotator.getFlagContext(account, modelKey);
1248
- reportFlagEvent({
1249
- flagHttpStatus: 403,
1250
- flagPatternsMatched: matchedPatterns,
1251
- model: modelKey,
1252
- timerType: ctx403.timerType as FlagEventData["timerType"],
1253
- accountQuotaPercent: ctx403.accountQuotaPercent,
1254
- wasProAccount: ctx403.wasProAccount,
1255
- accountTotalRequests: account.totalRequests,
1256
- accountRequestsLastHour: ctx403.accountRequestsLastHour,
1257
- accountConcurrentAtFlag: account.inFlightRequests,
1258
- poolSize: ctx403.poolSize,
1259
- poolHealthyCount: ctx403.poolHealthyCount,
1260
- protectivePauseTriggered: false, // not yet
1261
- uptimeSeconds: ctx403.uptimeSeconds,
1262
- timeSinceLastFlagSeconds: -1, // filled by reporter
1331
+ label,
1332
+ modelKey,
1333
+ displayModelKey,
1334
+ requestId,
1335
+ requestStartMs,
1336
+ endpoint,
1337
+ };
1338
+ const decision = await handleUpstreamAccountAction({
1339
+ action,
1340
+ rotator,
1341
+ account,
1342
+ model: body.model,
1343
+ modelKey,
1344
+ label,
1345
+ context,
1346
+ logRequestEnd,
1347
+ rotateAndRelease,
1348
+ writeLog: proxyLog,
1349
+ recordFailureAttempt: recordOutcome,
1263
1350
  });
1264
-
1265
- rotator.markFlagged(account, action.errorText.slice(0, 300));
1266
- const nextAccount = await rotateAndRelease();
1267
- if (!nextAccount) {
1268
- sendNoAccountsAvailable(
1269
- `no replacement account remained after ${label} was flagged with 403`,
1270
- );
1271
- return;
1272
- }
1273
- continue;
1274
- }
1275
-
1276
- if (action.kind === "forbidden") {
1277
- proxyLog(`[${label}] 403: ${action.errorText.slice(0, 200)}`, "warn");
1278
- logRequestEnd(403, `endpoint=${action.endpoint}`);
1279
- res.writeHead(403, { "Content-Type": "application/json" });
1280
- res.end(action.errorText || JSON.stringify({ error: "Forbidden" }));
1351
+ if (decision.kind === "retry") continue;
1352
+ sendFailureDecision(decision);
1281
1353
  return;
1282
1354
  }
1283
1355
 
1284
- if (action.kind === "not-found") {
1285
- proxyLog(
1286
- `[${label}] 404 from ${action.endpoint}: ${action.errorText.slice(0, 200)}`,
1287
- "warn",
1288
- );
1289
- logRequestEnd(404, `endpoint=${action.endpoint}`);
1290
- res.writeHead(404, { "Content-Type": "application/json" });
1291
- res.end(action.errorText || JSON.stringify({ error: "Not found" }));
1292
- return;
1293
- }
1294
-
1295
- if (action.kind === "bad-request") {
1296
- proxyLog(
1297
- `[${label}] 400 Bad Request from ${action.endpoint}: ${action.errorText.slice(0, 500)}`,
1298
- "warn",
1299
- );
1300
- logRequestEnd(400, `endpoint=${action.endpoint}`);
1301
- res.writeHead(400, { "Content-Type": "application/json" });
1302
- res.end(action.errorText || JSON.stringify({ error: "Bad request" }));
1303
- return;
1304
- }
1305
-
1306
- if (action.kind === "server-error-503") {
1307
- proxyLog(
1308
- `[${label}] Server error 503: ${action.errorText.slice(0, 200)}`,
1309
- "warn",
1310
- );
1311
- recordOutcome(503);
1312
- logRequestEnd(503, `endpoint=${action.endpoint}`);
1313
- // Return 503 as-is. Capacity errors still consume quota upstream,
1314
- // so retrying on another account would just burn more quota for nothing.
1315
- res.writeHead(503, { "Content-Type": "application/json" });
1316
- res.end(
1317
- action.errorText ||
1318
- JSON.stringify({
1319
- error: "Server unavailable",
1320
- account: label,
1321
- model: body.model,
1322
- }),
1323
- );
1324
- return;
1325
- }
1326
-
1327
- if (action.kind === "rotate-on-5xx") {
1328
- proxyLog(
1329
- `[${label}] Server error ${action.httpStatus}: ${action.errorText.slice(0, 200)}`,
1330
- "warn",
1331
- );
1332
- recordOutcome(action.httpStatus);
1333
- logRequestEnd(action.httpStatus, `endpoint=${action.endpoint}`);
1334
- rotator.markError(
1335
- account,
1336
- `${action.httpStatus}: ${action.errorText.slice(0, 200)}`,
1337
- );
1338
- const nextAccount = await rotateAndRelease();
1339
- if (!nextAccount) {
1340
- sendNoAccountsAvailable(
1341
- `no replacement account remained after ${label} failed with ${action.httpStatus}`,
1342
- );
1343
- return;
1344
- }
1345
- continue;
1346
- }
1347
-
1348
1356
  // Success or non-error client response
1349
1357
  const shouldRotate = rotator.recordRequest(account, body.model);
1350
1358
 
@@ -1454,7 +1462,7 @@ export function startProxy(
1454
1462
  rotator: AccountRotator,
1455
1463
  port: number,
1456
1464
  bindHost = "0.0.0.0",
1457
- ): void {
1465
+ ): Server {
1458
1466
  startVersionChecker();
1459
1467
  startNotificationPoller();
1460
1468
  const sseClients = new Set<ServerResponse>();
@@ -1539,7 +1547,6 @@ export function startProxy(
1539
1547
  }
1540
1548
 
1541
1549
  if (method === "GET" && pathname === "/auth/antigravity/callback") {
1542
- if (!requireAdmin(req, res)) return;
1543
1550
  handleHostedCallback(req, res, rotator).catch((err) => {
1544
1551
  log(`Hosted callback error: ${err}`, rotator, "error");
1545
1552
  if (!res.headersSent) {
@@ -1622,46 +1629,46 @@ export function startProxy(
1622
1629
  return;
1623
1630
  }
1624
1631
 
1625
- if (method === "POST" && url.startsWith("/api/enable/")) {
1632
+ if (method === "POST" && pathname.startsWith("/api/enable/")) {
1626
1633
  if (!requireAdmin(req, res)) return;
1627
- const email = decodeURIComponent(url.slice("/api/enable/".length));
1634
+ const email = decodeURIComponent(pathname.slice("/api/enable/".length));
1628
1635
  serveEnableApi(res, rotator, email);
1629
1636
  return;
1630
1637
  }
1631
1638
 
1632
- if (method === "POST" && url.startsWith("/api/disable/")) {
1639
+ if (method === "POST" && pathname.startsWith("/api/disable/")) {
1633
1640
  if (!requireAdmin(req, res)) return;
1634
- const email = decodeURIComponent(url.slice("/api/disable/".length));
1641
+ const email = decodeURIComponent(pathname.slice("/api/disable/".length));
1635
1642
  serveDisableApi(res, rotator, email);
1636
1643
  return;
1637
1644
  }
1638
1645
 
1639
- if (method === "POST" && url.startsWith("/api/quarantine/")) {
1646
+ if (method === "POST" && pathname.startsWith("/api/quarantine/")) {
1640
1647
  if (!requireAdmin(req, res)) return;
1641
- const email = decodeURIComponent(url.slice("/api/quarantine/".length));
1648
+ const email = decodeURIComponent(pathname.slice("/api/quarantine/".length));
1642
1649
  serveQuarantineApi(res, rotator, email);
1643
1650
  return;
1644
1651
  }
1645
1652
 
1646
- if (method === "POST" && url.startsWith("/api/restore/")) {
1653
+ if (method === "POST" && pathname.startsWith("/api/restore/")) {
1647
1654
  if (!requireAdmin(req, res)) return;
1648
- const email = decodeURIComponent(url.slice("/api/restore/".length));
1655
+ const email = decodeURIComponent(pathname.slice("/api/restore/".length));
1649
1656
  serveRestoreApi(res, rotator, email);
1650
1657
  return;
1651
1658
  }
1652
1659
 
1653
- if (method === "POST" && url.startsWith("/api/remove-account/")) {
1660
+ if (method === "POST" && pathname.startsWith("/api/remove-account/")) {
1654
1661
  if (!requireAdmin(req, res)) return;
1655
1662
  const email = decodeURIComponent(
1656
- url.slice("/api/remove-account/".length),
1663
+ pathname.slice("/api/remove-account/".length),
1657
1664
  );
1658
1665
  serveRemoveAccountApi(res, rotator, email);
1659
1666
  return;
1660
1667
  }
1661
1668
 
1662
- if (method === "POST" && url.startsWith("/api/set-tier/")) {
1669
+ if (method === "POST" && pathname.startsWith("/api/set-tier/")) {
1663
1670
  if (!requireAdmin(req, res)) return;
1664
- const rest = url.slice("/api/set-tier/".length);
1671
+ const rest = pathname.slice("/api/set-tier/".length);
1665
1672
  const lastSlash = rest.lastIndexOf("/");
1666
1673
  if (lastSlash < 0) {
1667
1674
  res.writeHead(400, { "Content-Type": "application/json" });
@@ -1679,9 +1686,9 @@ export function startProxy(
1679
1686
  return;
1680
1687
  }
1681
1688
 
1682
- if (method === "POST" && url.startsWith("/api/clear-inflight/")) {
1689
+ if (method === "POST" && pathname.startsWith("/api/clear-inflight/")) {
1683
1690
  if (!requireAdmin(req, res)) return;
1684
- const rest = url.slice("/api/clear-inflight/".length);
1691
+ const rest = pathname.slice("/api/clear-inflight/".length);
1685
1692
  const firstSlash = rest.indexOf("/");
1686
1693
  const email = decodeURIComponent(
1687
1694
  firstSlash >= 0 ? rest.slice(0, firstSlash) : rest,
@@ -1694,9 +1701,9 @@ export function startProxy(
1694
1701
  return;
1695
1702
  }
1696
1703
 
1697
- if (method === "POST" && url.startsWith("/api/clear-breaker/")) {
1704
+ if (method === "POST" && pathname.startsWith("/api/clear-breaker/")) {
1698
1705
  if (!requireAdmin(req, res)) return;
1699
- const rest = url.slice("/api/clear-breaker/".length);
1706
+ const rest = pathname.slice("/api/clear-breaker/".length);
1700
1707
  const modelKey =
1701
1708
  rest && rest !== "all" ? decodeURIComponent(rest) : undefined;
1702
1709
  serveClearBreakerApi(res, rotator, modelKey);
@@ -1705,22 +1712,22 @@ export function startProxy(
1705
1712
 
1706
1713
  if (
1707
1714
  method === "POST" &&
1708
- (url === "/api/settings/fresh-window-starts/on" ||
1709
- url === "/api/settings/fresh-window-starts/off")
1715
+ (pathname === "/api/settings/fresh-window-starts/on" ||
1716
+ pathname === "/api/settings/fresh-window-starts/off")
1710
1717
  ) {
1711
1718
  if (!requireAdmin(req, res)) return;
1712
1719
  trackFeature("freshWindowToggle");
1713
- serveFreshWindowStartsApi(res, rotator, url.endsWith("/on"));
1720
+ serveFreshWindowStartsApi(res, rotator, pathname.endsWith("/on"));
1714
1721
  return;
1715
1722
  }
1716
1723
 
1717
1724
  if (
1718
1725
  method === "POST" &&
1719
- url.startsWith("/api/account-fresh-window-starts/") &&
1720
- (url.endsWith("/on") || url.endsWith("/off"))
1726
+ pathname.startsWith("/api/account-fresh-window-starts/") &&
1727
+ (pathname.endsWith("/on") || pathname.endsWith("/off"))
1721
1728
  ) {
1722
1729
  if (!requireAdmin(req, res)) return;
1723
- const rest = url.slice("/api/account-fresh-window-starts/".length);
1730
+ const rest = pathname.slice("/api/account-fresh-window-starts/".length);
1724
1731
  const lastSlash = rest.lastIndexOf("/");
1725
1732
  const email = decodeURIComponent(rest.slice(0, lastSlash));
1726
1733
  const enabled = rest.slice(lastSlash + 1) === "on";
@@ -1728,6 +1735,33 @@ export function startProxy(
1728
1735
  return;
1729
1736
  }
1730
1737
 
1738
+ if (method === "POST" && pathname.startsWith("/api/kickstart/")) {
1739
+ if (!requireAdmin(req, res)) return;
1740
+ const rest = pathname.slice("/api/kickstart/".length);
1741
+ const firstSlash = rest.indexOf("/");
1742
+ if (firstSlash >= 0) {
1743
+ // /api/kickstart/:email/:modelKey
1744
+ const email = decodeURIComponent(rest.slice(0, firstSlash));
1745
+ const modelKey = decodeURIComponent(rest.slice(firstSlash + 1));
1746
+ serveKickstartApi(res, rotator, email, modelKey);
1747
+ } else {
1748
+ // /api/kickstart/:email — kickstart all fresh timers
1749
+ const email = decodeURIComponent(rest);
1750
+ serveKickstartApi(res, rotator, email);
1751
+ }
1752
+ return;
1753
+ }
1754
+
1755
+ if (
1756
+ method === "POST" &&
1757
+ (pathname === "/api/settings/auto-warmup/on" ||
1758
+ pathname === "/api/settings/auto-warmup/off")
1759
+ ) {
1760
+ if (!requireAdmin(req, res)) return;
1761
+ serveAutoWarmupApi(res, rotator, pathname.endsWith("/on"));
1762
+ return;
1763
+ }
1764
+
1731
1765
  if (method === "POST" && pathname === "/api/self-update") {
1732
1766
  if (!requireAdmin(req, res)) return;
1733
1767
  trackFeature("selfUpdate");
@@ -1869,4 +1903,5 @@ export function startProxy(
1869
1903
  log(`Dashboard: http://localhost:${port}/dashboard`, rotator);
1870
1904
  log(`Hosted login: http://localhost:${port}/login`, rotator);
1871
1905
  });
1906
+ return server;
1872
1907
  }