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/CHANGELOG.md +11 -0
- package/README.md +9 -1
- package/package.json +1 -1
- package/src/account-store.ts +109 -168
- package/src/admin-auth.ts +74 -83
- package/src/cli.ts +62 -46
- package/src/config-defaults.ts +64 -0
- package/src/config-storage.ts +21 -0
- package/src/dashboard.ts +310 -3193
- package/src/db-store.ts +155 -0
- package/src/doctor.ts +96 -78
- package/src/index.ts +200 -149
- package/src/onboarding.ts +352 -113
- package/src/paths.ts +34 -31
- package/src/proxy.ts +1751 -1270
- package/src/responses-store.ts +150 -178
- package/src/rotator.ts +3041 -2310
- package/src/settings-repository.ts +314 -0
- package/src/static/dashboard.css +1224 -0
- package/src/static/dashboard.js +1834 -0
- package/src/types.ts +501 -397
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 {
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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 {
|
|
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
|
-
|
|
86
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
65
87
|
}
|
|
66
88
|
|
|
67
89
|
export interface RequestBody {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
80
|
-
|
|
101
|
+
response: Response;
|
|
102
|
+
endpoint: string;
|
|
81
103
|
}
|
|
82
104
|
|
|
83
105
|
export interface RotationAttemptContext {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
159
|
+
response: Response,
|
|
160
|
+
endpoint: string,
|
|
161
|
+
account: AccountRuntime,
|
|
162
|
+
model: string,
|
|
163
|
+
modelKey: string,
|
|
125
164
|
): Promise<UpstreamAction> {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
240
|
+
return Math.min(ms, MAX_COOLDOWN_MS);
|
|
179
241
|
}
|
|
180
242
|
|
|
181
243
|
function formatError(err: unknown): string {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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(
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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(
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
327
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
): Promise<{
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
|
|
529
|
+
account: AccountRuntime,
|
|
530
|
+
body: RequestBody,
|
|
531
|
+
originalHeaders: Record<string, string>,
|
|
436
532
|
): Promise<ForwardedResponse> {
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
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(
|
|
763
|
-
|
|
764
|
-
|
|
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
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
989
|
+
req: IncomingMessage,
|
|
990
|
+
res: ServerResponse,
|
|
991
|
+
rotator: AccountRotator,
|
|
992
|
+
onComplete?: () => void,
|
|
775
993
|
): Promise<void> {
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
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(
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
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(
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
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
|
}
|