pi-antigravity-rotator 2.2.2 → 2.3.0

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