pi-antigravity-rotator 2.2.2 → 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/src/proxy.ts CHANGED
@@ -1,56 +1,80 @@
1
1
  // HTTP reverse proxy - forwards requests to Antigravity with credential rotation
2
2
 
3
- import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
3
+ import {
4
+ createServer,
5
+ type IncomingMessage,
6
+ type ServerResponse,
7
+ } from "node:http";
4
8
  import { Readable } from "node:stream";
5
9
  import {
6
- ANTIGRAVITY_ENDPOINTS,
7
- REQUEST_CLIENT_METADATA,
8
- REQUEST_GOOG_API_CLIENT,
9
- REQUEST_USER_AGENT,
10
- applyModelAlias,
11
- resolveQuotaModelKey,
12
- resolveDisplayModelKey,
10
+ ANTIGRAVITY_ENDPOINTS,
11
+ REQUEST_CLIENT_METADATA,
12
+ REQUEST_GOOG_API_CLIENT,
13
+ REQUEST_USER_AGENT,
14
+ applyModelAlias,
15
+ resolveQuotaModelKey,
16
+ resolveDisplayModelKey,
13
17
  } from "./types.js";
14
18
  import type { AccountRuntime } from "./types.js";
15
19
  import type { AccountRotator } from "./rotator.js";
16
20
  import {
17
- serveDashboard,
18
- serveStatusApi,
19
- serveConfigApi,
20
- serveConfigExportApi,
21
- serveConfigImportApi,
22
- serveEnableApi,
23
- serveDisableApi,
24
- serveQuarantineApi,
25
- serveRestoreApi,
26
- serveFreshWindowStartsApi,
27
- serveAccountFreshWindowStartsApi,
28
- serveClearInFlightApi,
29
- serveClearBreakerApi,
21
+ serveDashboard,
22
+ serveStatusApi,
23
+ serveConfigApi,
24
+ serveConfigExportApi,
25
+ serveConfigImportApi,
26
+ serveEnableApi,
27
+ serveDisableApi,
28
+ serveQuarantineApi,
29
+ serveRestoreApi,
30
+ serveRemoveAccountApi,
31
+ serveSetTierApi,
32
+ serveFreshWindowStartsApi,
33
+ serveAccountFreshWindowStartsApi,
34
+ serveClearInFlightApi,
35
+ serveClearBreakerApi,
36
+ serveKickstartApi,
37
+ serveAutoWarmupApi,
38
+ serveStaticCss,
39
+ serveStaticJs,
30
40
  } from "./dashboard.js";
31
- import { handleHostedCallback, serveLoginLanding, startHostedLogin } from "./onboarding.js";
41
+ import {
42
+ handleHostedCallback,
43
+ serveLoginLanding,
44
+ startHostedLogin,
45
+ serveCliLogin,
46
+ handleCliLoginApi,
47
+ } from "./onboarding.js";
32
48
  import { requireAdmin } from "./admin-auth.js";
33
49
  import { PayloadTooLargeError, readLimitedBody } from "./body-limit.js";
34
50
  import { validateConfig, validateProxyRequestBody } from "./validators.js";
35
51
  import { logger } from "./logger.js";
36
- import { trackFeature, reportFlagEvent, FLAG_PATTERNS, type FlagPattern } from "./telemetry.js";
52
+ import {
53
+ trackFeature,
54
+ reportFlagEvent,
55
+ FLAG_PATTERNS,
56
+ type FlagPattern,
57
+ } from "./telemetry.js";
37
58
  import type { FlagEventData } from "./telemetry.js";
38
59
  import { startVersionChecker, performSelfUpdate } from "./version-check.js";
39
60
  import { startNotificationPoller } from "./notification-poller.js";
40
61
  import {
41
- handleAnthropicMessages,
42
- handleGeminiGenerateContent,
43
- handleOpenAIChatCompletions,
44
- handleOpenAIResponsesCancel,
45
- handleOpenAIResponsesCreate,
46
- handleOpenAIResponsesDelete,
47
- handleOpenAIResponsesInputItems,
48
- handleOpenAIResponsesRetrieve,
49
- serveGeminiModels,
50
- serveOpenAIModels,
62
+ handleAnthropicMessages,
63
+ handleGeminiGenerateContent,
64
+ handleOpenAIChatCompletions,
65
+ handleOpenAIResponsesCancel,
66
+ handleOpenAIResponsesCreate,
67
+ handleOpenAIResponsesDelete,
68
+ handleOpenAIResponsesInputItems,
69
+ handleOpenAIResponsesRetrieve,
70
+ serveGeminiModels,
71
+ serveOpenAIModels,
51
72
  } from "./compat.js";
52
73
  import { applyConfigDefaults } from "./account-store.js";
53
- import { classifyRateLimitReason, parseRetryAfterMs } from "./rate-limit-parser.js";
74
+ import {
75
+ classifyRateLimitReason,
76
+ parseRetryAfterMs,
77
+ } from "./rate-limit-parser.js";
54
78
 
55
79
  const proxyLogger = logger.child("proxy");
56
80
 
@@ -61,38 +85,44 @@ const STREAM_IDLE_TIMEOUT_MS = 2 * 60 * 1000; // Release account if a stream goe
61
85
  const LARGE_CONTEXT_WARN_BYTES = 1 * 1024 * 1024;
62
86
 
63
87
  function sleep(ms: number): Promise<void> {
64
- return new Promise((resolve) => setTimeout(resolve, ms));
88
+ return new Promise((resolve) => setTimeout(resolve, ms));
65
89
  }
66
90
 
67
91
  export interface RequestBody {
68
- project: string;
69
- model: string;
70
- request: unknown;
71
- requestType?: string;
72
- userAgent?: string;
73
- requestId?: string;
74
- displayModel?: string;
75
- [key: string]: unknown;
92
+ project: string;
93
+ model: string;
94
+ request: unknown;
95
+ requestType?: string;
96
+ userAgent?: string;
97
+ requestId?: string;
98
+ displayModel?: string;
99
+ [key: string]: unknown;
76
100
  }
77
101
 
78
102
  export interface ForwardedResponse {
79
- response: Response;
80
- endpoint: string;
103
+ response: Response;
104
+ endpoint: string;
81
105
  }
82
106
 
83
107
  export interface RotationAttemptContext {
84
- account: AccountRuntime;
85
- label: string;
86
- modelKey: string;
87
- displayModelKey: string;
88
- requestId: string;
89
- requestStartMs: number;
90
- endpoint: string;
108
+ account: AccountRuntime;
109
+ label: string;
110
+ modelKey: string;
111
+ displayModelKey: string;
112
+ requestId: string;
113
+ requestStartMs: number;
114
+ endpoint: string;
91
115
  }
92
116
 
93
117
  export type RotationOutcome<T> =
94
- | { ok: true; result: T; endpoint: string }
95
- | { ok: false; status: number; errorText: string; retryAfterMs?: number; endpoint?: string };
118
+ | { ok: true; result: T; endpoint: string }
119
+ | {
120
+ ok: false;
121
+ status: number;
122
+ errorText: string;
123
+ retryAfterMs?: number;
124
+ endpoint?: string;
125
+ };
96
126
 
97
127
  /**
98
128
  * Discriminated union describing what should happen after inspecting an
@@ -101,15 +131,26 @@ export type RotationOutcome<T> =
101
131
  * keep the status-code branching in one place.
102
132
  */
103
133
  export type UpstreamAction =
104
- | { kind: "rate-limited"; cooldownMs: number; providerResourceExhausted: boolean; errorText: string; endpoint: string }
105
- | { kind: "flagged-401"; errorText: string; endpoint: string }
106
- | { kind: "flagged-403"; errorText: string; endpoint: string }
107
- | { kind: "forbidden"; errorText: string; endpoint: string }
108
- | { kind: "not-found"; errorText: string; endpoint: string }
109
- | { kind: "bad-request"; errorText: string; endpoint: string }
110
- | { kind: "server-error-503"; errorText: string; endpoint: string }
111
- | { kind: "rotate-on-5xx"; httpStatus: number; errorText: string; endpoint: string }
112
- | { kind: "success" };
134
+ | {
135
+ kind: "rate-limited";
136
+ cooldownMs: number;
137
+ providerResourceExhausted: boolean;
138
+ errorText: string;
139
+ endpoint: string;
140
+ }
141
+ | { kind: "flagged-401"; errorText: string; endpoint: string }
142
+ | { kind: "flagged-403"; errorText: string; endpoint: string }
143
+ | { kind: "forbidden"; errorText: string; endpoint: string }
144
+ | { kind: "not-found"; errorText: string; endpoint: string }
145
+ | { kind: "bad-request"; errorText: string; endpoint: string }
146
+ | { kind: "server-error-503"; errorText: string; endpoint: string }
147
+ | {
148
+ kind: "rotate-on-5xx";
149
+ httpStatus: number;
150
+ errorText: string;
151
+ endpoint: string;
152
+ }
153
+ | { kind: "success" };
113
154
 
114
155
  /**
115
156
  * Inspect an upstream response and return a tag describing what to do next.
@@ -117,82 +158,105 @@ export type UpstreamAction =
117
158
  * classification logic lives in one place.
118
159
  */
119
160
  export async function classifyUpstreamResponse(
120
- response: Response,
121
- endpoint: string,
122
- account: AccountRuntime,
123
- model: string,
124
- modelKey: string,
161
+ response: Response,
162
+ endpoint: string,
163
+ account: AccountRuntime,
164
+ model: string,
165
+ modelKey: string,
125
166
  ): Promise<UpstreamAction> {
126
- if (response.status === 429) {
127
- const errorText = await response.text().catch(() => "");
128
- const rateLimitReason = classifyRateLimitReason(errorText, response.status);
129
- const providerResourceExhausted = rateLimitReason === "quota-exhausted";
130
- const cooldownMs = providerResourceExhausted
131
- ? RESOURCE_EXHAUSTED_COOLDOWN_MS
132
- : capCooldown(parseRetryAfterMs(errorText, response.headers));
133
- return { kind: "rate-limited", cooldownMs, providerResourceExhausted, errorText, endpoint };
134
- }
135
-
136
- if (response.status === 401) {
137
- const errorText = await response.text().catch(() => "");
138
- return { kind: "flagged-401", errorText, endpoint };
139
- }
140
-
141
- if (response.status === 403) {
142
- const errorText = await response.text().catch(() => "");
143
- const lower = errorText.toLowerCase();
144
- const flagPatternsLocal = ["infring", "suspend", "abus", "terminat", "violat", "banned", "policy", "forbidden", "verif"];
145
- const isFlagged = flagPatternsLocal.some((p) => lower.includes(p));
146
- if (isFlagged) {
147
- return { kind: "flagged-403", errorText, endpoint };
148
- }
149
- return { kind: "forbidden", errorText, endpoint };
150
- }
151
-
152
- if (response.status === 404) {
153
- const errorText = await response.text().catch(() => "");
154
- return { kind: "not-found", errorText, endpoint };
155
- }
156
-
157
- if (response.status === 400) {
158
- const errorText = await response.text().catch(() => "");
159
- return { kind: "bad-request", errorText, endpoint };
160
- }
161
-
162
- if (response.status === 503) {
163
- const errorText = await response.text().catch(() => "");
164
- return { kind: "server-error-503", errorText, endpoint };
165
- }
166
-
167
- if (response.status >= 500) {
168
- const errorText = await response.text().catch(() => "");
169
- return { kind: "rotate-on-5xx", httpStatus: response.status, errorText, endpoint };
170
- }
171
-
172
- // Reference parameters to satisfy "all parameters are used" without changing behavior.
173
- void account; void model; void modelKey;
174
- return { kind: "success" };
167
+ if (response.status === 429) {
168
+ const errorText = await response.text().catch(() => "");
169
+ const rateLimitReason = classifyRateLimitReason(errorText, response.status);
170
+ const providerResourceExhausted = rateLimitReason === "quota-exhausted";
171
+ const cooldownMs = providerResourceExhausted
172
+ ? RESOURCE_EXHAUSTED_COOLDOWN_MS
173
+ : capCooldown(parseRetryAfterMs(errorText, response.headers));
174
+ return {
175
+ kind: "rate-limited",
176
+ cooldownMs,
177
+ providerResourceExhausted,
178
+ errorText,
179
+ endpoint,
180
+ };
181
+ }
182
+
183
+ if (response.status === 401) {
184
+ const errorText = await response.text().catch(() => "");
185
+ return { kind: "flagged-401", errorText, endpoint };
186
+ }
187
+
188
+ if (response.status === 403) {
189
+ const errorText = await response.text().catch(() => "");
190
+ const lower = errorText.toLowerCase();
191
+ const flagPatternsLocal = [
192
+ "infring",
193
+ "suspend",
194
+ "abus",
195
+ "terminat",
196
+ "violat",
197
+ "banned",
198
+ "policy",
199
+ "forbidden",
200
+ "verif",
201
+ ];
202
+ const isFlagged = flagPatternsLocal.some((p) => lower.includes(p));
203
+ if (isFlagged) {
204
+ return { kind: "flagged-403", errorText, endpoint };
205
+ }
206
+ return { kind: "forbidden", errorText, endpoint };
207
+ }
208
+
209
+ if (response.status === 404) {
210
+ const errorText = await response.text().catch(() => "");
211
+ return { kind: "not-found", errorText, endpoint };
212
+ }
213
+
214
+ if (response.status === 400) {
215
+ const errorText = await response.text().catch(() => "");
216
+ return { kind: "bad-request", errorText, endpoint };
217
+ }
218
+
219
+ if (response.status === 503) {
220
+ const errorText = await response.text().catch(() => "");
221
+ return { kind: "server-error-503", errorText, endpoint };
222
+ }
223
+
224
+ if (response.status >= 500) {
225
+ const errorText = await response.text().catch(() => "");
226
+ return {
227
+ kind: "rotate-on-5xx",
228
+ httpStatus: response.status,
229
+ errorText,
230
+ endpoint,
231
+ };
232
+ }
233
+
234
+ // Reference parameters to satisfy "all parameters are used" without changing behavior.
235
+ void account;
236
+ void model;
237
+ void modelKey;
238
+ return { kind: "success" };
175
239
  }
176
240
 
177
241
  function capCooldown(ms: number): number {
178
- return Math.min(ms, MAX_COOLDOWN_MS);
242
+ return Math.min(ms, MAX_COOLDOWN_MS);
179
243
  }
180
244
 
181
245
  function formatError(err: unknown): string {
182
- if (!(err instanceof Error)) return String(err);
183
- const cause = err.cause;
184
- if (cause && typeof cause === "object") {
185
- const code = "code" in cause ? String(cause.code) : null;
186
- const message = "message" in cause ? String(cause.message) : null;
187
- if (code || message) {
188
- return `${err.name}: ${err.message} (${[code, message].filter(Boolean).join(": ")})`;
189
- }
190
- }
191
- return `${err.name}: ${err.message}`;
246
+ if (!(err instanceof Error)) return String(err);
247
+ const cause = err.cause;
248
+ if (cause && typeof cause === "object") {
249
+ const code = "code" in cause ? String(cause.code) : null;
250
+ const message = "message" in cause ? String(cause.message) : null;
251
+ if (code || message) {
252
+ return `${err.name}: ${err.message} (${[code, message].filter(Boolean).join(": ")})`;
253
+ }
254
+ }
255
+ return `${err.name}: ${err.message}`;
192
256
  }
193
257
 
194
258
  function isFetchTransportError(err: unknown): boolean {
195
- return err instanceof TypeError && err.message === "fetch failed";
259
+ return err instanceof TypeError && err.message === "fetch failed";
196
260
  }
197
261
 
198
262
  /** Max bytes kept in the SSE event buffer. A single event is rarely >1MB;
@@ -200,1192 +264,1638 @@ function isFetchTransportError(err: unknown): boolean {
200
264
  const SSE_EVENT_BUFFER_MAX = 1024 * 1024;
201
265
 
202
266
  function isRecord(value: unknown): value is Record<string, unknown> {
203
- return typeof value === "object" && value !== null && !Array.isArray(value);
267
+ return typeof value === "object" && value !== null && !Array.isArray(value);
204
268
  }
205
269
 
206
270
  /** Recursively search a parsed JSON value for the first usage block we recognise.
207
271
  * Supports Gemini (usageMetadata), OpenAI (usage with prompt_tokens/completion_tokens),
208
272
  * and Anthropic (usage with input_tokens/output_tokens). */
209
- function findUsageInJson(value: unknown): { inputTokens: number; outputTokens: number } | null {
210
- if (!isRecord(value)) return null;
211
- // Gemini format
212
- const gemini = value.usageMetadata;
213
- if (isRecord(gemini)) {
214
- const input = typeof gemini.promptTokenCount === "number" ? gemini.promptTokenCount : 0;
215
- const output = typeof gemini.candidatesTokenCount === "number" ? gemini.candidatesTokenCount : 0;
216
- if (input > 0 || output > 0) return { inputTokens: input, outputTokens: output };
217
- }
218
- // OpenAI / Anthropic format
219
- const usage = value.usage;
220
- if (isRecord(usage)) {
221
- const input = typeof usage.prompt_tokens === "number" ? usage.prompt_tokens
222
- : typeof usage.input_tokens === "number" ? usage.input_tokens
223
- : 0;
224
- const output = typeof usage.completion_tokens === "number" ? usage.completion_tokens
225
- : typeof usage.output_tokens === "number" ? usage.output_tokens
226
- : 0;
227
- if (input > 0 || output > 0) return { inputTokens: input, outputTokens: output };
228
- }
229
- // Recurse into common nesting locations.
230
- for (const key of ["candidates", "output", "response", "message"]) {
231
- const child = value[key];
232
- if (Array.isArray(child)) {
233
- for (const item of child) {
234
- const found = findUsageInJson(item);
235
- if (found) return found;
236
- }
237
- } else if (isRecord(child)) {
238
- const found = findUsageInJson(child);
239
- if (found) return found;
240
- }
241
- }
242
- return null;
273
+ function findUsageInJson(
274
+ value: unknown,
275
+ ): { inputTokens: number; outputTokens: number } | null {
276
+ if (!isRecord(value)) return null;
277
+ // Gemini format
278
+ const gemini = value.usageMetadata;
279
+ if (isRecord(gemini)) {
280
+ const input =
281
+ typeof gemini.promptTokenCount === "number" ? gemini.promptTokenCount : 0;
282
+ const output =
283
+ typeof gemini.candidatesTokenCount === "number"
284
+ ? gemini.candidatesTokenCount
285
+ : 0;
286
+ if (input > 0 || output > 0)
287
+ return { inputTokens: input, outputTokens: output };
288
+ }
289
+ // OpenAI / Anthropic format
290
+ const usage = value.usage;
291
+ if (isRecord(usage)) {
292
+ const input =
293
+ typeof usage.prompt_tokens === "number"
294
+ ? usage.prompt_tokens
295
+ : typeof usage.input_tokens === "number"
296
+ ? usage.input_tokens
297
+ : 0;
298
+ const output =
299
+ typeof usage.completion_tokens === "number"
300
+ ? usage.completion_tokens
301
+ : typeof usage.output_tokens === "number"
302
+ ? usage.output_tokens
303
+ : 0;
304
+ if (input > 0 || output > 0)
305
+ return { inputTokens: input, outputTokens: output };
306
+ }
307
+ // Recurse into common nesting locations.
308
+ for (const key of ["candidates", "output", "response", "message"]) {
309
+ const child = value[key];
310
+ if (Array.isArray(child)) {
311
+ for (const item of child) {
312
+ const found = findUsageInJson(item);
313
+ if (found) return found;
314
+ }
315
+ } else if (isRecord(child)) {
316
+ const found = findUsageInJson(child);
317
+ if (found) return found;
318
+ }
319
+ }
320
+ return null;
243
321
  }
244
322
 
245
323
  /** Extract usage from a single complete SSE event (one or more `data:` lines
246
324
  * separated by newlines, terminated by a blank line). The last successful
247
325
  * extraction wins (callers should stop scanning once they find usage). */
248
- export function extractUsageFromSseEvent(eventText: string): { inputTokens: number; outputTokens: number } | null {
249
- const dataLines: string[] = [];
250
- for (const raw of eventText.split("\n")) {
251
- if (raw.startsWith("data:")) {
252
- dataLines.push(raw.slice(5).trim());
253
- }
254
- }
255
- if (dataLines.length === 0) return null;
256
- const payload = dataLines.join("\n");
257
- if (payload === "[DONE]" || payload === "") return null;
258
- let parsed: unknown;
259
- try {
260
- parsed = JSON.parse(payload);
261
- } catch {
262
- // Fall back to regex on the raw event text. This handles non-standard
263
- // streams that don't quite produce valid JSON per event.
264
- const fallback = regexExtractUsage(payload);
265
- return fallback;
266
- }
267
- return findUsageInJson(parsed);
326
+ export function extractUsageFromSseEvent(
327
+ eventText: string,
328
+ ): { inputTokens: number; outputTokens: number } | null {
329
+ const dataLines: string[] = [];
330
+ for (const raw of eventText.split("\n")) {
331
+ if (raw.startsWith("data:")) {
332
+ dataLines.push(raw.slice(5).trim());
333
+ }
334
+ }
335
+ if (dataLines.length === 0) return null;
336
+ const payload = dataLines.join("\n");
337
+ if (payload === "[DONE]" || payload === "") return null;
338
+ let parsed: unknown;
339
+ try {
340
+ parsed = JSON.parse(payload);
341
+ } catch {
342
+ // Fall back to regex on the raw event text. This handles non-standard
343
+ // streams that don't quite produce valid JSON per event.
344
+ const fallback = regexExtractUsage(payload);
345
+ return fallback;
346
+ }
347
+ return findUsageInJson(parsed);
268
348
  }
269
349
 
270
350
  /** Last-resort regex extraction for streams that don't yield parseable JSON. */
271
- function regexExtractUsage(buffer: string): { inputTokens: number; outputTokens: number } | null {
272
- try {
273
- const patterns = [
274
- /"promptTokenCount"\s*:\s*(\d+).*?"candidatesTokenCount"\s*:\s*(\d+)/s,
275
- /"input_tokens"\s*:\s*(\d+).*?"output_tokens"\s*:\s*(\d+)/s,
276
- ];
277
- for (const pattern of patterns) {
278
- const match = buffer.match(pattern);
279
- if (match) {
280
- return {
281
- inputTokens: parseInt(match[1], 10),
282
- outputTokens: parseInt(match[2], 10),
283
- };
284
- }
285
- }
286
- } catch { /* extraction failed */ }
287
- return null;
351
+ function regexExtractUsage(
352
+ buffer: string,
353
+ ): { inputTokens: number; outputTokens: number } | null {
354
+ try {
355
+ const patterns = [
356
+ /"promptTokenCount"\s*:\s*(\d+).*?"candidatesTokenCount"\s*:\s*(\d+)/s,
357
+ /"input_tokens"\s*:\s*(\d+).*?"output_tokens"\s*:\s*(\d+)/s,
358
+ ];
359
+ for (const pattern of patterns) {
360
+ const match = buffer.match(pattern);
361
+ if (match) {
362
+ return {
363
+ inputTokens: parseInt(match[1], 10),
364
+ outputTokens: parseInt(match[2], 10),
365
+ };
366
+ }
367
+ }
368
+ } catch {
369
+ /* extraction failed */
370
+ }
371
+ return null;
288
372
  }
289
373
 
290
374
  /** State for the SSE event accumulator used by streamResponseBody. */
291
375
  class SseEventAccumulator {
292
- private buffer = "";
293
- private readonly maxBytes: number;
294
- constructor(maxBytes: number = SSE_EVENT_BUFFER_MAX) {
295
- this.maxBytes = maxBytes;
296
- }
297
-
298
- /** Append a chunk, return any usage extracted from newly-completed events. */
299
- append(chunkText: string): { inputTokens: number; outputTokens: number } | null {
300
- this.buffer += chunkText;
301
- if (this.buffer.length > this.maxBytes) {
302
- this.buffer = this.buffer.slice(-this.maxBytes);
303
- }
304
- let extracted: { inputTokens: number; outputTokens: number } | null = null;
305
- let boundary = this.buffer.indexOf("\n\n");
306
- while (boundary !== -1) {
307
- const eventText = this.buffer.slice(0, boundary);
308
- this.buffer = this.buffer.slice(boundary + 2);
309
- const usage = extractUsageFromSseEvent(eventText);
310
- if (usage && !extracted) extracted = usage;
311
- boundary = this.buffer.indexOf("\n\n");
312
- }
313
- return extracted;
314
- }
315
-
316
- /** Flush any partial event at end-of-stream. */
317
- final(): { inputTokens: number; outputTokens: number } | null {
318
- if (!this.buffer) return null;
319
- const usage = extractUsageFromSseEvent(this.buffer);
320
- this.buffer = "";
321
- return usage;
322
- }
376
+ private buffer = "";
377
+ private readonly maxBytes: number;
378
+ constructor(maxBytes: number = SSE_EVENT_BUFFER_MAX) {
379
+ this.maxBytes = maxBytes;
380
+ }
381
+
382
+ /** Append a chunk, return any usage extracted from newly-completed events. */
383
+ append(
384
+ chunkText: string,
385
+ ): { inputTokens: number; outputTokens: number } | null {
386
+ this.buffer += chunkText;
387
+ if (this.buffer.length > this.maxBytes) {
388
+ this.buffer = this.buffer.slice(-this.maxBytes);
389
+ }
390
+ let extracted: { inputTokens: number; outputTokens: number } | null = null;
391
+ let boundary = this.buffer.indexOf("\n\n");
392
+ while (boundary !== -1) {
393
+ const eventText = this.buffer.slice(0, boundary);
394
+ this.buffer = this.buffer.slice(boundary + 2);
395
+ const usage = extractUsageFromSseEvent(eventText);
396
+ if (usage && !extracted) extracted = usage;
397
+ boundary = this.buffer.indexOf("\n\n");
398
+ }
399
+ return extracted;
400
+ }
401
+
402
+ /** Flush any partial event at end-of-stream. */
403
+ final(): { inputTokens: number; outputTokens: number } | null {
404
+ if (!this.buffer) return null;
405
+ const usage = extractUsageFromSseEvent(this.buffer);
406
+ this.buffer = "";
407
+ return usage;
408
+ }
323
409
  }
324
410
 
325
411
  async function readJsonRequest(req: IncomingMessage): Promise<unknown> {
326
- const body = await readLimitedBody(req);
327
- return body.length === 0 ? {} : JSON.parse(body.toString("utf-8"));
412
+ const body = await readLimitedBody(req);
413
+ return body.length === 0 ? {} : JSON.parse(body.toString("utf-8"));
328
414
  }
329
415
 
330
416
  async function streamResponseBody(
331
- body: Response["body"],
332
- req: IncomingMessage,
333
- res: ServerResponse,
334
- label: string,
335
- proxyLog: (msg: string, level?: "info" | "warn" | "error") => void,
336
- ): Promise<{ inputTokens: number; outputTokens: number; firstByteMs: number } | null> {
337
- if (!body) return null;
338
-
339
- const nodeStream = Readable.fromWeb(body as import("node:stream/web").ReadableStream);
340
- const eventAccumulator = new SseEventAccumulator();
341
- let firstUsage: { inputTokens: number; outputTokens: number } | null = null;
342
- const streamStartMs = Date.now();
343
- let firstByteMs = 0;
344
-
345
- const usage = await new Promise<{ inputTokens: number; outputTokens: number; firstByteMs: number } | null>((resolve) => {
346
- let settled = false;
347
- let idleTimer: ReturnType<typeof setTimeout> | null = null;
348
-
349
- const cleanup = (): void => {
350
- if (idleTimer) clearTimeout(idleTimer);
351
- nodeStream.off("data", onData);
352
- nodeStream.off("end", onEnd);
353
- nodeStream.off("error", onError);
354
- nodeStream.off("close", onClose);
355
- req.off("close", onClientClose);
356
- res.off("close", onResponseClose);
357
- res.off("error", onResponseError);
358
- };
359
-
360
- const finish = (reason?: string): void => {
361
- if (settled) return;
362
- settled = true;
363
- if (reason) proxyLog(`[${label}] Stream closed: ${reason}`, "warn");
364
- cleanup();
365
- // Drain any partial event that didn't end with \n\n
366
- if (!firstUsage) firstUsage = eventAccumulator.final();
367
- resolve(firstUsage ? { ...firstUsage, firstByteMs } : null);
368
- };
369
-
370
- const resetIdleTimer = (): void => {
371
- if (idleTimer) clearTimeout(idleTimer);
372
- idleTimer = setTimeout(() => {
373
- finish(`idle timeout after ${Math.round(STREAM_IDLE_TIMEOUT_MS / 1000)}s`);
374
- if (!nodeStream.destroyed) nodeStream.destroy();
375
- }, STREAM_IDLE_TIMEOUT_MS);
376
- };
377
-
378
- const onData = (chunk: Buffer): void => {
379
- if (firstByteMs === 0) firstByteMs = Date.now() - streamStartMs;
380
- resetIdleTimer();
381
- // Forward to client immediately (real-time streaming preserved)
382
- if (!res.destroyed && !res.writableEnded) {
383
- res.write(chunk);
384
- }
385
- // Extract usage from any newly-completed SSE events
386
- if (!firstUsage) {
387
- const usage = eventAccumulator.append(chunk.toString());
388
- if (usage) firstUsage = usage;
389
- }
390
- };
391
- const onEnd = (): void => finish();
392
- const onError = (err: Error): void => finish(String(err));
393
- const onClose = (): void => finish();
394
- // req.aborted is deprecated and unreliable since Node 18+.
395
- // req.on("close") is the correct signal for client disconnect in Node 22.
396
- const onClientClose = (): void => {
397
- // Always destroy when the client disconnects — regardless of writableEnded.
398
- // The upstream stream from Google may still be open even if res finished writing,
399
- // which would leave the account stuck in-flight until the idle timeout.
400
- if (!settled) {
401
- nodeStream.destroy();
402
- finish("client closed connection");
403
- }
404
- };
405
- const onResponseClose = (): void => {
406
- if (!settled) {
407
- nodeStream.destroy();
408
- finish("response closed before completion");
409
- }
410
- };
411
- const onResponseError = (err: Error): void => {
412
- nodeStream.destroy(err);
413
- finish(String(err));
414
- };
415
-
416
- nodeStream.on("data", onData);
417
- nodeStream.once("end", onEnd);
418
- nodeStream.once("error", onError);
419
- nodeStream.once("close", onClose);
420
- req.once("close", onClientClose);
421
- res.once("close", onResponseClose);
422
- res.once("error", onResponseError);
423
- resetIdleTimer();
424
- });
425
-
426
- return usage;
417
+ body: Response["body"],
418
+ req: IncomingMessage,
419
+ res: ServerResponse,
420
+ label: string,
421
+ proxyLog: (msg: string, level?: "info" | "warn" | "error") => void,
422
+ ): Promise<{
423
+ inputTokens: number;
424
+ outputTokens: number;
425
+ firstByteMs: number;
426
+ } | null> {
427
+ if (!body) return null;
428
+
429
+ const nodeStream = Readable.fromWeb(
430
+ body as import("node:stream/web").ReadableStream,
431
+ );
432
+ const eventAccumulator = new SseEventAccumulator();
433
+ let firstUsage: { inputTokens: number; outputTokens: number } | null = null;
434
+ const streamStartMs = Date.now();
435
+ let firstByteMs = 0;
436
+
437
+ const usage = await new Promise<{
438
+ inputTokens: number;
439
+ outputTokens: number;
440
+ firstByteMs: number;
441
+ } | null>((resolve) => {
442
+ let settled = false;
443
+ let idleTimer: ReturnType<typeof setTimeout> | null = null;
444
+
445
+ const cleanup = (): void => {
446
+ if (idleTimer) clearTimeout(idleTimer);
447
+ nodeStream.off("data", onData);
448
+ nodeStream.off("end", onEnd);
449
+ nodeStream.off("error", onError);
450
+ nodeStream.off("close", onClose);
451
+ req.off("close", onClientClose);
452
+ res.off("close", onResponseClose);
453
+ res.off("error", onResponseError);
454
+ };
455
+
456
+ const finish = (reason?: string): void => {
457
+ if (settled) return;
458
+ settled = true;
459
+ if (reason) proxyLog(`[${label}] Stream closed: ${reason}`, "warn");
460
+ cleanup();
461
+ // Drain any partial event that didn't end with \n\n
462
+ if (!firstUsage) firstUsage = eventAccumulator.final();
463
+ resolve(firstUsage ? { ...firstUsage, firstByteMs } : null);
464
+ };
465
+
466
+ const resetIdleTimer = (): void => {
467
+ if (idleTimer) clearTimeout(idleTimer);
468
+ idleTimer = setTimeout(() => {
469
+ finish(
470
+ `idle timeout after ${Math.round(STREAM_IDLE_TIMEOUT_MS / 1000)}s`,
471
+ );
472
+ if (!nodeStream.destroyed) nodeStream.destroy();
473
+ }, STREAM_IDLE_TIMEOUT_MS);
474
+ };
475
+
476
+ const onData = (chunk: Buffer): void => {
477
+ if (firstByteMs === 0) firstByteMs = Date.now() - streamStartMs;
478
+ resetIdleTimer();
479
+ // Forward to client immediately (real-time streaming preserved)
480
+ if (!res.destroyed && !res.writableEnded) {
481
+ res.write(chunk);
482
+ }
483
+ // Extract usage from any newly-completed SSE events
484
+ if (!firstUsage) {
485
+ const usage = eventAccumulator.append(chunk.toString());
486
+ if (usage) firstUsage = usage;
487
+ }
488
+ };
489
+ const onEnd = (): void => finish();
490
+ const onError = (err: Error): void => finish(String(err));
491
+ const onClose = (): void => finish();
492
+ // req.aborted is deprecated and unreliable since Node 18+.
493
+ // req.on("close") is the correct signal for client disconnect in Node 22.
494
+ const onClientClose = (): void => {
495
+ // Always destroy when the client disconnects — regardless of writableEnded.
496
+ // The upstream stream from Google may still be open even if res finished writing,
497
+ // which would leave the account stuck in-flight until the idle timeout.
498
+ if (!settled) {
499
+ nodeStream.destroy();
500
+ finish("client closed connection");
501
+ }
502
+ };
503
+ const onResponseClose = (): void => {
504
+ if (!settled) {
505
+ nodeStream.destroy();
506
+ finish("response closed before completion");
507
+ }
508
+ };
509
+ const onResponseError = (err: Error): void => {
510
+ nodeStream.destroy(err);
511
+ finish(String(err));
512
+ };
513
+
514
+ nodeStream.on("data", onData);
515
+ nodeStream.once("end", onEnd);
516
+ nodeStream.once("error", onError);
517
+ nodeStream.once("close", onClose);
518
+ req.once("close", onClientClose);
519
+ res.once("close", onResponseClose);
520
+ res.once("error", onResponseError);
521
+ resetIdleTimer();
522
+ });
523
+
524
+ return usage;
427
525
  }
428
526
 
429
527
  /**
430
528
  * Forward a request to the real Antigravity endpoint with credential swapping.
431
529
  */
432
530
  export async function forwardRequest(
433
- account: AccountRuntime,
434
- body: RequestBody,
435
- originalHeaders: Record<string, string>,
531
+ account: AccountRuntime,
532
+ body: RequestBody,
533
+ originalHeaders: Record<string, string>,
436
534
  ): Promise<ForwardedResponse> {
437
- // Swap credentials
438
- body.project = account.config.projectId;
439
-
440
- // Map internal display/compat names to Google upstream names (single source
441
- // of truth: src/types.ts:applyModelAlias)
442
- body.model = applyModelAlias(body.model);
443
-
444
- const { displayModel, ...bodyToForward } = body;
445
- const requestBody = JSON.stringify(bodyToForward);
446
-
447
- // Build headers: keep originals but swap Authorization
448
- const forwardHeaders: Record<string, string> = {
449
- ...originalHeaders,
450
- "Content-Type": "application/json",
451
- Accept: "text/event-stream",
452
- };
453
- // Remove original authorization (any case), provider-set headers, and
454
- // hop-by-hop headers per RFC 7230 §6.1. The hop-by-hop list prevents
455
- // leaking client IP (X-Forwarded-For) and prevents IP spoofing in
456
- // upstream logs (Via).
457
- const HOP_BY_HOP = new Set([
458
- "connection",
459
- "keep-alive",
460
- "proxy-authenticate",
461
- "proxy-authorization",
462
- "te",
463
- "trailers",
464
- "transfer-encoding",
465
- "upgrade",
466
- // Forwarding / proxying artefacts that should never reach the upstream
467
- "x-forwarded-for",
468
- "x-forwarded-host",
469
- "x-forwarded-proto",
470
- "x-forwarded-port",
471
- "x-real-ip",
472
- "forwarded",
473
- "via",
474
- ]);
475
- for (const key of Object.keys(forwardHeaders)) {
476
- const lowerKey = key.toLowerCase();
477
- if (
478
- HOP_BY_HOP.has(lowerKey) ||
479
- lowerKey === "authorization" ||
480
- lowerKey === "user-agent" ||
481
- lowerKey === "x-goog-api-client" ||
482
- lowerKey === "client-metadata"
483
- ) {
484
- delete forwardHeaders[key];
485
- }
486
- }
487
- forwardHeaders["Authorization"] = `Bearer ${account.accessToken}`;
488
- forwardHeaders["User-Agent"] = REQUEST_USER_AGENT;
489
- forwardHeaders["X-Goog-Api-Client"] = REQUEST_GOOG_API_CLIENT;
490
- forwardHeaders["Client-Metadata"] = REQUEST_CLIENT_METADATA;
491
- // Claude models on Cloud Code Assist (Antigravity) require this beta header to
492
- // return interleaved thinking blocks. Mirrors pi-mono's needsClaudeThinkingBetaHeader.
493
- if (/^claude-/i.test(body.model)) {
494
- forwardHeaders["anthropic-beta"] = "interleaved-thinking-2025-05-14";
495
- }
496
- delete forwardHeaders["host"];
497
- delete forwardHeaders["connection"];
498
- delete forwardHeaders["transfer-encoding"];
499
- delete forwardHeaders["content-length"];
500
-
501
- // Try endpoints with cascade on 401/403/404
502
- for (let endpointIdx = 0; endpointIdx < ANTIGRAVITY_ENDPOINTS.length; endpointIdx++) {
503
- const endpoint = ANTIGRAVITY_ENDPOINTS[endpointIdx];
504
- const url = `${endpoint}/v1internal:streamGenerateContent?alt=sse`;
505
- const isProd = endpointIdx === ANTIGRAVITY_ENDPOINTS.length - 1;
506
-
507
- try {
508
- const controller = !isProd ? new AbortController() : undefined;
509
- const timeout = controller ? setTimeout(() => controller.abort(), 10_000) : undefined;
510
-
511
- const response = await fetch(url, {
512
- method: "POST",
513
- headers: forwardHeaders,
514
- body: requestBody,
515
- signal: controller?.signal,
516
- });
517
- if (timeout) clearTimeout(timeout);
518
-
519
- if ((response.status === 401 || response.status === 403 || response.status === 404) && endpointIdx < ANTIGRAVITY_ENDPOINTS.length - 1) {
520
- log(`Endpoint ${endpoint} returned ${response.status}, cascading...`);
521
- response.text().catch(() => { });
522
- continue;
523
- }
524
-
525
- return { response, endpoint };
526
- } catch (err) {
527
- if (endpointIdx < ANTIGRAVITY_ENDPOINTS.length - 1) {
528
- log(`Endpoint ${endpoint} failed: ${err instanceof Error ? err.message : err}, cascading...`);
529
- continue;
530
- }
531
- throw err;
532
- }
533
- }
534
-
535
- throw new Error("All endpoints failed");
535
+ // Swap credentials
536
+ body.project = account.config.projectId;
537
+
538
+ // Map internal display/compat names to Google upstream names (single source
539
+ // of truth: src/types.ts:applyModelAlias)
540
+ body.model = applyModelAlias(body.model);
541
+
542
+ const { displayModel: _displayModel, ...bodyToForward } = body;
543
+ const requestBody = JSON.stringify(bodyToForward);
544
+
545
+ // Build headers: keep originals but swap Authorization
546
+ const forwardHeaders: Record<string, string> = {
547
+ ...originalHeaders,
548
+ "Content-Type": "application/json",
549
+ Accept: "text/event-stream",
550
+ };
551
+ // Remove original authorization (any case), provider-set headers, and
552
+ // hop-by-hop headers per RFC 7230 §6.1. The hop-by-hop list prevents
553
+ // leaking client IP (X-Forwarded-For) and prevents IP spoofing in
554
+ // upstream logs (Via).
555
+ const HOP_BY_HOP = new Set([
556
+ "connection",
557
+ "keep-alive",
558
+ "proxy-authenticate",
559
+ "proxy-authorization",
560
+ "te",
561
+ "trailers",
562
+ "transfer-encoding",
563
+ "upgrade",
564
+ // Forwarding / proxying artefacts that should never reach the upstream
565
+ "x-forwarded-for",
566
+ "x-forwarded-host",
567
+ "x-forwarded-proto",
568
+ "x-forwarded-port",
569
+ "x-real-ip",
570
+ "forwarded",
571
+ "via",
572
+ ]);
573
+ for (const key of Object.keys(forwardHeaders)) {
574
+ const lowerKey = key.toLowerCase();
575
+ if (
576
+ HOP_BY_HOP.has(lowerKey) ||
577
+ lowerKey === "authorization" ||
578
+ lowerKey === "user-agent" ||
579
+ lowerKey === "x-goog-api-client" ||
580
+ lowerKey === "client-metadata"
581
+ ) {
582
+ delete forwardHeaders[key];
583
+ }
584
+ }
585
+ forwardHeaders["Authorization"] = `Bearer ${account.accessToken}`;
586
+ forwardHeaders["User-Agent"] = REQUEST_USER_AGENT;
587
+ forwardHeaders["X-Goog-Api-Client"] = REQUEST_GOOG_API_CLIENT;
588
+ forwardHeaders["Client-Metadata"] = REQUEST_CLIENT_METADATA;
589
+ // Claude models on Cloud Code Assist (Antigravity) require this beta header to
590
+ // return interleaved thinking blocks. Mirrors pi-mono's needsClaudeThinkingBetaHeader.
591
+ if (/^claude-/i.test(body.model)) {
592
+ forwardHeaders["anthropic-beta"] = "interleaved-thinking-2025-05-14";
593
+ }
594
+ delete forwardHeaders["host"];
595
+ delete forwardHeaders["connection"];
596
+ delete forwardHeaders["transfer-encoding"];
597
+ delete forwardHeaders["content-length"];
598
+
599
+ // Try endpoints with cascade on 401/403/404
600
+ for (
601
+ let endpointIdx = 0;
602
+ endpointIdx < ANTIGRAVITY_ENDPOINTS.length;
603
+ endpointIdx++
604
+ ) {
605
+ const endpoint = ANTIGRAVITY_ENDPOINTS[endpointIdx];
606
+ const url = `${endpoint}/v1internal:streamGenerateContent?alt=sse`;
607
+ const isProd = endpointIdx === ANTIGRAVITY_ENDPOINTS.length - 1;
608
+
609
+ try {
610
+ const controller = !isProd ? new AbortController() : undefined;
611
+ const timeout = controller
612
+ ? setTimeout(() => controller.abort(), 10_000)
613
+ : undefined;
614
+
615
+ const response = await fetch(url, {
616
+ method: "POST",
617
+ headers: forwardHeaders,
618
+ body: requestBody,
619
+ signal: controller?.signal,
620
+ });
621
+ if (timeout) clearTimeout(timeout);
622
+
623
+ if (
624
+ (response.status === 401 ||
625
+ response.status === 403 ||
626
+ response.status === 404) &&
627
+ endpointIdx < ANTIGRAVITY_ENDPOINTS.length - 1
628
+ ) {
629
+ log(`Endpoint ${endpoint} returned ${response.status}, cascading...`);
630
+ response.text().catch(() => {});
631
+ continue;
632
+ }
633
+
634
+ return { response, endpoint };
635
+ } catch (err) {
636
+ if (endpointIdx < ANTIGRAVITY_ENDPOINTS.length - 1) {
637
+ log(
638
+ `Endpoint ${endpoint} failed: ${err instanceof Error ? err.message : err}, cascading...`,
639
+ );
640
+ continue;
641
+ }
642
+ throw err;
643
+ }
644
+ }
645
+
646
+ throw new Error("All endpoints failed");
536
647
  }
537
648
 
538
649
  export async function withRotation<T>(
539
- rotator: AccountRotator,
540
- model: string,
541
- originalHeaders: Record<string, string>,
542
- body: RequestBody,
543
- onSuccess: (response: Response, context: RotationAttemptContext) => Promise<T>,
650
+ rotator: AccountRotator,
651
+ model: string,
652
+ originalHeaders: Record<string, string>,
653
+ body: RequestBody,
654
+ onSuccess: (
655
+ response: Response,
656
+ context: RotationAttemptContext,
657
+ ) => Promise<T>,
544
658
  ): Promise<RotationOutcome<T>> {
545
- const sendNoAccountsAvailable = (reason: string): RotationOutcome<T> => {
546
- log(`[${model}] No healthy account available: ${reason}`, rotator, "warn");
547
- const retryAfterMs = rotator.getRetryAfterMs(model);
548
- if (retryAfterMs > 0) {
549
- return {
550
- ok: false,
551
- status: 429,
552
- errorText: `All accounts cooling down or model circuit breaker active: ${reason}`,
553
- retryAfterMs,
554
- };
555
- }
556
- return {
557
- ok: false,
558
- status: 503,
559
- errorText: `All accounts exhausted or disabled: ${reason}`,
560
- };
561
- };
562
-
563
- const rotateAndRelease = async (): Promise<AccountRuntime | null> => {
564
- const nextAccount = await rotator.rotateToNext(model);
565
- if (nextAccount) {
566
- rotator.finishRequest(nextAccount, resolveQuotaModelKey(model) ?? undefined);
567
- }
568
- return nextAccount;
569
- };
570
-
571
- for (let attempt = 0; attempt < MAX_ENDPOINT_RETRIES; attempt++) {
572
- const account = await rotator.getActiveAccount(model);
573
- if (!account) {
574
- return sendNoAccountsAvailable("rotation returned no available account");
575
- }
576
-
577
- const label = account.config.label || account.config.email;
578
- const modelKey = resolveQuotaModelKey(model) ?? model;
579
- const displayModelKey = resolveDisplayModelKey(body.displayModel || model);
580
- const requestId = `${modelKey}-${Date.now().toString(36)}-${attempt + 1}`;
581
- const requestStartMs = Date.now();
582
- const logRequestEnd = (status: string | number, extra = ""): void => {
583
- log(
584
- `[${requestId}] END account=${label} model=${model} status=${status}${extra ? ` ${extra}` : ""} totalMs=${Date.now() - requestStartMs}`,
585
- rotator,
586
- status === 200 || status === 0 ? "info" : "warn",
587
- );
588
- };
589
-
590
- log(`[${requestId}] START account=${label} model=${model} attempt=${attempt + 1}`, rotator);
591
-
592
- try {
593
- const jitterMs = rotator.getSafetyJitterMs(account);
594
- const globalDelayMs = rotator.getGlobalDelayMs();
595
- const totalDelayMs = jitterMs + globalDelayMs;
596
- if (totalDelayMs > 0) {
597
- if (jitterMs > 0) {
598
- log(`[${requestId}] Safety slow-mode jitter ${jitterMs}ms for account/project daily budget pressure`, rotator, "warn");
599
- }
600
- if (globalDelayMs > 0) {
601
- log(`[${requestId}] Global request delay ${globalDelayMs}ms applied to slow down requests`, rotator, "info");
602
- }
603
- await sleep(totalDelayMs);
604
- }
605
-
606
- rotator.recordUpstreamAttempt(account);
607
- const forwarded = await forwardRequest(account, { ...body }, originalHeaders);
608
- const { response, endpoint } = forwarded;
609
- const context: RotationAttemptContext = {
610
- account,
611
- label,
612
- modelKey,
613
- displayModelKey,
614
- requestId,
615
- requestStartMs,
616
- endpoint,
617
- };
618
-
619
- const action = await classifyUpstreamResponse(response, endpoint, account, model, modelKey);
620
-
621
- if (action.kind === "rate-limited") {
622
- log(
623
- `[${label}] 429 rate limited${action.providerResourceExhausted ? " (RESOURCE_EXHAUSTED)" : ""}, cooldown ${Math.ceil(action.cooldownMs / 1000)}s. Error text: ${action.errorText.slice(0, 300)}`,
624
- rotator,
625
- "warn",
626
- );
627
- rotator.markExhausted(account, model, action.cooldownMs, action.errorText.slice(0, 300));
628
- rotator.recordProvider429(account, model, action.cooldownMs);
629
- logRequestEnd(429, `cooldownMs=${action.cooldownMs}${action.providerResourceExhausted ? " resourceExhausted=true" : ""}`);
630
- return {
631
- ok: false,
632
- status: 429,
633
- errorText: action.errorText,
634
- retryAfterMs: action.cooldownMs,
635
- endpoint,
636
- };
637
- }
638
-
639
- if (action.kind === "flagged-401") {
640
- log(`[${label}] BLOCKED (401): ${action.errorText.slice(0, 200)}`, rotator, "error");
641
- const lower401 = action.errorText.toLowerCase();
642
- const matched401 = FLAG_PATTERNS.filter((p) => lower401.includes(p));
643
- const ctx401 = rotator.getFlagContext(account, modelKey);
644
- reportFlagEvent({
645
- flagHttpStatus: 401,
646
- flagPatternsMatched: matched401.length > 0 ? matched401 : ["blocked_401" as FlagPattern],
647
- model: modelKey,
648
- timerType: ctx401.timerType as FlagEventData["timerType"],
649
- accountQuotaPercent: ctx401.accountQuotaPercent,
650
- wasProAccount: ctx401.wasProAccount,
651
- accountTotalRequests: account.totalRequests,
652
- accountRequestsLastHour: ctx401.accountRequestsLastHour,
653
- accountConcurrentAtFlag: account.inFlightRequests,
654
- poolSize: ctx401.poolSize,
655
- poolHealthyCount: ctx401.poolHealthyCount,
656
- protectivePauseTriggered: false,
657
- uptimeSeconds: ctx401.uptimeSeconds,
658
- timeSinceLastFlagSeconds: -1,
659
- });
660
- rotator.markFlagged(account, `Account blocked (401): ${action.errorText.slice(0, 300)}`);
661
- logRequestEnd(401);
662
- const nextAccount = await rotateAndRelease();
663
- if (!nextAccount) {
664
- return sendNoAccountsAvailable(`no replacement account remained after ${label} was flagged with 401`);
665
- }
666
- continue;
667
- }
668
-
669
- if (action.kind === "flagged-403") {
670
- log(`[${label}] FLAGGED: ${action.errorText.slice(0, 200)}`, rotator, "error");
671
- const lower = action.errorText.toLowerCase();
672
- const matchedPatterns = FLAG_PATTERNS.filter((p) => lower.includes(p));
673
- const ctx403 = rotator.getFlagContext(account, modelKey);
674
- reportFlagEvent({
675
- flagHttpStatus: 403,
676
- flagPatternsMatched: matchedPatterns,
677
- model: modelKey,
678
- timerType: ctx403.timerType as FlagEventData["timerType"],
679
- accountQuotaPercent: ctx403.accountQuotaPercent,
680
- wasProAccount: ctx403.wasProAccount,
681
- accountTotalRequests: account.totalRequests,
682
- accountRequestsLastHour: ctx403.accountRequestsLastHour,
683
- accountConcurrentAtFlag: account.inFlightRequests,
684
- poolSize: ctx403.poolSize,
685
- poolHealthyCount: ctx403.poolHealthyCount,
686
- protectivePauseTriggered: false,
687
- uptimeSeconds: ctx403.uptimeSeconds,
688
- timeSinceLastFlagSeconds: -1,
689
- });
690
- rotator.markFlagged(account, action.errorText.slice(0, 300));
691
- logRequestEnd(403);
692
- const nextAccount = await rotateAndRelease();
693
- if (!nextAccount) {
694
- return sendNoAccountsAvailable(`no replacement account remained after ${label} was flagged with 403`);
695
- }
696
- continue;
697
- }
698
-
699
- if (action.kind === "forbidden") {
700
- log(`[${label}] 403: ${action.errorText.slice(0, 200)}`, rotator, "warn");
701
- logRequestEnd(403);
702
- return { ok: false, status: 403, errorText: action.errorText, endpoint };
703
- }
704
-
705
- if (action.kind === "not-found") {
706
- log(`[${label}] 404 from ${action.endpoint}: ${action.errorText.slice(0, 200)}`, rotator, "warn");
707
- logRequestEnd(404, `endpoint=${action.endpoint}`);
708
- return { ok: false, status: 404, errorText: action.errorText, endpoint };
709
- }
710
-
711
- if (action.kind === "bad-request") {
712
- log(`[${label}] 400 Bad Request from ${action.endpoint}: ${action.errorText.slice(0, 500)}`, rotator, "warn");
713
- logRequestEnd(400, `endpoint=${action.endpoint}`);
714
- return { ok: false, status: 400, errorText: action.errorText, endpoint };
715
- }
716
-
717
- if (action.kind === "server-error-503") {
718
- log(`[${label}] Server error 503: ${action.errorText.slice(0, 200)}`, rotator, "warn");
719
- logRequestEnd(503, `endpoint=${action.endpoint}`);
720
- return { ok: false, status: 503, errorText: action.errorText, endpoint };
721
- }
722
-
723
- if (action.kind === "rotate-on-5xx") {
724
- log(`[${label}] Server error ${action.httpStatus}: ${action.errorText.slice(0, 200)}`, rotator, "warn");
725
- logRequestEnd(action.httpStatus, `endpoint=${action.endpoint}`);
726
- rotator.markError(account, `${action.httpStatus}: ${action.errorText.slice(0, 200)}`);
727
- const nextAccount = await rotateAndRelease();
728
- if (!nextAccount) {
729
- return sendNoAccountsAvailable(`no replacement account remained after ${label} failed with ${action.httpStatus}`);
730
- }
731
- continue;
732
- }
733
-
734
- // success
735
- const result = await onSuccess(response, context);
736
- const shouldRotate = rotator.recordRequest(account, model);
737
- logRequestEnd(response.status, `endpoint=${endpoint}`);
738
- if (shouldRotate) {
739
- await rotateAndRelease();
740
- }
741
- return { ok: true, result, endpoint };
742
- } catch (err) {
743
- const formattedError = formatError(err);
744
- log(`[${label}] Request failed: ${formattedError}`, rotator, isFetchTransportError(err) ? "warn" : "error");
745
- logRequestEnd(isFetchTransportError(err) ? "fetch-error" : 500, `error=${formattedError.slice(0, 120)}`);
746
- if (!isFetchTransportError(err)) {
747
- rotator.markError(account, formattedError);
748
- }
749
- const nextAccount = await rotateAndRelease();
750
- if (!nextAccount) {
751
- return sendNoAccountsAvailable(`no replacement account remained after ${label} request error`);
752
- }
753
- continue;
754
- } finally {
755
- rotator.finishRequest(account, resolveQuotaModelKey(model) ?? undefined);
756
- }
757
- }
758
-
759
- return { ok: false, status: 502, errorText: "All retry attempts failed" };
659
+ const sendNoAccountsAvailable = (reason: string): RotationOutcome<T> => {
660
+ log(`[${model}] No healthy account available: ${reason}`, rotator, "warn");
661
+ const retryAfterMs = rotator.getRetryAfterMs(model);
662
+ if (retryAfterMs > 0) {
663
+ return {
664
+ ok: false,
665
+ status: 429,
666
+ errorText: `All accounts cooling down or model circuit breaker active: ${reason}`,
667
+ retryAfterMs,
668
+ };
669
+ }
670
+ return {
671
+ ok: false,
672
+ status: 503,
673
+ errorText: `All accounts exhausted or disabled: ${reason}`,
674
+ };
675
+ };
676
+
677
+ const rotateAndRelease = async (): Promise<AccountRuntime | null> => {
678
+ const nextAccount = await rotator.rotateToNext(model);
679
+ if (nextAccount) {
680
+ rotator.finishRequest(
681
+ nextAccount,
682
+ resolveQuotaModelKey(model) ?? undefined,
683
+ );
684
+ }
685
+ return nextAccount;
686
+ };
687
+
688
+ for (let attempt = 0; attempt < MAX_ENDPOINT_RETRIES; attempt++) {
689
+ const account = await rotator.getActiveAccount(model);
690
+ if (!account) {
691
+ return sendNoAccountsAvailable("rotation returned no available account");
692
+ }
693
+
694
+ const label = account.config.label || account.config.email;
695
+ const modelKey = resolveQuotaModelKey(model) ?? model;
696
+ const displayModelKey = resolveDisplayModelKey(body.displayModel || model);
697
+ const requestId = `${modelKey}-${Date.now().toString(36)}-${attempt + 1}`;
698
+ const requestStartMs = Date.now();
699
+ const logRequestEnd = (status: string | number, extra = ""): void => {
700
+ log(
701
+ `[${requestId}] END account=${label} model=${model} status=${status}${extra ? ` ${extra}` : ""} totalMs=${Date.now() - requestStartMs}`,
702
+ rotator,
703
+ status === 200 || status === 0 ? "info" : "warn",
704
+ );
705
+ };
706
+
707
+ log(
708
+ `[${requestId}] START account=${label} model=${model} attempt=${attempt + 1}`,
709
+ rotator,
710
+ );
711
+
712
+ try {
713
+ const jitterMs = rotator.getSafetyJitterMs(account);
714
+ const globalDelayMs = rotator.getGlobalDelayMs();
715
+ const totalDelayMs = jitterMs + globalDelayMs;
716
+ if (totalDelayMs > 0) {
717
+ if (jitterMs > 0) {
718
+ log(
719
+ `[${requestId}] Safety slow-mode jitter ${jitterMs}ms for account/project daily budget pressure`,
720
+ rotator,
721
+ "warn",
722
+ );
723
+ }
724
+ if (globalDelayMs > 0) {
725
+ log(
726
+ `[${requestId}] Global request delay ${globalDelayMs}ms applied to slow down requests`,
727
+ rotator,
728
+ "info",
729
+ );
730
+ }
731
+ await sleep(totalDelayMs);
732
+ }
733
+
734
+ rotator.recordUpstreamAttempt(account);
735
+ const forwarded = await forwardRequest(
736
+ account,
737
+ { ...body },
738
+ originalHeaders,
739
+ );
740
+ const { response, endpoint } = forwarded;
741
+ const context: RotationAttemptContext = {
742
+ account,
743
+ label,
744
+ modelKey,
745
+ displayModelKey,
746
+ requestId,
747
+ requestStartMs,
748
+ endpoint,
749
+ };
750
+
751
+ const action = await classifyUpstreamResponse(
752
+ response,
753
+ endpoint,
754
+ account,
755
+ model,
756
+ modelKey,
757
+ );
758
+
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)}`,
762
+ rotator,
763
+ "warn",
764
+ );
765
+ rotator.markExhausted(
766
+ account,
767
+ 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,
849
+ });
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;
939
+ }
940
+
941
+ // success
942
+ const result = await onSuccess(response, context);
943
+ const shouldRotate = rotator.recordRequest(account, model);
944
+ logRequestEnd(response.status, `endpoint=${endpoint}`);
945
+ if (shouldRotate) {
946
+ await rotateAndRelease();
947
+ }
948
+ return { ok: true, result, endpoint };
949
+ } catch (err) {
950
+ const formattedError = formatError(err);
951
+ log(
952
+ `[${label}] Request failed: ${formattedError}`,
953
+ rotator,
954
+ isFetchTransportError(err) ? "warn" : "error",
955
+ );
956
+ logRequestEnd(
957
+ isFetchTransportError(err) ? "fetch-error" : 500,
958
+ `error=${formattedError.slice(0, 120)}`,
959
+ );
960
+ if (!isFetchTransportError(err)) {
961
+ rotator.markError(account, formattedError);
962
+ }
963
+ const nextAccount = await rotateAndRelease();
964
+ if (!nextAccount) {
965
+ return sendNoAccountsAvailable(
966
+ `no replacement account remained after ${label} request error`,
967
+ );
968
+ }
969
+ continue;
970
+ } finally {
971
+ rotator.finishRequest(account, resolveQuotaModelKey(model) ?? undefined);
972
+ }
973
+ }
974
+
975
+ return { ok: false, status: 502, errorText: "All retry attempts failed" };
760
976
  }
761
977
 
762
- function log(msg: string, rotator?: AccountRotator, level: "info" | "warn" | "error" = "info"): void {
763
- proxyLogger.log(level, msg);
764
- rotator?.recordProxyEvent(msg, level);
978
+ function log(
979
+ msg: string,
980
+ rotator?: AccountRotator,
981
+ level: "info" | "warn" | "error" = "info",
982
+ ): void {
983
+ proxyLogger.log(level, msg);
984
+ rotator?.recordProxyEvent(msg, level);
765
985
  }
766
986
 
767
987
  /**
768
988
  * Handle a proxied API request.
769
989
  */
770
990
  async function handleProxyRequest(
771
- req: IncomingMessage,
772
- res: ServerResponse,
773
- rotator: AccountRotator,
774
- onComplete?: () => void,
991
+ req: IncomingMessage,
992
+ res: ServerResponse,
993
+ rotator: AccountRotator,
994
+ onComplete?: () => void,
775
995
  ): Promise<void> {
776
- let bodyBuffer: Buffer;
777
- try {
778
- bodyBuffer = await readLimitedBody(req);
779
- } catch (err) {
780
- if (err instanceof PayloadTooLargeError) {
781
- res.writeHead(413, { "Content-Type": "application/json" });
782
- res.end(JSON.stringify({ error: "Payload too large", limitBytes: err.limitBytes }));
783
- return;
784
- }
785
- throw err;
786
- }
787
- let body: RequestBody;
788
- try {
789
- const parsed: unknown = JSON.parse(bodyBuffer.toString("utf-8"));
790
- const validation = validateProxyRequestBody(parsed);
791
- if (!validation.ok || !validation.value) {
792
- res.writeHead(400, { "Content-Type": "application/json" });
793
- res.end(JSON.stringify({ error: "Invalid request body", details: validation.errors }));
794
- return;
795
- }
796
- body = validation.value as RequestBody;
797
- } catch {
798
- res.writeHead(400, { "Content-Type": "application/json" });
799
- res.end(JSON.stringify({ error: "Invalid JSON body" }));
800
- return;
801
- }
802
-
803
- const proxyLog = (msg: string, level: "info" | "warn" | "error" = "info"): void => {
804
- log(msg, rotator, level);
805
- };
806
- if (bodyBuffer.length > LARGE_CONTEXT_WARN_BYTES) {
807
- proxyLog(`[${body.model}] Large request body ${bodyBuffer.length} bytes; high context pressure increases rate-limit/flag risk`, "warn");
808
- }
809
-
810
- const sendNoAccountsAvailable = (reason: string): void => {
811
- proxyLog(`[${body.model}] No healthy account available: ${reason}`, "warn");
812
- const retryAfterMs = rotator.getRetryAfterMs(body.model);
813
- if (retryAfterMs > 0) {
814
- res.writeHead(429, {
815
- "Content-Type": "application/json",
816
- "Retry-After": String(Math.ceil(retryAfterMs / 1000)),
817
- });
818
- res.end(JSON.stringify({
819
- error: "All accounts cooling down or model circuit breaker active",
820
- reason,
821
- model: body.model,
822
- retryAfterMs,
823
- }));
824
- return;
825
- }
826
- res.writeHead(503, { "Content-Type": "application/json" });
827
- res.end(JSON.stringify({ error: "All accounts exhausted or disabled", reason, model: body.model, retryable: false }));
828
- };
829
- const rotateAndRelease = async (): Promise<AccountRuntime | null> => {
830
- const nextAccount = await rotator.rotateToNext(body.model);
831
- if (nextAccount) {
832
- rotator.finishRequest(nextAccount, resolveQuotaModelKey(body.model) ?? undefined);
833
- }
834
- return nextAccount;
835
- };
836
-
837
- for (let attempt = 0; attempt < MAX_ENDPOINT_RETRIES; attempt++) {
838
- const account = await rotator.getActiveAccount(body.model);
839
- if (!account) {
840
- sendNoAccountsAvailable("rotation returned no available account");
841
- return;
842
- }
843
-
844
- const label = account.config.label || account.config.email;
845
- const modelKey = resolveQuotaModelKey(body.model) ?? body.model; // quota routing
846
- const displayModelKey = resolveDisplayModelKey(body.model); // metrics/logs
847
- const requestId = `${modelKey}-${Date.now().toString(36)}-${attempt + 1}`;
848
- proxyLog(`[${requestId}] START account=${label} model=${body.model} attempt=${attempt + 1}`);
849
- const requestStartMs = Date.now();
850
- const logRequestEnd = (status: string | number, extra = ""): void => {
851
- proxyLog(
852
- `[${requestId}] END account=${label} model=${body.model} status=${status}${extra ? ` ${extra}` : ""} totalMs=${Date.now() - requestStartMs}`,
853
- status === 200 || status === 0 ? "info" : "warn",
854
- );
855
- };
856
- const recordOutcome = (statusCode: number, ttfbMs = 0, totalMs = Date.now() - requestStartMs, inputTokens = 0, outputTokens = 0): void => {
857
- rotator.recordRequestLog({
858
- model: modelKey,
859
- account: label,
860
- statusCode,
861
- ttfbMs,
862
- totalMs,
863
- inputTokens,
864
- outputTokens,
865
- });
866
- };
867
-
868
- try {
869
- const jitterMs = rotator.getSafetyJitterMs(account);
870
- const globalDelayMs = rotator.getGlobalDelayMs();
871
- const totalDelayMs = jitterMs + globalDelayMs;
872
- if (totalDelayMs > 0) {
873
- if (jitterMs > 0) {
874
- proxyLog(`[${requestId}] Safety slow-mode jitter ${jitterMs}ms for account/project daily budget pressure`, "warn");
875
- }
876
- if (globalDelayMs > 0) {
877
- proxyLog(`[${requestId}] Global request delay ${globalDelayMs}ms applied to slow down requests`, "info");
878
- }
879
- await sleep(totalDelayMs);
880
- }
881
- rotator.recordUpstreamAttempt(account);
882
- const forwarded = await forwardRequest(account, { ...body }, flattenHeaders(req.headers));
883
- const { response, endpoint } = forwarded;
884
-
885
- const action = await classifyUpstreamResponse(response, endpoint, account, body.model, modelKey);
886
-
887
- if (action.kind === "rate-limited") {
888
- proxyLog(
889
- `[${label}] 429 rate limited${action.providerResourceExhausted ? " (RESOURCE_EXHAUSTED)" : ""}, cooldown ${Math.ceil(action.cooldownMs / 1000)}s. Error text: ${action.errorText.slice(0, 300)}`,
890
- "warn",
891
- );
892
- recordOutcome(429);
893
- logRequestEnd(429, `cooldownMs=${action.cooldownMs}${action.providerResourceExhausted ? " resourceExhausted=true" : ""} endpoint=${endpoint}`);
894
- rotator.markExhausted(account, body.model, action.cooldownMs);
895
- rotator.recordProvider429(account, body.model, action.cooldownMs);
896
-
897
- // Safety first: do NOT immediately retry another account on 429.
898
- // Provider-side 429s can represent daily/request buckets or shared project pressure;
899
- // cascading retries burn the full pool and increase ban/flag risk.
900
- res.writeHead(429, {
901
- "Content-Type": "application/json",
902
- "Retry-After": String(Math.ceil(action.cooldownMs / 1000)),
903
- });
904
- res.end(JSON.stringify({
905
- error: action.providerResourceExhausted ? "Resource exhausted" : "Rate limited",
906
- reason: action.providerResourceExhausted
907
- ? `${label} hit provider RESOURCE_EXHAUSTED; not retrying another account to avoid pool-wide hammering`
908
- : `${label} was rate limited; not retrying another account for account-safety`,
909
- model: body.model,
910
- account: label,
911
- retryAfterMs: action.cooldownMs,
912
- }));
913
- return;
914
- }
915
-
916
- if (action.kind === "flagged-401") {
917
- proxyLog(`[${label}] BLOCKED (401): ${action.errorText.slice(0, 200)}`, "error");
918
-
919
- // Telemetry: report flag event BEFORE markFlagged (which may trigger protective pause)
920
- const lower401 = action.errorText.toLowerCase();
921
- const matched401 = FLAG_PATTERNS.filter(p => lower401.includes(p));
922
- const ctx401 = rotator.getFlagContext(account, modelKey);
923
- reportFlagEvent({
924
- flagHttpStatus: 401,
925
- flagPatternsMatched: matched401.length > 0 ? matched401 : ["blocked_401" as FlagPattern],
926
- model: modelKey,
927
- timerType: ctx401.timerType as FlagEventData["timerType"],
928
- accountQuotaPercent: ctx401.accountQuotaPercent,
929
- wasProAccount: ctx401.wasProAccount,
930
- accountTotalRequests: account.totalRequests,
931
- accountRequestsLastHour: ctx401.accountRequestsLastHour,
932
- accountConcurrentAtFlag: account.inFlightRequests,
933
- poolSize: ctx401.poolSize,
934
- poolHealthyCount: ctx401.poolHealthyCount,
935
- protectivePauseTriggered: false, // not yet — markFlagged decides
936
- uptimeSeconds: ctx401.uptimeSeconds,
937
- timeSinceLastFlagSeconds: -1, // filled by reporter
938
- });
939
-
940
- rotator.markFlagged(account, `Account blocked (401): ${action.errorText.slice(0, 300)}`);
941
- logRequestEnd(401, `endpoint=${endpoint}`);
942
- const nextAccount = await rotateAndRelease();
943
- if (!nextAccount) {
944
- sendNoAccountsAvailable(`no replacement account remained after ${label} was flagged with 401`);
945
- return;
946
- }
947
- continue;
948
- }
949
-
950
- if (action.kind === "flagged-403") {
951
- proxyLog(`[${label}] FLAGGED: ${action.errorText.slice(0, 200)}`, "error");
952
- recordOutcome(403);
953
- logRequestEnd(403, `endpoint=${endpoint}`);
954
-
955
- const matchedPatterns = FLAG_PATTERNS.filter(p => action.errorText.toLowerCase().includes(p));
956
- const ctx403 = rotator.getFlagContext(account, modelKey);
957
- reportFlagEvent({
958
- flagHttpStatus: 403,
959
- flagPatternsMatched: matchedPatterns,
960
- model: modelKey,
961
- timerType: ctx403.timerType as FlagEventData["timerType"],
962
- accountQuotaPercent: ctx403.accountQuotaPercent,
963
- wasProAccount: ctx403.wasProAccount,
964
- accountTotalRequests: account.totalRequests,
965
- accountRequestsLastHour: ctx403.accountRequestsLastHour,
966
- accountConcurrentAtFlag: account.inFlightRequests,
967
- poolSize: ctx403.poolSize,
968
- poolHealthyCount: ctx403.poolHealthyCount,
969
- protectivePauseTriggered: false, // not yet
970
- uptimeSeconds: ctx403.uptimeSeconds,
971
- timeSinceLastFlagSeconds: -1, // filled by reporter
972
- });
973
-
974
- rotator.markFlagged(account, action.errorText.slice(0, 300));
975
- const nextAccount = await rotateAndRelease();
976
- if (!nextAccount) {
977
- sendNoAccountsAvailable(`no replacement account remained after ${label} was flagged with 403`);
978
- return;
979
- }
980
- continue;
981
- }
982
-
983
- if (action.kind === "forbidden") {
984
- proxyLog(`[${label}] 403: ${action.errorText.slice(0, 200)}`, "warn");
985
- logRequestEnd(403, `endpoint=${action.endpoint}`);
986
- res.writeHead(403, { "Content-Type": "application/json" });
987
- res.end(action.errorText || JSON.stringify({ error: "Forbidden" }));
988
- return;
989
- }
990
-
991
- if (action.kind === "not-found") {
992
- proxyLog(`[${label}] 404 from ${action.endpoint}: ${action.errorText.slice(0, 200)}`, "warn");
993
- logRequestEnd(404, `endpoint=${action.endpoint}`);
994
- res.writeHead(404, { "Content-Type": "application/json" });
995
- res.end(action.errorText || JSON.stringify({ error: "Not found" }));
996
- return;
997
- }
998
-
999
- if (action.kind === "bad-request") {
1000
- proxyLog(`[${label}] 400 Bad Request from ${action.endpoint}: ${action.errorText.slice(0, 500)}`, "warn");
1001
- logRequestEnd(400, `endpoint=${action.endpoint}`);
1002
- res.writeHead(400, { "Content-Type": "application/json" });
1003
- res.end(action.errorText || JSON.stringify({ error: "Bad request" }));
1004
- return;
1005
- }
1006
-
1007
- if (action.kind === "server-error-503") {
1008
- proxyLog(`[${label}] Server error 503: ${action.errorText.slice(0, 200)}`, "warn");
1009
- recordOutcome(503);
1010
- logRequestEnd(503, `endpoint=${action.endpoint}`);
1011
- // Return 503 as-is. Capacity errors still consume quota upstream,
1012
- // so retrying on another account would just burn more quota for nothing.
1013
- res.writeHead(503, { "Content-Type": "application/json" });
1014
- res.end(action.errorText || JSON.stringify({ error: "Server unavailable", account: label, model: body.model }));
1015
- return;
1016
- }
1017
-
1018
- if (action.kind === "rotate-on-5xx") {
1019
- proxyLog(`[${label}] Server error ${action.httpStatus}: ${action.errorText.slice(0, 200)}`, "warn");
1020
- recordOutcome(action.httpStatus);
1021
- logRequestEnd(action.httpStatus, `endpoint=${action.endpoint}`);
1022
- rotator.markError(account, `${action.httpStatus}: ${action.errorText.slice(0, 200)}`);
1023
- const nextAccount = await rotateAndRelease();
1024
- if (!nextAccount) {
1025
- sendNoAccountsAvailable(`no replacement account remained after ${label} failed with ${action.httpStatus}`);
1026
- return;
1027
- }
1028
- continue;
1029
- }
1030
-
1031
- // Success or non-error client response
1032
- const shouldRotate = rotator.recordRequest(account, body.model);
1033
-
1034
- const responseHeaders: Record<string, string> = {};
1035
- response.headers.forEach((value, key) => {
1036
- if (key.toLowerCase() !== "transfer-encoding" && key.toLowerCase() !== "connection") {
1037
- responseHeaders[key] = value;
1038
- }
1039
- });
1040
-
1041
- res.writeHead(response.status, responseHeaders);
1042
-
1043
- try {
1044
- const usage = await streamResponseBody(response.body, req, res, label, proxyLog);
1045
- const totalMs = Date.now() - requestStartMs;
1046
- const ttfbMs = usage?.firstByteMs ?? totalMs;
1047
- rotator.recordLatency(body.displayModel || body.model, ttfbMs, totalMs);
1048
- logRequestEnd(response.status, `ttfbMs=${ttfbMs} endpoint=${endpoint}`);
1049
- rotator.recordRequestLog({
1050
- model: displayModelKey,
1051
- account: label,
1052
- statusCode: response.status,
1053
- ttfbMs,
1054
- totalMs,
1055
- inputTokens: usage?.inputTokens ?? 0,
1056
- outputTokens: usage?.outputTokens ?? 0,
1057
- });
1058
- if (usage && (usage.inputTokens > 0 || usage.outputTokens > 0)) {
1059
- rotator.recordTokenUsage(body.displayModel || body.model, usage.inputTokens, usage.outputTokens);
1060
- }
1061
- } catch (err) {
1062
- proxyLog(`[${label}] Stream setup error: ${err}`, "warn");
1063
- }
1064
- res.end();
1065
-
1066
- if (shouldRotate) {
1067
- await rotateAndRelease();
1068
- }
1069
- return;
1070
- } catch (err) {
1071
- const formattedError = formatError(err);
1072
- proxyLog(`[${label}] Request failed: ${formattedError}`, isFetchTransportError(err) ? "warn" : "error");
1073
- recordOutcome(isFetchTransportError(err) ? 0 : 500);
1074
- logRequestEnd(isFetchTransportError(err) ? "fetch-error" : 500, `error=${formattedError.slice(0, 120)}`);
1075
- if (!isFetchTransportError(err)) {
1076
- rotator.markError(account, formattedError);
1077
- }
1078
- if (res.headersSent) {
1079
- res.end();
1080
- return;
1081
- }
1082
- const nextAccount = await rotateAndRelease();
1083
- if (!nextAccount) {
1084
- sendNoAccountsAvailable(`no replacement account remained after ${label} request error`);
1085
- return;
1086
- }
1087
- continue;
1088
- } finally {
1089
- rotator.finishRequest(account, resolveQuotaModelKey(body.model) ?? undefined);
1090
- if (onComplete) onComplete();
1091
- }
1092
- }
1093
-
1094
- if (!res.headersSent) {
1095
- res.writeHead(502, { "Content-Type": "application/json" });
1096
- }
1097
- res.end(JSON.stringify({ error: "All retry attempts failed" }));
996
+ let bodyBuffer: Buffer;
997
+ try {
998
+ bodyBuffer = await readLimitedBody(req);
999
+ } catch (err) {
1000
+ if (err instanceof PayloadTooLargeError) {
1001
+ res.writeHead(413, { "Content-Type": "application/json" });
1002
+ res.end(
1003
+ JSON.stringify({
1004
+ error: "Payload too large",
1005
+ limitBytes: err.limitBytes,
1006
+ }),
1007
+ );
1008
+ return;
1009
+ }
1010
+ throw err;
1011
+ }
1012
+ let body: RequestBody;
1013
+ try {
1014
+ const parsed: unknown = JSON.parse(bodyBuffer.toString("utf-8"));
1015
+ const validation = validateProxyRequestBody(parsed);
1016
+ if (!validation.ok || !validation.value) {
1017
+ res.writeHead(400, { "Content-Type": "application/json" });
1018
+ res.end(
1019
+ JSON.stringify({
1020
+ error: "Invalid request body",
1021
+ details: validation.errors,
1022
+ }),
1023
+ );
1024
+ return;
1025
+ }
1026
+ body = validation.value as RequestBody;
1027
+ } catch {
1028
+ res.writeHead(400, { "Content-Type": "application/json" });
1029
+ res.end(JSON.stringify({ error: "Invalid JSON body" }));
1030
+ return;
1031
+ }
1032
+
1033
+ const proxyLog = (
1034
+ msg: string,
1035
+ level: "info" | "warn" | "error" = "info",
1036
+ ): void => {
1037
+ log(msg, rotator, level);
1038
+ };
1039
+ if (bodyBuffer.length > LARGE_CONTEXT_WARN_BYTES) {
1040
+ proxyLog(
1041
+ `[${body.model}] Large request body ${bodyBuffer.length} bytes; high context pressure increases rate-limit/flag risk`,
1042
+ "warn",
1043
+ );
1044
+ }
1045
+
1046
+ const sendNoAccountsAvailable = (reason: string): void => {
1047
+ proxyLog(`[${body.model}] No healthy account available: ${reason}`, "warn");
1048
+ const retryAfterMs = rotator.getRetryAfterMs(body.model);
1049
+ if (retryAfterMs > 0) {
1050
+ res.writeHead(429, {
1051
+ "Content-Type": "application/json",
1052
+ "Retry-After": String(Math.ceil(retryAfterMs / 1000)),
1053
+ });
1054
+ res.end(
1055
+ JSON.stringify({
1056
+ error: "All accounts cooling down or model circuit breaker active",
1057
+ reason,
1058
+ model: body.model,
1059
+ retryAfterMs,
1060
+ }),
1061
+ );
1062
+ return;
1063
+ }
1064
+ res.writeHead(503, { "Content-Type": "application/json" });
1065
+ res.end(
1066
+ JSON.stringify({
1067
+ error: "All accounts exhausted or disabled",
1068
+ reason,
1069
+ model: body.model,
1070
+ retryable: false,
1071
+ }),
1072
+ );
1073
+ };
1074
+ const rotateAndRelease = async (): Promise<AccountRuntime | null> => {
1075
+ const nextAccount = await rotator.rotateToNext(body.model);
1076
+ if (nextAccount) {
1077
+ rotator.finishRequest(
1078
+ nextAccount,
1079
+ resolveQuotaModelKey(body.model) ?? undefined,
1080
+ );
1081
+ }
1082
+ return nextAccount;
1083
+ };
1084
+
1085
+ for (let attempt = 0; attempt < MAX_ENDPOINT_RETRIES; attempt++) {
1086
+ const account = await rotator.getActiveAccount(body.model);
1087
+ if (!account) {
1088
+ sendNoAccountsAvailable("rotation returned no available account");
1089
+ return;
1090
+ }
1091
+
1092
+ const label = account.config.label || account.config.email;
1093
+ const modelKey = resolveQuotaModelKey(body.model) ?? body.model; // quota routing
1094
+ const displayModelKey = resolveDisplayModelKey(body.model); // metrics/logs
1095
+ const requestId = `${modelKey}-${Date.now().toString(36)}-${attempt + 1}`;
1096
+ proxyLog(
1097
+ `[${requestId}] START account=${label} model=${body.model} attempt=${attempt + 1}`,
1098
+ );
1099
+ const requestStartMs = Date.now();
1100
+ const logRequestEnd = (status: string | number, extra = ""): void => {
1101
+ proxyLog(
1102
+ `[${requestId}] END account=${label} model=${body.model} status=${status}${extra ? ` ${extra}` : ""} totalMs=${Date.now() - requestStartMs}`,
1103
+ status === 200 || status === 0 ? "info" : "warn",
1104
+ );
1105
+ };
1106
+ const recordOutcome = (
1107
+ statusCode: number,
1108
+ ttfbMs = 0,
1109
+ totalMs = Date.now() - requestStartMs,
1110
+ inputTokens = 0,
1111
+ outputTokens = 0,
1112
+ ): void => {
1113
+ rotator.recordRequestLog({
1114
+ model: modelKey,
1115
+ account: label,
1116
+ statusCode,
1117
+ ttfbMs,
1118
+ totalMs,
1119
+ inputTokens,
1120
+ outputTokens,
1121
+ });
1122
+ };
1123
+
1124
+ try {
1125
+ const jitterMs = rotator.getSafetyJitterMs(account);
1126
+ const globalDelayMs = rotator.getGlobalDelayMs();
1127
+ const totalDelayMs = jitterMs + globalDelayMs;
1128
+ if (totalDelayMs > 0) {
1129
+ if (jitterMs > 0) {
1130
+ proxyLog(
1131
+ `[${requestId}] Safety slow-mode jitter ${jitterMs}ms for account/project daily budget pressure`,
1132
+ "warn",
1133
+ );
1134
+ }
1135
+ if (globalDelayMs > 0) {
1136
+ proxyLog(
1137
+ `[${requestId}] Global request delay ${globalDelayMs}ms applied to slow down requests`,
1138
+ "info",
1139
+ );
1140
+ }
1141
+ await sleep(totalDelayMs);
1142
+ }
1143
+ rotator.recordUpstreamAttempt(account);
1144
+ const forwarded = await forwardRequest(
1145
+ account,
1146
+ { ...body },
1147
+ flattenHeaders(req.headers),
1148
+ );
1149
+ const { response, endpoint } = forwarded;
1150
+
1151
+ const action = await classifyUpstreamResponse(
1152
+ response,
1153
+ endpoint,
1154
+ account,
1155
+ body.model,
1156
+ modelKey,
1157
+ );
1158
+
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(
1224
+ 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
1265
+ });
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
+ );
1326
+ return;
1327
+ }
1328
+
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
+ // Success or non-error client response
1351
+ const shouldRotate = rotator.recordRequest(account, body.model);
1352
+
1353
+ const responseHeaders: Record<string, string> = {};
1354
+ response.headers.forEach((value, key) => {
1355
+ if (
1356
+ key.toLowerCase() !== "transfer-encoding" &&
1357
+ key.toLowerCase() !== "connection"
1358
+ ) {
1359
+ responseHeaders[key] = value;
1360
+ }
1361
+ });
1362
+
1363
+ res.writeHead(response.status, responseHeaders);
1364
+
1365
+ try {
1366
+ const usage = await streamResponseBody(
1367
+ response.body,
1368
+ req,
1369
+ res,
1370
+ label,
1371
+ proxyLog,
1372
+ );
1373
+ const totalMs = Date.now() - requestStartMs;
1374
+ const ttfbMs = usage?.firstByteMs ?? totalMs;
1375
+ rotator.recordLatency(body.displayModel || body.model, ttfbMs, totalMs);
1376
+ logRequestEnd(response.status, `ttfbMs=${ttfbMs} endpoint=${endpoint}`);
1377
+ rotator.recordRequestLog({
1378
+ model: displayModelKey,
1379
+ account: label,
1380
+ statusCode: response.status,
1381
+ ttfbMs,
1382
+ totalMs,
1383
+ inputTokens: usage?.inputTokens ?? 0,
1384
+ outputTokens: usage?.outputTokens ?? 0,
1385
+ });
1386
+ if (usage && (usage.inputTokens > 0 || usage.outputTokens > 0)) {
1387
+ rotator.recordTokenUsage(
1388
+ body.displayModel || body.model,
1389
+ usage.inputTokens,
1390
+ usage.outputTokens,
1391
+ );
1392
+ }
1393
+ } catch (err) {
1394
+ proxyLog(`[${label}] Stream setup error: ${err}`, "warn");
1395
+ }
1396
+ res.end();
1397
+
1398
+ if (shouldRotate) {
1399
+ await rotateAndRelease();
1400
+ }
1401
+ return;
1402
+ } catch (err) {
1403
+ const formattedError = formatError(err);
1404
+ proxyLog(
1405
+ `[${label}] Request failed: ${formattedError}`,
1406
+ isFetchTransportError(err) ? "warn" : "error",
1407
+ );
1408
+ recordOutcome(isFetchTransportError(err) ? 0 : 500);
1409
+ logRequestEnd(
1410
+ isFetchTransportError(err) ? "fetch-error" : 500,
1411
+ `error=${formattedError.slice(0, 120)}`,
1412
+ );
1413
+ if (!isFetchTransportError(err)) {
1414
+ rotator.markError(account, formattedError);
1415
+ }
1416
+ if (res.headersSent) {
1417
+ res.end();
1418
+ return;
1419
+ }
1420
+ const nextAccount = await rotateAndRelease();
1421
+ if (!nextAccount) {
1422
+ sendNoAccountsAvailable(
1423
+ `no replacement account remained after ${label} request error`,
1424
+ );
1425
+ return;
1426
+ }
1427
+ continue;
1428
+ } finally {
1429
+ rotator.finishRequest(
1430
+ account,
1431
+ resolveQuotaModelKey(body.model) ?? undefined,
1432
+ );
1433
+ if (onComplete) onComplete();
1434
+ }
1435
+ }
1436
+
1437
+ if (!res.headersSent) {
1438
+ res.writeHead(502, { "Content-Type": "application/json" });
1439
+ }
1440
+ res.end(JSON.stringify({ error: "All retry attempts failed" }));
1098
1441
  }
1099
1442
 
1100
- export function flattenHeaders(headers: IncomingMessage["headers"]): Record<string, string> {
1101
- const flat: Record<string, string> = {};
1102
- for (const [key, value] of Object.entries(headers)) {
1103
- if (value) {
1104
- flat[key] = Array.isArray(value) ? value.join(", ") : value;
1105
- }
1106
- }
1107
- return flat;
1443
+ export function flattenHeaders(
1444
+ headers: IncomingMessage["headers"],
1445
+ ): Record<string, string> {
1446
+ const flat: Record<string, string> = {};
1447
+ for (const [key, value] of Object.entries(headers)) {
1448
+ if (value) {
1449
+ flat[key] = Array.isArray(value) ? value.join(", ") : value;
1450
+ }
1451
+ }
1452
+ return flat;
1108
1453
  }
1109
1454
 
1110
- export function startProxy(rotator: AccountRotator, port: number, bindHost = "0.0.0.0"): void {
1111
- startVersionChecker();
1112
- startNotificationPoller();
1113
- const sseClients = new Set<ServerResponse>();
1114
- let sseBroadcastTimer: ReturnType<typeof setTimeout> | null = null;
1115
- const SSE_THROTTLE_MS = 1000; // max 1 push/second
1116
-
1117
- const scheduleSseBroadcast = (): void => {
1118
- if (sseBroadcastTimer) return; // already scheduled
1119
- sseBroadcastTimer = setTimeout(() => {
1120
- sseBroadcastTimer = null;
1121
- if (sseClients.size === 0) return;
1122
- const data = JSON.stringify(rotator.getStatus());
1123
- for (const client of sseClients) {
1124
- try {
1125
- client.write(`data: ${data}\n\n`);
1126
- } catch {
1127
- sseClients.delete(client);
1128
- }
1129
- }
1130
- }, SSE_THROTTLE_MS);
1131
- };
1132
-
1133
- // Hook into rotator state changes to trigger SSE
1134
- const origSaveState = rotator.saveState.bind(rotator);
1135
- rotator.saveState = (): void => {
1136
- origSaveState();
1137
- scheduleSseBroadcast();
1138
- };
1139
-
1140
- const server = createServer((req, res) => {
1141
- const method = req.method?.toUpperCase();
1142
- const url = req.url || "";
1143
- const pathname = url.split("?")[0];
1144
-
1145
- if (method === "GET" && (pathname === "/" || pathname === "/dashboard")) {
1146
- if (!requireAdmin(req, res)) return;
1147
- trackFeature("dashboard");
1148
- serveDashboard(res);
1149
- return;
1150
- }
1151
-
1152
- if (method === "GET" && pathname === "/login") {
1153
- if (!requireAdmin(req, res)) return;
1154
- trackFeature("hostedLogin");
1155
- serveLoginLanding(res);
1156
- return;
1157
- }
1158
-
1159
- if (method === "GET" && pathname === "/auth/antigravity/start") {
1160
- if (!requireAdmin(req, res)) return;
1161
- startHostedLogin(req, res);
1162
- return;
1163
- }
1164
-
1165
- if (method === "GET" && pathname === "/auth/antigravity/callback") {
1166
- if (!requireAdmin(req, res)) return;
1167
- handleHostedCallback(req, res, rotator).catch((err) => {
1168
- log(`Hosted callback error: ${err}`, rotator, "error");
1169
- if (!res.headersSent) {
1170
- res.writeHead(500, { "Content-Type": "text/html; charset=utf-8" });
1171
- }
1172
- res.end("<h1>Internal login error</h1>");
1173
- });
1174
- return;
1175
- }
1176
-
1177
- if (method === "GET" && pathname === "/api/status") {
1178
- if (!requireAdmin(req, res)) return;
1179
- serveStatusApi(res, rotator);
1180
- return;
1181
- }
1182
-
1183
- if (method === "GET" && pathname === "/api/config") {
1184
- if (!requireAdmin(req, res)) return;
1185
- serveConfigApi(res, rotator);
1186
- return;
1187
- }
1188
-
1189
- if (method === "GET" && pathname === "/api/config/export") {
1190
- if (!requireAdmin(req, res)) return;
1191
- serveConfigExportApi(res, rotator);
1192
- return;
1193
- }
1194
-
1195
- if ((method === "PUT" && pathname === "/api/config") || (method === "POST" && pathname === "/api/config/import")) {
1196
- if (!requireAdmin(req, res)) return;
1197
- readJsonRequest(req).then((parsed) => {
1198
- const candidate = parsed && typeof parsed === "object" && "config" in (parsed as Record<string, unknown>)
1199
- ? (parsed as { config: unknown }).config
1200
- : parsed;
1201
- const validation = validateConfig(candidate);
1202
- if (!validation.ok || !validation.value) {
1203
- res.writeHead(400, { "Content-Type": "application/json" });
1204
- res.end(JSON.stringify({ ok: false, errors: validation.errors }));
1205
- return;
1206
- }
1207
- serveConfigImportApi(res, rotator, applyConfigDefaults(validation.value));
1208
- }).catch((err) => {
1209
- res.writeHead(err instanceof PayloadTooLargeError ? 413 : 400, { "Content-Type": "application/json" });
1210
- res.end(JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) }));
1211
- });
1212
- return;
1213
- }
1214
-
1215
- if (method === "GET" && pathname === "/api/events") {
1216
- if (!requireAdmin(req, res)) return;
1217
- // Server-Sent Events for live dashboard
1218
- res.writeHead(200, {
1219
- "Content-Type": "text/event-stream",
1220
- "Cache-Control": "no-cache",
1221
- "Connection": "keep-alive",
1222
- "X-Accel-Buffering": "no",
1223
- });
1224
- res.write(":\n\n"); // keepalive comment
1225
- sseClients.add(res);
1226
- req.on("close", () => sseClients.delete(res));
1227
- return;
1228
- }
1229
-
1230
- if (method === "POST" && url.startsWith("/api/enable/")) {
1231
- if (!requireAdmin(req, res)) return;
1232
- const email = decodeURIComponent(url.slice("/api/enable/".length));
1233
- serveEnableApi(res, rotator, email);
1234
- return;
1235
- }
1236
-
1237
- if (method === "POST" && url.startsWith("/api/disable/")) {
1238
- if (!requireAdmin(req, res)) return;
1239
- const email = decodeURIComponent(url.slice("/api/disable/".length));
1240
- serveDisableApi(res, rotator, email);
1241
- return;
1242
- }
1243
-
1244
- if (method === "POST" && url.startsWith("/api/quarantine/")) {
1245
- if (!requireAdmin(req, res)) return;
1246
- const email = decodeURIComponent(url.slice("/api/quarantine/".length));
1247
- serveQuarantineApi(res, rotator, email);
1248
- return;
1249
- }
1250
-
1251
- if (method === "POST" && url.startsWith("/api/restore/")) {
1252
- if (!requireAdmin(req, res)) return;
1253
- const email = decodeURIComponent(url.slice("/api/restore/".length));
1254
- serveRestoreApi(res, rotator, email);
1255
- return;
1256
- }
1257
-
1258
- if (method === "POST" && url.startsWith("/api/clear-inflight/")) {
1259
- if (!requireAdmin(req, res)) return;
1260
- const rest = url.slice("/api/clear-inflight/".length);
1261
- const firstSlash = rest.indexOf("/");
1262
- const email = decodeURIComponent(firstSlash >= 0 ? rest.slice(0, firstSlash) : rest);
1263
- const modelKey = firstSlash >= 0 ? decodeURIComponent(rest.slice(firstSlash + 1)) : undefined;
1264
- serveClearInFlightApi(res, rotator, email, modelKey);
1265
- return;
1266
- }
1267
-
1268
- if (method === "POST" && url.startsWith("/api/clear-breaker/")) {
1269
- if (!requireAdmin(req, res)) return;
1270
- const rest = url.slice("/api/clear-breaker/".length);
1271
- const modelKey = rest && rest !== "all" ? decodeURIComponent(rest) : undefined;
1272
- serveClearBreakerApi(res, rotator, modelKey);
1273
- return;
1274
- }
1275
-
1276
-
1277
-
1278
- if (method === "POST" && (url === "/api/settings/fresh-window-starts/on" || url === "/api/settings/fresh-window-starts/off")) {
1279
- if (!requireAdmin(req, res)) return;
1280
- trackFeature("freshWindowToggle");
1281
- serveFreshWindowStartsApi(res, rotator, url.endsWith("/on"));
1282
- return;
1283
- }
1284
-
1285
- if (
1286
- method === "POST" &&
1287
- (url.startsWith("/api/account-fresh-window-starts/") && (url.endsWith("/on") || url.endsWith("/off")))
1288
- ) {
1289
- if (!requireAdmin(req, res)) return;
1290
- const rest = url.slice("/api/account-fresh-window-starts/".length);
1291
- const lastSlash = rest.lastIndexOf("/");
1292
- const email = decodeURIComponent(rest.slice(0, lastSlash));
1293
- const enabled = rest.slice(lastSlash + 1) === "on";
1294
- serveAccountFreshWindowStartsApi(res, rotator, email, enabled);
1295
- return;
1296
- }
1297
-
1298
- if (method === "POST" && pathname === "/api/self-update") {
1299
- if (!requireAdmin(req, res)) return;
1300
- trackFeature("selfUpdate");
1301
- try {
1302
- const result = performSelfUpdate();
1303
- res.writeHead(result.ok ? 200 : 500, { "Content-Type": "application/json" });
1304
- res.end(JSON.stringify(result));
1305
- } catch (err) {
1306
- res.writeHead(500, { "Content-Type": "application/json" });
1307
- res.end(JSON.stringify({ ok: false, message: String(err) }));
1308
- }
1309
- return;
1310
- }
1311
-
1312
- // OpenAI-compatible adapter route (additive; does not affect native v1internal route)
1313
- if (method === "GET" && pathname === "/v1/models") {
1314
- serveOpenAIModels(res);
1315
- return;
1316
- }
1317
-
1318
- if (method === "GET" && pathname === "/v1beta/models") {
1319
- serveGeminiModels(res);
1320
- return;
1321
- }
1322
-
1323
- if (method === "POST" && pathname === "/v1/chat/completions") {
1324
- handleOpenAIChatCompletions(req, res, rotator).catch((err) => {
1325
- log(`OpenAI compat error: ${err}`, rotator, "error");
1326
- if (!res.headersSent) res.writeHead(500, { "Content-Type": "application/json" });
1327
- res.end(JSON.stringify({ error: { message: "Internal OpenAI compat error", type: "server_error" } }));
1328
- });
1329
- return;
1330
- }
1331
-
1332
- if (method === "POST" && pathname === "/v1/responses") {
1333
- handleOpenAIResponsesCreate(req, res, rotator).catch((err) => {
1334
- log(`OpenAI responses compat error: ${err}`, rotator, "error");
1335
- if (!res.headersSent) res.writeHead(500, { "Content-Type": "application/json" });
1336
- res.end(JSON.stringify({ error: { message: "Internal OpenAI responses compat error", type: "server_error" } }));
1337
- });
1338
- return;
1339
- }
1340
-
1341
- const responseMatch = pathname.match(/^\/v1\/responses\/([^/]+)(?:\/(cancel|input_items))?$/);
1342
- if (responseMatch) {
1343
- const responseId = decodeURIComponent(responseMatch[1]);
1344
- const action = responseMatch[2] || "";
1345
- if (method === "GET" && !action) return handleOpenAIResponsesRetrieve(req, res, responseId);
1346
- if (method === "DELETE" && !action) return handleOpenAIResponsesDelete(req, res, responseId);
1347
- if (method === "POST" && action === "cancel") return handleOpenAIResponsesCancel(req, res, responseId);
1348
- if (method === "GET" && action === "input_items") return handleOpenAIResponsesInputItems(req, res, responseId);
1349
- }
1350
-
1351
- // Anthropic-compatible adapter route (additive; does not affect native v1internal route)
1352
- if (method === "POST" && pathname === "/v1/messages") {
1353
- handleAnthropicMessages(req, res, rotator).catch((err) => {
1354
- log(`Anthropic compat error: ${err}`, rotator, "error");
1355
- if (!res.headersSent) res.writeHead(500, { "Content-Type": "application/json" });
1356
- res.end(JSON.stringify({ type: "error", error: { type: "server_error", message: "Internal Anthropic compat error" } }));
1357
- });
1358
- return;
1359
- }
1360
-
1361
- if (method === "POST" && /\/v1beta\/models\/.+:(generateContent|streamGenerateContent)$/.test(pathname)) {
1362
- handleGeminiGenerateContent(req, res, rotator).catch((err) => {
1363
- log(`Gemini compat error: ${err}`, rotator, "error");
1364
- if (!res.headersSent) res.writeHead(500, { "Content-Type": "application/json" });
1365
- res.end(JSON.stringify({ error: { message: "Internal Gemini compat error", status: "INTERNAL" } }));
1366
- });
1367
- return;
1368
- }
1369
-
1370
- // Proxy route
1371
- if (method === "POST" && url.includes("v1internal")) {
1372
- handleProxyRequest(req, res, rotator, scheduleSseBroadcast).catch((err) => {
1373
- log(`Unhandled error: ${err}`, rotator, "error");
1374
- if (!res.headersSent) {
1375
- res.writeHead(500, { "Content-Type": "application/json" });
1376
- }
1377
- res.end(JSON.stringify({ error: "Internal proxy error" }));
1378
- });
1379
- return;
1380
- }
1381
-
1382
- res.writeHead(404, { "Content-Type": "application/json" });
1383
- res.end(JSON.stringify({ error: "Not found" }));
1384
- });
1385
-
1386
- server.listen(port, bindHost, () => {
1387
- log(`Listening on ${bindHost}:${port}`, rotator);
1388
- log(`Dashboard: http://localhost:${port}/dashboard`, rotator);
1389
- log(`Hosted login: http://localhost:${port}/login`, rotator);
1390
- });
1455
+ export function startProxy(
1456
+ rotator: AccountRotator,
1457
+ port: number,
1458
+ bindHost = "0.0.0.0",
1459
+ ): void {
1460
+ startVersionChecker();
1461
+ startNotificationPoller();
1462
+ const sseClients = new Set<ServerResponse>();
1463
+ let sseBroadcastTimer: ReturnType<typeof setTimeout> | null = null;
1464
+ const SSE_THROTTLE_MS = 1000; // max 1 push/second
1465
+
1466
+ const scheduleSseBroadcast = (): void => {
1467
+ if (sseBroadcastTimer) return; // already scheduled
1468
+ sseBroadcastTimer = setTimeout(() => {
1469
+ sseBroadcastTimer = null;
1470
+ if (sseClients.size === 0) return;
1471
+ const data = JSON.stringify(rotator.getStatus());
1472
+ for (const client of sseClients) {
1473
+ try {
1474
+ client.write(`data: ${data}\n\n`);
1475
+ } catch {
1476
+ sseClients.delete(client);
1477
+ }
1478
+ }
1479
+ }, SSE_THROTTLE_MS);
1480
+ };
1481
+
1482
+ // Hook into rotator state changes to trigger SSE
1483
+ const origSaveState = rotator.saveState.bind(rotator);
1484
+ rotator.saveState = (): void => {
1485
+ origSaveState();
1486
+ scheduleSseBroadcast();
1487
+ };
1488
+
1489
+ const server = createServer((req, res) => {
1490
+ const method = req.method?.toUpperCase();
1491
+ const url = req.url || "";
1492
+ const pathname = url.split("?")[0];
1493
+
1494
+ if (method === "GET" && (pathname === "/" || pathname === "/dashboard")) {
1495
+ if (!requireAdmin(req, res)) return;
1496
+ trackFeature("dashboard");
1497
+ serveDashboard(res);
1498
+ return;
1499
+ }
1500
+
1501
+ if (method === "GET" && pathname === "/static/dashboard.css") {
1502
+ serveStaticCss(res);
1503
+ return;
1504
+ }
1505
+
1506
+ if (method === "GET" && pathname === "/static/dashboard.js") {
1507
+ serveStaticJs(res);
1508
+ return;
1509
+ }
1510
+
1511
+ if (method === "GET" && pathname === "/login") {
1512
+ if (!requireAdmin(req, res)) return;
1513
+ trackFeature("hostedLogin");
1514
+ serveLoginLanding(res);
1515
+ return;
1516
+ }
1517
+
1518
+ if (method === "GET" && pathname === "/login-cli") {
1519
+ if (!requireAdmin(req, res)) return;
1520
+ trackFeature("cliLogin");
1521
+ serveCliLogin(res);
1522
+ return;
1523
+ }
1524
+
1525
+ if (method === "POST" && pathname === "/api/cli-login") {
1526
+ if (!requireAdmin(req, res)) return;
1527
+ handleCliLoginApi(req, res, rotator).catch((err) => {
1528
+ log(`CLI login error: ${err}`, rotator, "error");
1529
+ if (!res.headersSent) {
1530
+ res.writeHead(500, { "Content-Type": "application/json" });
1531
+ }
1532
+ res.end(JSON.stringify({ ok: false, error: "Internal login error" }));
1533
+ });
1534
+ return;
1535
+ }
1536
+
1537
+ if (method === "GET" && pathname === "/auth/antigravity/start") {
1538
+ if (!requireAdmin(req, res)) return;
1539
+ startHostedLogin(req, res);
1540
+ return;
1541
+ }
1542
+
1543
+ if (method === "GET" && pathname === "/auth/antigravity/callback") {
1544
+ if (!requireAdmin(req, res)) return;
1545
+ handleHostedCallback(req, res, rotator).catch((err) => {
1546
+ log(`Hosted callback error: ${err}`, rotator, "error");
1547
+ if (!res.headersSent) {
1548
+ res.writeHead(500, { "Content-Type": "text/html; charset=utf-8" });
1549
+ }
1550
+ res.end("<h1>Internal login error</h1>");
1551
+ });
1552
+ return;
1553
+ }
1554
+
1555
+ if (method === "GET" && pathname === "/api/status") {
1556
+ if (!requireAdmin(req, res)) return;
1557
+ serveStatusApi(res, rotator);
1558
+ return;
1559
+ }
1560
+
1561
+ if (method === "GET" && pathname === "/api/config") {
1562
+ if (!requireAdmin(req, res)) return;
1563
+ serveConfigApi(res, rotator);
1564
+ return;
1565
+ }
1566
+
1567
+ if (method === "GET" && pathname === "/api/config/export") {
1568
+ if (!requireAdmin(req, res)) return;
1569
+ serveConfigExportApi(res, rotator);
1570
+ return;
1571
+ }
1572
+
1573
+ if (
1574
+ (method === "PUT" && pathname === "/api/config") ||
1575
+ (method === "POST" && pathname === "/api/config/import")
1576
+ ) {
1577
+ if (!requireAdmin(req, res)) return;
1578
+ readJsonRequest(req)
1579
+ .then((parsed) => {
1580
+ const candidate =
1581
+ parsed &&
1582
+ typeof parsed === "object" &&
1583
+ "config" in (parsed as Record<string, unknown>)
1584
+ ? (parsed as { config: unknown }).config
1585
+ : parsed;
1586
+ const validation = validateConfig(candidate);
1587
+ if (!validation.ok || !validation.value) {
1588
+ res.writeHead(400, { "Content-Type": "application/json" });
1589
+ res.end(JSON.stringify({ ok: false, errors: validation.errors }));
1590
+ return;
1591
+ }
1592
+ serveConfigImportApi(
1593
+ res,
1594
+ rotator,
1595
+ applyConfigDefaults(validation.value),
1596
+ );
1597
+ })
1598
+ .catch((err) => {
1599
+ res.writeHead(err instanceof PayloadTooLargeError ? 413 : 400, {
1600
+ "Content-Type": "application/json",
1601
+ });
1602
+ res.end(
1603
+ JSON.stringify({
1604
+ ok: false,
1605
+ error: err instanceof Error ? err.message : String(err),
1606
+ }),
1607
+ );
1608
+ });
1609
+ return;
1610
+ }
1611
+
1612
+ if (method === "GET" && pathname === "/api/events") {
1613
+ if (!requireAdmin(req, res)) return;
1614
+ // Server-Sent Events for live dashboard
1615
+ res.writeHead(200, {
1616
+ "Content-Type": "text/event-stream",
1617
+ "Cache-Control": "no-cache",
1618
+ Connection: "keep-alive",
1619
+ "X-Accel-Buffering": "no",
1620
+ });
1621
+ res.write(":\n\n"); // keepalive comment
1622
+ sseClients.add(res);
1623
+ req.on("close", () => sseClients.delete(res));
1624
+ return;
1625
+ }
1626
+
1627
+ if (method === "POST" && url.startsWith("/api/enable/")) {
1628
+ if (!requireAdmin(req, res)) return;
1629
+ const email = decodeURIComponent(url.slice("/api/enable/".length));
1630
+ serveEnableApi(res, rotator, email);
1631
+ return;
1632
+ }
1633
+
1634
+ if (method === "POST" && url.startsWith("/api/disable/")) {
1635
+ if (!requireAdmin(req, res)) return;
1636
+ const email = decodeURIComponent(url.slice("/api/disable/".length));
1637
+ serveDisableApi(res, rotator, email);
1638
+ return;
1639
+ }
1640
+
1641
+ if (method === "POST" && url.startsWith("/api/quarantine/")) {
1642
+ if (!requireAdmin(req, res)) return;
1643
+ const email = decodeURIComponent(url.slice("/api/quarantine/".length));
1644
+ serveQuarantineApi(res, rotator, email);
1645
+ return;
1646
+ }
1647
+
1648
+ if (method === "POST" && url.startsWith("/api/restore/")) {
1649
+ if (!requireAdmin(req, res)) return;
1650
+ const email = decodeURIComponent(url.slice("/api/restore/".length));
1651
+ serveRestoreApi(res, rotator, email);
1652
+ return;
1653
+ }
1654
+
1655
+ if (method === "POST" && url.startsWith("/api/remove-account/")) {
1656
+ if (!requireAdmin(req, res)) return;
1657
+ const email = decodeURIComponent(
1658
+ url.slice("/api/remove-account/".length),
1659
+ );
1660
+ serveRemoveAccountApi(res, rotator, email);
1661
+ return;
1662
+ }
1663
+
1664
+ if (method === "POST" && url.startsWith("/api/set-tier/")) {
1665
+ if (!requireAdmin(req, res)) return;
1666
+ const rest = url.slice("/api/set-tier/".length);
1667
+ const lastSlash = rest.lastIndexOf("/");
1668
+ if (lastSlash < 0) {
1669
+ res.writeHead(400, { "Content-Type": "application/json" });
1670
+ res.end(
1671
+ JSON.stringify({
1672
+ ok: false,
1673
+ error: "Missing tier. Use /api/set-tier/:email/:tier",
1674
+ }),
1675
+ );
1676
+ return;
1677
+ }
1678
+ const email = decodeURIComponent(rest.slice(0, lastSlash));
1679
+ const tier = decodeURIComponent(rest.slice(lastSlash + 1));
1680
+ serveSetTierApi(res, rotator, email, tier);
1681
+ return;
1682
+ }
1683
+
1684
+ if (method === "POST" && url.startsWith("/api/clear-inflight/")) {
1685
+ if (!requireAdmin(req, res)) return;
1686
+ const rest = url.slice("/api/clear-inflight/".length);
1687
+ const firstSlash = rest.indexOf("/");
1688
+ const email = decodeURIComponent(
1689
+ firstSlash >= 0 ? rest.slice(0, firstSlash) : rest,
1690
+ );
1691
+ const modelKey =
1692
+ firstSlash >= 0
1693
+ ? decodeURIComponent(rest.slice(firstSlash + 1))
1694
+ : undefined;
1695
+ serveClearInFlightApi(res, rotator, email, modelKey);
1696
+ return;
1697
+ }
1698
+
1699
+ if (method === "POST" && url.startsWith("/api/clear-breaker/")) {
1700
+ if (!requireAdmin(req, res)) return;
1701
+ const rest = url.slice("/api/clear-breaker/".length);
1702
+ const modelKey =
1703
+ rest && rest !== "all" ? decodeURIComponent(rest) : undefined;
1704
+ serveClearBreakerApi(res, rotator, modelKey);
1705
+ return;
1706
+ }
1707
+
1708
+ if (
1709
+ method === "POST" &&
1710
+ (url === "/api/settings/fresh-window-starts/on" ||
1711
+ url === "/api/settings/fresh-window-starts/off")
1712
+ ) {
1713
+ if (!requireAdmin(req, res)) return;
1714
+ trackFeature("freshWindowToggle");
1715
+ serveFreshWindowStartsApi(res, rotator, url.endsWith("/on"));
1716
+ return;
1717
+ }
1718
+
1719
+ if (
1720
+ method === "POST" &&
1721
+ url.startsWith("/api/account-fresh-window-starts/") &&
1722
+ (url.endsWith("/on") || url.endsWith("/off"))
1723
+ ) {
1724
+ if (!requireAdmin(req, res)) return;
1725
+ const rest = url.slice("/api/account-fresh-window-starts/".length);
1726
+ const lastSlash = rest.lastIndexOf("/");
1727
+ const email = decodeURIComponent(rest.slice(0, lastSlash));
1728
+ const enabled = rest.slice(lastSlash + 1) === "on";
1729
+ serveAccountFreshWindowStartsApi(res, rotator, email, enabled);
1730
+ return;
1731
+ }
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
+
1760
+ if (method === "POST" && pathname === "/api/self-update") {
1761
+ if (!requireAdmin(req, res)) return;
1762
+ trackFeature("selfUpdate");
1763
+ try {
1764
+ const result = performSelfUpdate();
1765
+ res.writeHead(result.ok ? 200 : 500, {
1766
+ "Content-Type": "application/json",
1767
+ });
1768
+ res.end(JSON.stringify(result));
1769
+ } catch (err) {
1770
+ res.writeHead(500, { "Content-Type": "application/json" });
1771
+ res.end(JSON.stringify({ ok: false, message: String(err) }));
1772
+ }
1773
+ return;
1774
+ }
1775
+
1776
+ // OpenAI-compatible adapter route (additive; does not affect native v1internal route)
1777
+ if (method === "GET" && pathname === "/v1/models") {
1778
+ serveOpenAIModels(res);
1779
+ return;
1780
+ }
1781
+
1782
+ if (method === "GET" && pathname === "/v1beta/models") {
1783
+ serveGeminiModels(res);
1784
+ return;
1785
+ }
1786
+
1787
+ if (method === "POST" && pathname === "/v1/chat/completions") {
1788
+ handleOpenAIChatCompletions(req, res, rotator).catch((err) => {
1789
+ log(`OpenAI compat error: ${err}`, rotator, "error");
1790
+ if (!res.headersSent)
1791
+ res.writeHead(500, { "Content-Type": "application/json" });
1792
+ res.end(
1793
+ JSON.stringify({
1794
+ error: {
1795
+ message: "Internal OpenAI compat error",
1796
+ type: "server_error",
1797
+ },
1798
+ }),
1799
+ );
1800
+ });
1801
+ return;
1802
+ }
1803
+
1804
+ if (method === "POST" && pathname === "/v1/responses") {
1805
+ handleOpenAIResponsesCreate(req, res, rotator).catch((err) => {
1806
+ log(`OpenAI responses compat error: ${err}`, rotator, "error");
1807
+ if (!res.headersSent)
1808
+ res.writeHead(500, { "Content-Type": "application/json" });
1809
+ res.end(
1810
+ JSON.stringify({
1811
+ error: {
1812
+ message: "Internal OpenAI responses compat error",
1813
+ type: "server_error",
1814
+ },
1815
+ }),
1816
+ );
1817
+ });
1818
+ return;
1819
+ }
1820
+
1821
+ const responseMatch = pathname.match(
1822
+ /^\/v1\/responses\/([^/]+)(?:\/(cancel|input_items))?$/,
1823
+ );
1824
+ if (responseMatch) {
1825
+ const responseId = decodeURIComponent(responseMatch[1]);
1826
+ const action = responseMatch[2] || "";
1827
+ if (method === "GET" && !action)
1828
+ return handleOpenAIResponsesRetrieve(req, res, responseId);
1829
+ if (method === "DELETE" && !action)
1830
+ return handleOpenAIResponsesDelete(req, res, responseId);
1831
+ if (method === "POST" && action === "cancel")
1832
+ return handleOpenAIResponsesCancel(req, res, responseId);
1833
+ if (method === "GET" && action === "input_items")
1834
+ return handleOpenAIResponsesInputItems(req, res, responseId);
1835
+ }
1836
+
1837
+ // Anthropic-compatible adapter route (additive; does not affect native v1internal route)
1838
+ if (method === "POST" && pathname === "/v1/messages") {
1839
+ handleAnthropicMessages(req, res, rotator).catch((err) => {
1840
+ log(`Anthropic compat error: ${err}`, rotator, "error");
1841
+ if (!res.headersSent)
1842
+ res.writeHead(500, { "Content-Type": "application/json" });
1843
+ res.end(
1844
+ JSON.stringify({
1845
+ type: "error",
1846
+ error: {
1847
+ type: "server_error",
1848
+ message: "Internal Anthropic compat error",
1849
+ },
1850
+ }),
1851
+ );
1852
+ });
1853
+ return;
1854
+ }
1855
+
1856
+ if (
1857
+ method === "POST" &&
1858
+ /\/v1beta\/models\/.+:(generateContent|streamGenerateContent)$/.test(
1859
+ pathname,
1860
+ )
1861
+ ) {
1862
+ handleGeminiGenerateContent(req, res, rotator).catch((err) => {
1863
+ log(`Gemini compat error: ${err}`, rotator, "error");
1864
+ if (!res.headersSent)
1865
+ res.writeHead(500, { "Content-Type": "application/json" });
1866
+ res.end(
1867
+ JSON.stringify({
1868
+ error: {
1869
+ message: "Internal Gemini compat error",
1870
+ status: "INTERNAL",
1871
+ },
1872
+ }),
1873
+ );
1874
+ });
1875
+ return;
1876
+ }
1877
+
1878
+ // Proxy route
1879
+ if (method === "POST" && url.includes("v1internal")) {
1880
+ handleProxyRequest(req, res, rotator, scheduleSseBroadcast).catch(
1881
+ (err) => {
1882
+ log(`Unhandled error: ${err}`, rotator, "error");
1883
+ if (!res.headersSent) {
1884
+ res.writeHead(500, { "Content-Type": "application/json" });
1885
+ }
1886
+ res.end(JSON.stringify({ error: "Internal proxy error" }));
1887
+ },
1888
+ );
1889
+ return;
1890
+ }
1891
+
1892
+ res.writeHead(404, { "Content-Type": "application/json" });
1893
+ res.end(JSON.stringify({ error: "Not found" }));
1894
+ });
1895
+
1896
+ server.listen(port, bindHost, () => {
1897
+ log(`Listening on ${bindHost}:${port}`, rotator);
1898
+ log(`Dashboard: http://localhost:${port}/dashboard`, rotator);
1899
+ log(`Hosted login: http://localhost:${port}/login`, rotator);
1900
+ });
1391
1901
  }