pi-antigravity-rotator 2.3.1 → 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";
@@ -122,6 +123,8 @@ export type RotationOutcome<T> =
122
123
  errorText: string;
123
124
  retryAfterMs?: number;
124
125
  endpoint?: string;
126
+ context?: RotationAttemptContext;
127
+ totalMs?: number;
125
128
  };
126
129
 
127
130
  /**
@@ -242,6 +245,266 @@ function capCooldown(ms: number): number {
242
245
  return Math.min(ms, MAX_COOLDOWN_MS);
243
246
  }
244
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
+
245
508
  function formatError(err: unknown): string {
246
509
  if (!(err instanceof Error)) return String(err);
247
510
  const cause = err.cause;
@@ -656,21 +919,29 @@ export async function withRotation<T>(
656
919
  context: RotationAttemptContext,
657
920
  ) => Promise<T>,
658
921
  ): Promise<RotationOutcome<T>> {
659
- const sendNoAccountsAvailable = (reason: string): RotationOutcome<T> => {
922
+ const sendNoAccountsAvailable = (
923
+ reason: string,
924
+ context?: RotationAttemptContext,
925
+ ): RotationOutcome<T> => {
660
926
  log(`[${model}] No healthy account available: ${reason}`, rotator, "warn");
661
927
  const retryAfterMs = rotator.getRetryAfterMs(model);
928
+ const contextFields = context
929
+ ? { context, totalMs: Date.now() - context.requestStartMs }
930
+ : {};
662
931
  if (retryAfterMs > 0) {
663
932
  return {
664
933
  ok: false,
665
934
  status: 429,
666
935
  errorText: `All accounts cooling down or model circuit breaker active: ${reason}`,
667
936
  retryAfterMs,
937
+ ...contextFields,
668
938
  };
669
939
  }
670
940
  return {
671
941
  ok: false,
672
942
  status: 503,
673
943
  errorText: `All accounts exhausted or disabled: ${reason}`,
944
+ ...contextFields,
674
945
  };
675
946
  };
676
947
 
@@ -756,186 +1027,21 @@ export async function withRotation<T>(
756
1027
  modelKey,
757
1028
  );
758
1029
 
759
- if (action.kind === "rate-limited") {
760
- log(
761
- `[${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,
762
1033
  rotator,
763
- "warn",
764
- );
765
- rotator.markExhausted(
766
1034
  account,
767
1035
  model,
768
- action.cooldownMs,
769
- action.errorText.slice(0, 300),
770
- );
771
- rotator.recordProvider429(account, model, action.cooldownMs);
772
- logRequestEnd(
773
- 429,
774
- `cooldownMs=${action.cooldownMs}${action.providerResourceExhausted ? " resourceExhausted=true" : ""}`,
775
- );
776
- return {
777
- ok: false,
778
- status: 429,
779
- errorText: action.errorText,
780
- retryAfterMs: action.cooldownMs,
781
- endpoint,
782
- };
783
- }
784
-
785
- if (action.kind === "flagged-401") {
786
- log(
787
- `[${label}] BLOCKED (401): ${action.errorText.slice(0, 200)}`,
788
- rotator,
789
- "error",
790
- );
791
- const lower401 = action.errorText.toLowerCase();
792
- const matched401 = FLAG_PATTERNS.filter((p) => lower401.includes(p));
793
- const ctx401 = rotator.getFlagContext(account, modelKey);
794
- reportFlagEvent({
795
- flagHttpStatus: 401,
796
- flagPatternsMatched:
797
- matched401.length > 0 ? matched401 : ["blocked_401" as FlagPattern],
798
- model: modelKey,
799
- timerType: ctx401.timerType as FlagEventData["timerType"],
800
- accountQuotaPercent: ctx401.accountQuotaPercent,
801
- wasProAccount: ctx401.wasProAccount,
802
- accountTotalRequests: account.totalRequests,
803
- accountRequestsLastHour: ctx401.accountRequestsLastHour,
804
- accountConcurrentAtFlag: account.inFlightRequests,
805
- poolSize: ctx401.poolSize,
806
- poolHealthyCount: ctx401.poolHealthyCount,
807
- protectivePauseTriggered: false,
808
- uptimeSeconds: ctx401.uptimeSeconds,
809
- timeSinceLastFlagSeconds: -1,
810
- });
811
- rotator.markFlagged(
812
- account,
813
- `Account blocked (401): ${action.errorText.slice(0, 300)}`,
814
- );
815
- logRequestEnd(401);
816
- const nextAccount = await rotateAndRelease();
817
- if (!nextAccount) {
818
- return sendNoAccountsAvailable(
819
- `no replacement account remained after ${label} was flagged with 401`,
820
- );
821
- }
822
- continue;
823
- }
824
-
825
- if (action.kind === "flagged-403") {
826
- log(
827
- `[${label}] FLAGGED: ${action.errorText.slice(0, 200)}`,
828
- rotator,
829
- "error",
830
- );
831
- const lower = action.errorText.toLowerCase();
832
- const matchedPatterns = FLAG_PATTERNS.filter((p) => lower.includes(p));
833
- const ctx403 = rotator.getFlagContext(account, modelKey);
834
- reportFlagEvent({
835
- flagHttpStatus: 403,
836
- flagPatternsMatched: matchedPatterns,
837
- model: modelKey,
838
- timerType: ctx403.timerType as FlagEventData["timerType"],
839
- accountQuotaPercent: ctx403.accountQuotaPercent,
840
- wasProAccount: ctx403.wasProAccount,
841
- accountTotalRequests: account.totalRequests,
842
- accountRequestsLastHour: ctx403.accountRequestsLastHour,
843
- accountConcurrentAtFlag: account.inFlightRequests,
844
- poolSize: ctx403.poolSize,
845
- poolHealthyCount: ctx403.poolHealthyCount,
846
- protectivePauseTriggered: false,
847
- uptimeSeconds: ctx403.uptimeSeconds,
848
- timeSinceLastFlagSeconds: -1,
1036
+ modelKey,
1037
+ label,
1038
+ context,
1039
+ logRequestEnd,
1040
+ rotateAndRelease,
1041
+ writeLog: (message, level = "info") => log(message, rotator, level),
849
1042
  });
850
- rotator.markFlagged(account, action.errorText.slice(0, 300));
851
- logRequestEnd(403);
852
- const nextAccount = await rotateAndRelease();
853
- if (!nextAccount) {
854
- return sendNoAccountsAvailable(
855
- `no replacement account remained after ${label} was flagged with 403`,
856
- );
857
- }
858
- continue;
859
- }
860
-
861
- if (action.kind === "forbidden") {
862
- log(
863
- `[${label}] 403: ${action.errorText.slice(0, 200)}`,
864
- rotator,
865
- "warn",
866
- );
867
- logRequestEnd(403);
868
- return {
869
- ok: false,
870
- status: 403,
871
- errorText: action.errorText,
872
- endpoint,
873
- };
874
- }
875
-
876
- if (action.kind === "not-found") {
877
- log(
878
- `[${label}] 404 from ${action.endpoint}: ${action.errorText.slice(0, 200)}`,
879
- rotator,
880
- "warn",
881
- );
882
- logRequestEnd(404, `endpoint=${action.endpoint}`);
883
- return {
884
- ok: false,
885
- status: 404,
886
- errorText: action.errorText,
887
- endpoint,
888
- };
889
- }
890
-
891
- if (action.kind === "bad-request") {
892
- log(
893
- `[${label}] 400 Bad Request from ${action.endpoint}: ${action.errorText.slice(0, 500)}`,
894
- rotator,
895
- "warn",
896
- );
897
- logRequestEnd(400, `endpoint=${action.endpoint}`);
898
- return {
899
- ok: false,
900
- status: 400,
901
- errorText: action.errorText,
902
- endpoint,
903
- };
904
- }
905
-
906
- if (action.kind === "server-error-503") {
907
- log(
908
- `[${label}] Server error 503: ${action.errorText.slice(0, 200)}`,
909
- rotator,
910
- "warn",
911
- );
912
- logRequestEnd(503, `endpoint=${action.endpoint}`);
913
- return {
914
- ok: false,
915
- status: 503,
916
- errorText: action.errorText,
917
- endpoint,
918
- };
919
- }
920
-
921
- if (action.kind === "rotate-on-5xx") {
922
- log(
923
- `[${label}] Server error ${action.httpStatus}: ${action.errorText.slice(0, 200)}`,
924
- rotator,
925
- "warn",
926
- );
927
- logRequestEnd(action.httpStatus, `endpoint=${action.endpoint}`);
928
- rotator.markError(
929
- account,
930
- `${action.httpStatus}: ${action.errorText.slice(0, 200)}`,
931
- );
932
- const nextAccount = await rotateAndRelease();
933
- if (!nextAccount) {
934
- return sendNoAccountsAvailable(
935
- `no replacement account remained after ${label} failed with ${action.httpStatus}`,
936
- );
937
- }
938
- continue;
1043
+ if (decision.kind === "retry") continue;
1044
+ return failureDecisionToOutcome(decision);
939
1045
  }
940
1046
 
941
1047
  // success
@@ -1081,6 +1187,69 @@ async function handleProxyRequest(
1081
1187
  }
1082
1188
  return nextAccount;
1083
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
+ };
1084
1253
 
1085
1254
  for (let attempt = 0; attempt < MAX_ENDPOINT_RETRIES; attempt++) {
1086
1255
  const account = await rotator.getActiveAccount(body.model);
@@ -1156,197 +1325,34 @@ async function handleProxyRequest(
1156
1325
  modelKey,
1157
1326
  );
1158
1327
 
1159
- if (action.kind === "rate-limited") {
1160
- proxyLog(
1161
- `[${label}] 429 rate limited${action.providerResourceExhausted ? " (RESOURCE_EXHAUSTED)" : ""}, cooldown ${Math.ceil(action.cooldownMs / 1000)}s. Error text: ${action.errorText.slice(0, 300)}`,
1162
- "warn",
1163
- );
1164
- recordOutcome(429);
1165
- logRequestEnd(
1166
- 429,
1167
- `cooldownMs=${action.cooldownMs}${action.providerResourceExhausted ? " resourceExhausted=true" : ""} endpoint=${endpoint}`,
1168
- );
1169
- rotator.markExhausted(account, body.model, action.cooldownMs);
1170
- rotator.recordProvider429(account, body.model, action.cooldownMs);
1171
-
1172
- // Safety first: do NOT immediately retry another account on 429.
1173
- // Provider-side 429s can represent daily/request buckets or shared project pressure;
1174
- // cascading retries burn the full pool and increase ban/flag risk.
1175
- res.writeHead(429, {
1176
- "Content-Type": "application/json",
1177
- "Retry-After": String(Math.ceil(action.cooldownMs / 1000)),
1178
- });
1179
- res.end(
1180
- JSON.stringify({
1181
- error: action.providerResourceExhausted
1182
- ? "Resource exhausted"
1183
- : "Rate limited",
1184
- reason: action.providerResourceExhausted
1185
- ? `${label} hit provider RESOURCE_EXHAUSTED; not retrying another account to avoid pool-wide hammering`
1186
- : `${label} was rate limited; not retrying another account for account-safety`,
1187
- model: body.model,
1188
- account: label,
1189
- retryAfterMs: action.cooldownMs,
1190
- }),
1191
- );
1192
- return;
1193
- }
1194
-
1195
- if (action.kind === "flagged-401") {
1196
- proxyLog(
1197
- `[${label}] BLOCKED (401): ${action.errorText.slice(0, 200)}`,
1198
- "error",
1199
- );
1200
-
1201
- // Telemetry: report flag event BEFORE markFlagged (which may trigger protective pause)
1202
- const lower401 = action.errorText.toLowerCase();
1203
- const matched401 = FLAG_PATTERNS.filter((p) => lower401.includes(p));
1204
- const ctx401 = rotator.getFlagContext(account, modelKey);
1205
- reportFlagEvent({
1206
- flagHttpStatus: 401,
1207
- flagPatternsMatched:
1208
- matched401.length > 0 ? matched401 : ["blocked_401" as FlagPattern],
1209
- model: modelKey,
1210
- timerType: ctx401.timerType as FlagEventData["timerType"],
1211
- accountQuotaPercent: ctx401.accountQuotaPercent,
1212
- wasProAccount: ctx401.wasProAccount,
1213
- accountTotalRequests: account.totalRequests,
1214
- accountRequestsLastHour: ctx401.accountRequestsLastHour,
1215
- accountConcurrentAtFlag: account.inFlightRequests,
1216
- poolSize: ctx401.poolSize,
1217
- poolHealthyCount: ctx401.poolHealthyCount,
1218
- protectivePauseTriggered: false, // not yet — markFlagged decides
1219
- uptimeSeconds: ctx401.uptimeSeconds,
1220
- timeSinceLastFlagSeconds: -1, // filled by reporter
1221
- });
1222
-
1223
- rotator.markFlagged(
1328
+ if (action.kind !== "success") {
1329
+ const context: RotationAttemptContext = {
1224
1330
  account,
1225
- `Account blocked (401): ${action.errorText.slice(0, 300)}`,
1226
- );
1227
- logRequestEnd(401, `endpoint=${endpoint}`);
1228
- const nextAccount = await rotateAndRelease();
1229
- if (!nextAccount) {
1230
- sendNoAccountsAvailable(
1231
- `no replacement account remained after ${label} was flagged with 401`,
1232
- );
1233
- return;
1234
- }
1235
- continue;
1236
- }
1237
-
1238
- if (action.kind === "flagged-403") {
1239
- proxyLog(
1240
- `[${label}] FLAGGED: ${action.errorText.slice(0, 200)}`,
1241
- "error",
1242
- );
1243
- recordOutcome(403);
1244
- logRequestEnd(403, `endpoint=${endpoint}`);
1245
-
1246
- const matchedPatterns = FLAG_PATTERNS.filter((p) =>
1247
- action.errorText.toLowerCase().includes(p),
1248
- );
1249
- const ctx403 = rotator.getFlagContext(account, modelKey);
1250
- reportFlagEvent({
1251
- flagHttpStatus: 403,
1252
- flagPatternsMatched: matchedPatterns,
1253
- model: modelKey,
1254
- timerType: ctx403.timerType as FlagEventData["timerType"],
1255
- accountQuotaPercent: ctx403.accountQuotaPercent,
1256
- wasProAccount: ctx403.wasProAccount,
1257
- accountTotalRequests: account.totalRequests,
1258
- accountRequestsLastHour: ctx403.accountRequestsLastHour,
1259
- accountConcurrentAtFlag: account.inFlightRequests,
1260
- poolSize: ctx403.poolSize,
1261
- poolHealthyCount: ctx403.poolHealthyCount,
1262
- protectivePauseTriggered: false, // not yet
1263
- uptimeSeconds: ctx403.uptimeSeconds,
1264
- 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,
1265
1350
  });
1266
-
1267
- rotator.markFlagged(account, action.errorText.slice(0, 300));
1268
- const nextAccount = await rotateAndRelease();
1269
- if (!nextAccount) {
1270
- sendNoAccountsAvailable(
1271
- `no replacement account remained after ${label} was flagged with 403`,
1272
- );
1273
- return;
1274
- }
1275
- continue;
1276
- }
1277
-
1278
- if (action.kind === "forbidden") {
1279
- proxyLog(`[${label}] 403: ${action.errorText.slice(0, 200)}`, "warn");
1280
- logRequestEnd(403, `endpoint=${action.endpoint}`);
1281
- res.writeHead(403, { "Content-Type": "application/json" });
1282
- res.end(action.errorText || JSON.stringify({ error: "Forbidden" }));
1283
- return;
1284
- }
1285
-
1286
- if (action.kind === "not-found") {
1287
- proxyLog(
1288
- `[${label}] 404 from ${action.endpoint}: ${action.errorText.slice(0, 200)}`,
1289
- "warn",
1290
- );
1291
- logRequestEnd(404, `endpoint=${action.endpoint}`);
1292
- res.writeHead(404, { "Content-Type": "application/json" });
1293
- res.end(action.errorText || JSON.stringify({ error: "Not found" }));
1294
- return;
1295
- }
1296
-
1297
- if (action.kind === "bad-request") {
1298
- proxyLog(
1299
- `[${label}] 400 Bad Request from ${action.endpoint}: ${action.errorText.slice(0, 500)}`,
1300
- "warn",
1301
- );
1302
- logRequestEnd(400, `endpoint=${action.endpoint}`);
1303
- res.writeHead(400, { "Content-Type": "application/json" });
1304
- res.end(action.errorText || JSON.stringify({ error: "Bad request" }));
1305
- return;
1306
- }
1307
-
1308
- if (action.kind === "server-error-503") {
1309
- proxyLog(
1310
- `[${label}] Server error 503: ${action.errorText.slice(0, 200)}`,
1311
- "warn",
1312
- );
1313
- recordOutcome(503);
1314
- logRequestEnd(503, `endpoint=${action.endpoint}`);
1315
- // Return 503 as-is. Capacity errors still consume quota upstream,
1316
- // so retrying on another account would just burn more quota for nothing.
1317
- res.writeHead(503, { "Content-Type": "application/json" });
1318
- res.end(
1319
- action.errorText ||
1320
- JSON.stringify({
1321
- error: "Server unavailable",
1322
- account: label,
1323
- model: body.model,
1324
- }),
1325
- );
1351
+ if (decision.kind === "retry") continue;
1352
+ sendFailureDecision(decision);
1326
1353
  return;
1327
1354
  }
1328
1355
 
1329
- if (action.kind === "rotate-on-5xx") {
1330
- proxyLog(
1331
- `[${label}] Server error ${action.httpStatus}: ${action.errorText.slice(0, 200)}`,
1332
- "warn",
1333
- );
1334
- recordOutcome(action.httpStatus);
1335
- logRequestEnd(action.httpStatus, `endpoint=${action.endpoint}`);
1336
- rotator.markError(
1337
- account,
1338
- `${action.httpStatus}: ${action.errorText.slice(0, 200)}`,
1339
- );
1340
- const nextAccount = await rotateAndRelease();
1341
- if (!nextAccount) {
1342
- sendNoAccountsAvailable(
1343
- `no replacement account remained after ${label} failed with ${action.httpStatus}`,
1344
- );
1345
- return;
1346
- }
1347
- continue;
1348
- }
1349
-
1350
1356
  // Success or non-error client response
1351
1357
  const shouldRotate = rotator.recordRequest(account, body.model);
1352
1358
 
@@ -1456,7 +1462,7 @@ export function startProxy(
1456
1462
  rotator: AccountRotator,
1457
1463
  port: number,
1458
1464
  bindHost = "0.0.0.0",
1459
- ): void {
1465
+ ): Server {
1460
1466
  startVersionChecker();
1461
1467
  startNotificationPoller();
1462
1468
  const sseClients = new Set<ServerResponse>();
@@ -1541,7 +1547,6 @@ export function startProxy(
1541
1547
  }
1542
1548
 
1543
1549
  if (method === "GET" && pathname === "/auth/antigravity/callback") {
1544
- if (!requireAdmin(req, res)) return;
1545
1550
  handleHostedCallback(req, res, rotator).catch((err) => {
1546
1551
  log(`Hosted callback error: ${err}`, rotator, "error");
1547
1552
  if (!res.headersSent) {
@@ -1624,46 +1629,46 @@ export function startProxy(
1624
1629
  return;
1625
1630
  }
1626
1631
 
1627
- if (method === "POST" && url.startsWith("/api/enable/")) {
1632
+ if (method === "POST" && pathname.startsWith("/api/enable/")) {
1628
1633
  if (!requireAdmin(req, res)) return;
1629
- const email = decodeURIComponent(url.slice("/api/enable/".length));
1634
+ const email = decodeURIComponent(pathname.slice("/api/enable/".length));
1630
1635
  serveEnableApi(res, rotator, email);
1631
1636
  return;
1632
1637
  }
1633
1638
 
1634
- if (method === "POST" && url.startsWith("/api/disable/")) {
1639
+ if (method === "POST" && pathname.startsWith("/api/disable/")) {
1635
1640
  if (!requireAdmin(req, res)) return;
1636
- const email = decodeURIComponent(url.slice("/api/disable/".length));
1641
+ const email = decodeURIComponent(pathname.slice("/api/disable/".length));
1637
1642
  serveDisableApi(res, rotator, email);
1638
1643
  return;
1639
1644
  }
1640
1645
 
1641
- if (method === "POST" && url.startsWith("/api/quarantine/")) {
1646
+ if (method === "POST" && pathname.startsWith("/api/quarantine/")) {
1642
1647
  if (!requireAdmin(req, res)) return;
1643
- const email = decodeURIComponent(url.slice("/api/quarantine/".length));
1648
+ const email = decodeURIComponent(pathname.slice("/api/quarantine/".length));
1644
1649
  serveQuarantineApi(res, rotator, email);
1645
1650
  return;
1646
1651
  }
1647
1652
 
1648
- if (method === "POST" && url.startsWith("/api/restore/")) {
1653
+ if (method === "POST" && pathname.startsWith("/api/restore/")) {
1649
1654
  if (!requireAdmin(req, res)) return;
1650
- const email = decodeURIComponent(url.slice("/api/restore/".length));
1655
+ const email = decodeURIComponent(pathname.slice("/api/restore/".length));
1651
1656
  serveRestoreApi(res, rotator, email);
1652
1657
  return;
1653
1658
  }
1654
1659
 
1655
- if (method === "POST" && url.startsWith("/api/remove-account/")) {
1660
+ if (method === "POST" && pathname.startsWith("/api/remove-account/")) {
1656
1661
  if (!requireAdmin(req, res)) return;
1657
1662
  const email = decodeURIComponent(
1658
- url.slice("/api/remove-account/".length),
1663
+ pathname.slice("/api/remove-account/".length),
1659
1664
  );
1660
1665
  serveRemoveAccountApi(res, rotator, email);
1661
1666
  return;
1662
1667
  }
1663
1668
 
1664
- if (method === "POST" && url.startsWith("/api/set-tier/")) {
1669
+ if (method === "POST" && pathname.startsWith("/api/set-tier/")) {
1665
1670
  if (!requireAdmin(req, res)) return;
1666
- const rest = url.slice("/api/set-tier/".length);
1671
+ const rest = pathname.slice("/api/set-tier/".length);
1667
1672
  const lastSlash = rest.lastIndexOf("/");
1668
1673
  if (lastSlash < 0) {
1669
1674
  res.writeHead(400, { "Content-Type": "application/json" });
@@ -1681,9 +1686,9 @@ export function startProxy(
1681
1686
  return;
1682
1687
  }
1683
1688
 
1684
- if (method === "POST" && url.startsWith("/api/clear-inflight/")) {
1689
+ if (method === "POST" && pathname.startsWith("/api/clear-inflight/")) {
1685
1690
  if (!requireAdmin(req, res)) return;
1686
- const rest = url.slice("/api/clear-inflight/".length);
1691
+ const rest = pathname.slice("/api/clear-inflight/".length);
1687
1692
  const firstSlash = rest.indexOf("/");
1688
1693
  const email = decodeURIComponent(
1689
1694
  firstSlash >= 0 ? rest.slice(0, firstSlash) : rest,
@@ -1696,9 +1701,9 @@ export function startProxy(
1696
1701
  return;
1697
1702
  }
1698
1703
 
1699
- if (method === "POST" && url.startsWith("/api/clear-breaker/")) {
1704
+ if (method === "POST" && pathname.startsWith("/api/clear-breaker/")) {
1700
1705
  if (!requireAdmin(req, res)) return;
1701
- const rest = url.slice("/api/clear-breaker/".length);
1706
+ const rest = pathname.slice("/api/clear-breaker/".length);
1702
1707
  const modelKey =
1703
1708
  rest && rest !== "all" ? decodeURIComponent(rest) : undefined;
1704
1709
  serveClearBreakerApi(res, rotator, modelKey);
@@ -1707,22 +1712,22 @@ export function startProxy(
1707
1712
 
1708
1713
  if (
1709
1714
  method === "POST" &&
1710
- (url === "/api/settings/fresh-window-starts/on" ||
1711
- url === "/api/settings/fresh-window-starts/off")
1715
+ (pathname === "/api/settings/fresh-window-starts/on" ||
1716
+ pathname === "/api/settings/fresh-window-starts/off")
1712
1717
  ) {
1713
1718
  if (!requireAdmin(req, res)) return;
1714
1719
  trackFeature("freshWindowToggle");
1715
- serveFreshWindowStartsApi(res, rotator, url.endsWith("/on"));
1720
+ serveFreshWindowStartsApi(res, rotator, pathname.endsWith("/on"));
1716
1721
  return;
1717
1722
  }
1718
1723
 
1719
1724
  if (
1720
1725
  method === "POST" &&
1721
- url.startsWith("/api/account-fresh-window-starts/") &&
1722
- (url.endsWith("/on") || url.endsWith("/off"))
1726
+ pathname.startsWith("/api/account-fresh-window-starts/") &&
1727
+ (pathname.endsWith("/on") || pathname.endsWith("/off"))
1723
1728
  ) {
1724
1729
  if (!requireAdmin(req, res)) return;
1725
- const rest = url.slice("/api/account-fresh-window-starts/".length);
1730
+ const rest = pathname.slice("/api/account-fresh-window-starts/".length);
1726
1731
  const lastSlash = rest.lastIndexOf("/");
1727
1732
  const email = decodeURIComponent(rest.slice(0, lastSlash));
1728
1733
  const enabled = rest.slice(lastSlash + 1) === "on";
@@ -1730,9 +1735,9 @@ export function startProxy(
1730
1735
  return;
1731
1736
  }
1732
1737
 
1733
- if (method === "POST" && url.startsWith("/api/kickstart/")) {
1738
+ if (method === "POST" && pathname.startsWith("/api/kickstart/")) {
1734
1739
  if (!requireAdmin(req, res)) return;
1735
- const rest = url.slice("/api/kickstart/".length);
1740
+ const rest = pathname.slice("/api/kickstart/".length);
1736
1741
  const firstSlash = rest.indexOf("/");
1737
1742
  if (firstSlash >= 0) {
1738
1743
  // /api/kickstart/:email/:modelKey
@@ -1749,11 +1754,11 @@ export function startProxy(
1749
1754
 
1750
1755
  if (
1751
1756
  method === "POST" &&
1752
- (url === "/api/settings/auto-warmup/on" ||
1753
- url === "/api/settings/auto-warmup/off")
1757
+ (pathname === "/api/settings/auto-warmup/on" ||
1758
+ pathname === "/api/settings/auto-warmup/off")
1754
1759
  ) {
1755
1760
  if (!requireAdmin(req, res)) return;
1756
- serveAutoWarmupApi(res, rotator, url.endsWith("/on"));
1761
+ serveAutoWarmupApi(res, rotator, pathname.endsWith("/on"));
1757
1762
  return;
1758
1763
  }
1759
1764
 
@@ -1898,4 +1903,5 @@ export function startProxy(
1898
1903
  log(`Dashboard: http://localhost:${port}/dashboard`, rotator);
1899
1904
  log(`Hosted login: http://localhost:${port}/login`, rotator);
1900
1905
  });
1906
+ return server;
1901
1907
  }