pi-antigravity-rotator 2.3.0 → 2.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [2.3.2] - 2026-07-05
6
+
7
+ ### Fixed
8
+ - **Assistant History**: Preserve final Responses assistant history.
9
+ - **Claude Formatting**: Prevent Claude assistant prefill payloads and fix orphan Claude tool results to comply with API formatting constraints.
10
+ - **Resource Leaks**: Release compat streams on client disconnect to prevent memory leaks.
11
+ - **Security Audit**: Addressed audit hardening findings and fixed audit issues around OAuth and admin routes.
12
+
13
+
14
+ ## [2.3.1] - 2026-06-30
15
+
16
+
17
+ ### Added
18
+ - **Manual & Auto-Warmup (Kickstart) Timers**: Added ability to kickstart idle/fresh timers. A minimal request (`maxOutputTokens=1`) is sent to the cheapest model in the pool to start the 5h/7d reset window.
19
+ - **Auto-Warmup Toggle**: Global operator toggle to automatically warm up idle pools during the quota polling cycle.
20
+ - **Admin Endpoints**: Added `POST /api/kickstart/:email`, `POST /api/kickstart/:email/:modelKey`, and `POST /api/settings/auto-warmup/on|off`.
21
+ - **Dashboard UI**: Added "Start Idle Timers" button on account cards when idle pools are detected, and a global "Enable/Disable Auto-Warmup" button in operator controls. Added telemetry cost estimates for `gemini-3.5-flash`.
22
+
23
+ ### Fixed
24
+ - Included rolling idle pools (100% quota with a fresh resetTime) as valid kickstart targets.
25
+ - Isolated tests from the production config directory (`PI_ROTATOR_DIR=/tmp/pi-rotator-test`) to prevent config corruption during `npm test`.
26
+
27
+ ### Improved
28
+ - **Accurate Quota Forecast**: Replaced the simple arithmetic average of pool quota with a weighted calculation based on each account's Tier capacity (Ultra/Pro/Plus/Free). The Dashboard's time remaining and percentage estimates are now mathematically accurate for mixed-tier deployments. (PR [#14](https://github.com/tuxevil/pi-antigravity-rotator/pull/14) by [@josenicomaia](https://github.com/josenicomaia))
29
+
5
30
  ## [2.3.0] - 2026-06-22
6
31
 
7
32
  ### Added
package/README.md CHANGED
@@ -68,9 +68,10 @@ To donate a quota-enabled account safely:
68
68
  - **Safer 429 handling** -- On provider `429`, stops the current request and avoids cascade-burning sibling accounts
69
69
  - **Concurrency guardrails** -- Limits each account to one in-flight request by default to avoid bursty pressure
70
70
  - **Operator fresh-window controls** -- You can block new `fresh` window starts globally, then selectively allow specific accounts to override that policy
71
+ - **Auto-Warmup & Kickstart** -- Automatically detects idle quota pools (100% quota with a full reset time) and sends a minimal request to kickstart the 5h/7d window
71
72
  - **Protective pause** -- Pauses all routing for several hours after serious ToS/abuse-style flags so the rest of the pool is not burned
72
73
  - **Token auto-refresh** -- Tokens are refreshed automatically before expiry; no manual management
73
- - **Endpoint cascade** -- Tries daily, autopush, and prod API endpoints for resilience
74
+ - **Endpoint fallback support** -- Uses the verified daily Cloud Code Assist endpoint by default; the forwarder supports endpoint cascading if additional verified upstream endpoints are configured in future releases
74
75
  - **Advanced Telemetry & Statistics** -- Track exactly how much USD you save compared to a paid API plan, predict quota depletion with the Forecast grid, view Latency tracking (p50/p95), and explore 60-day historical usage heatmaps
75
76
  - **Web dashboard** -- Real-time view of model routing table, per-account quota bars with per-model timers, and flagged account alerts
76
77
  - **Auto-update notifications** -- The dashboard checks npm for new releases every 30 minutes and shows a banner with one-click update when a newer version is available
@@ -79,6 +80,8 @@ To donate a quota-enabled account safely:
79
80
 
80
81
  ## Quick Start
81
82
 
83
+ Requirements for npm/source installs: Node.js 22 or newer. The published package runs the TypeScript sources through `tsx` at runtime, so `tsx` is intentionally listed as a production dependency. The Docker image uses Node 22.
84
+
82
85
  ### Option A: Install from npm
83
86
 
84
87
  ```bash
@@ -108,10 +111,11 @@ npm start
108
111
  ### Option C: Docker
109
112
 
110
113
  ```bash
114
+ mkdir -p docker-data
111
115
  docker compose up -d
112
116
  ```
113
117
 
114
- The included compose file persists runtime data under `./docker-data` and sets `PI_ROTATOR_DIR=/data`.
118
+ The Docker image installs production dependencies only and runs as the non-root `node` user (UID/GID 1000). The included compose file persists runtime data under `./docker-data`, sets `PI_ROTATOR_DIR=/data`, and publishes the proxy on `127.0.0.1:51200` by default. On Linux, make sure `docker-data` is writable by UID 1000 if your host user differs. If you need LAN access, change the compose port mapping to `51200:51200` only on a trusted network or behind a firewall/reverse proxy.
115
119
 
116
120
  ## Adding Accounts
117
121
 
@@ -151,7 +155,7 @@ If login fails at project discovery:
151
155
 
152
156
  ## Dashboard
153
157
 
154
- After starting the proxy, open `http://localhost:51200/dashboard` or `http://<your-server-ip>:51200/dashboard` from any machine on the same network (the proxy binds to `0.0.0.0`).
158
+ After starting the proxy, open `http://localhost:51200/dashboard`. Native npm starts bind to `0.0.0.0` unless you set `PI_ROTATOR_BIND_HOST` or `bindHost`; the Docker compose example publishes only `127.0.0.1:51200` on the host by default. Use `http://<your-server-ip>:51200/dashboard` only after deliberately exposing the port on a trusted LAN, firewall, or reverse proxy.
155
159
 
156
160
  If `PI_ROTATOR_ADMIN_TOKEN` is unset, the proxy automatically generates a cryptographically secure token on first run and saves it to a `.admin-token` file in the root directory. This token will be printed to the console on first startup. You must append `?token=<your-token>` to the dashboard URL to access it, or set `PI_ROTATOR_ADMIN_TOKEN` in your environment to override it.
157
161
 
@@ -161,7 +165,7 @@ The dashboard shows:
161
165
  - **Token Usage & Savings** -- Interactive chart (`1h`, `2h`, `4h`, `8h`, `12h`, `1d`, `7d`, `1m`) showing token consumption by model, with estimated USD savings and `CSV`/`JSON` export options.
162
166
  - **Activity Heatmap** -- 60-day responsive GitHub-style contribution grid showing request intensity hour by hour.
163
167
  - **Latency (p50/p95)** -- Real-time median and 95th percentile tracking for Time-to-First-Byte (TTFB) and Total Duration per model.
164
- - **Quota Forecast** -- Predictive modeling showing when each model's quota will run out based on the current requests/hour burn rate.
168
+ - **Quota Forecast** -- Predictive modeling showing when each model's quota will run out, using **tier-weighted capacity** algorithms for pinpoint accuracy across mixed-tier pools.
165
169
  - **Searchable Request Log** -- Live feed of the last 200 requests with exact timestamps, models, masked accounts, status codes, and latency.
166
170
  - **Account Cards** -- Sorted by total quota. Shows status (`active`, `ready`, `cooldown`, `flagged`, `disabled`), quota bars with timers, and precise error messages.
167
171
  - **Web-based Account Management** -- Add, remove, and manage account credentials and tier configurations directly from the UI without touching JSON files.
@@ -185,7 +189,7 @@ Agent 3 (Flash) ---> localhost:51200 ---> Account A
185
189
  2. The proxy resolves the model to a quota key (e.g., `gemini-3.1-pro`)
186
190
  3. The best available account for that specific model is selected
187
191
  4. The `Authorization` header and `project` field are swapped with real credentials
188
- 5. The request is forwarded (trying daily, autopush, then prod endpoints)
192
+ 5. The request is forwarded to the configured Antigravity endpoint (the verified daily endpoint by default; cascade applies only if additional verified endpoints are configured)
189
193
  6. The SSE response streams back to the agent transparently
190
194
 
191
195
  ### Per-Model Account Selection
@@ -243,7 +247,7 @@ The proxy detects blocked/suspended accounts at three levels:
243
247
 
244
248
  1. **Quota API check** (initial poll + every poll) -- If the quota API returns `401` or `403`, the account is immediately flagged.
245
249
 
246
- 2. **API 401** (on request) -- If the prod endpoint rejects the token with `401 UNAUTHENTICATED`, the account is flagged.
250
+ 2. **API 401** (on request) -- If the upstream endpoint rejects the token with `401 UNAUTHENTICATED`, the account is flagged.
247
251
 
248
252
  3. **API 403** (on request) -- If the response body contains enforcement keywords such as `infring`, `suspend`, `abus`, `terminat`, `violat`, `banned`, `policy`, `forbidden`, or `verif`, the account is flagged.
249
253
 
@@ -310,13 +314,13 @@ pi-antigravity-rotator start --config-dir /path/to/config
310
314
 
311
315
  New v2.0 config fields:
312
316
 
313
- - `bindHost`: interface to bind on. Default: `0.0.0.0`.
317
+ - `bindHost`: interface to bind on. Default: `0.0.0.0`. For local-only use, set `PI_ROTATOR_BIND_HOST=127.0.0.1` or `"bindHost": "127.0.0.1"`.
314
318
  - `routingPolicy`: current default is `timer-first`. Optional values now include `tier-first`, `quota-first`, and `hybrid`.
315
319
  - `tokenBucketEnabled`: enables the local per-account request bucket used by `hybrid`. Default: `false`.
316
320
  - `tokenBucketMaxTokens`: bucket capacity when enabled. Default: `50`.
317
321
  - `tokenBucketRefillPerMinute`: refill speed when enabled. Default: `6`.
318
322
  - `tokenBucketInitialTokens`: startup fill level when enabled. Default: `50`.
319
- - `accounts[].tier`: optional `ultra`, `pro`, `free`, or `unknown`.
323
+ - `accounts[].tier`: optional `ultra`, `pro`, `plus`, `free`, or `unknown`.
320
324
 
321
325
  ## Doctor
322
326
 
@@ -418,7 +422,7 @@ Login now fails if Google does not return a project ID. No shared fallback.
418
422
  | `POST` | `/v1/chat/completions` | OpenAI-compatible non-streaming chat adapter |
419
423
  | `POST` | `/v1/messages` | Anthropic-compatible non-streaming messages adapter |
420
424
 
421
- Dashboard and internal `/api/*` requests must include either `Authorization: Bearer <token>`, `X-Rotator-Admin-Token: <token>`, or `?token=<token>` for browser dashboard access. The native pi proxy endpoint and compatibility adapters (`/v1/*`) remain unauthenticated by design, so your AI agents and existing clients will keep working without requiring a token. Put this service behind a trusted local boundary if exposing beyond localhost/LAN.
425
+ Dashboard and internal `/api/*` requests must include either `Authorization: Bearer <token>`, `X-Rotator-Admin-Token: <token>`, or `?token=<token>` for browser dashboard access. The native pi proxy endpoint and compatibility adapters (`/v1/*`) remain unauthenticated by design, so your AI agents and existing clients will keep working without requiring a token. `PI_ROTATOR_ADMIN_TOKEN` does **not** protect those proxy routes. Put this service behind a trusted local boundary, firewall, or reverse proxy if exposing beyond localhost/LAN.
422
426
 
423
427
  ### Compatibility Adapters
424
428
 
@@ -530,11 +534,12 @@ To connect Codex to your local rotator:
530
534
 
531
535
  ```bash
532
536
  npm run typecheck
537
+ npm run typecheck:test
533
538
  npm test
534
539
  npm run check
535
540
  ```
536
541
 
537
- The test suite covers model resolution contracts and dashboard render/syntax smoke checks.
542
+ `npm run typecheck` checks runtime source under `src/`; `npm run typecheck:test` also checks test helpers and mocks under `test/`. The test suite covers model resolution contracts and dashboard render/syntax smoke checks.
538
543
 
539
544
  ## Running as a Service
540
545
 
@@ -602,15 +607,16 @@ The dashboard shows both raw `Flag Events` and deduped `Unique Flag Incidents` s
602
607
 
603
608
  Flag data is the most valuable signal. It lets us study what behavior patterns lead to flags and improve the rotation algorithm to avoid them — benefiting everyone.
604
609
 
605
- ### What is NEVER collected
610
+ ### What is not included in telemetry payloads
606
611
 
607
612
  - ❌ Email addresses
608
613
  - ❌ OAuth tokens or API keys
609
- - ❌ IP addresses
610
614
  - ❌ Google project IDs
611
615
  - ❌ Request/response bodies
612
616
  - ❌ Error message text (only which known keywords matched)
613
617
 
618
+ IP addresses are not part of the telemetry JSON payload and are not written to telemetry event files. The receiver and network path can still observe the source IP for transport and in-memory rate limiting, and the default telemetry endpoint uses plain HTTP. Set `PI_ROTATOR_TELEMETRY=off` to disable telemetry, or set `PI_ROTATOR_TELEMETRY_URL` to an HTTPS endpoint you control.
619
+
614
620
  ### Endpoint
615
621
 
616
622
  Telemetry posts to:
package/package.json CHANGED
@@ -1,20 +1,24 @@
1
1
  {
2
2
  "name": "pi-antigravity-rotator",
3
- "version": "2.3.0",
3
+ "version": "2.3.2",
4
4
  "description": "Multi-account rotation proxy for Google Antigravity with per-model routing, real-time quota tracking, and infringement detection",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "pi-antigravity-rotator": "bin/pi-antigravity-rotator.js"
9
9
  },
10
+ "engines": {
11
+ "node": ">=22.0.0"
12
+ },
10
13
  "scripts": {
11
14
  "start": "tsx src/cli.ts start",
12
15
  "login": "tsx src/cli.ts login",
13
16
  "typecheck": "tsc --noEmit",
14
- "test": "node --import tsx/esm --test \"test/**/*.test.ts\"",
17
+ "typecheck:test": "tsc --project tsconfig.test.json",
18
+ "test": "PI_ROTATOR_DIR=/tmp/pi-rotator-test node --import tsx/esm --test \"test/**/*.test.ts\"",
15
19
  "lint": "eslint src test",
16
- "coverage": "c8 --reporter=text --reporter=html npm test",
17
- "check": "npm run typecheck && npm test && npm run lint"
20
+ "coverage": "c8 --reporter=text --reporter=html --check-coverage --lines=60 --branches=50 --functions=60 --statements=60 npm test",
21
+ "check": "npm run typecheck && npm run typecheck:test && npm test && npm run lint"
18
22
  },
19
23
  "files": [
20
24
  "bin/",
@@ -43,15 +47,15 @@
43
47
  "author": "Sebastián Real (tuxevil)",
44
48
  "dependencies": {
45
49
  "pg": "^8.22.0",
46
- "tsx": "^4.19.0"
50
+ "tsx": "^4.22.4"
47
51
  },
48
52
  "devDependencies": {
49
53
  "@eslint/js": "^10.0.1",
50
- "@types/node": "^22.0.0",
54
+ "@types/node": "^22.20.0",
51
55
  "@types/pg": "^8.20.0",
52
56
  "c8": "^11.0.0",
53
- "eslint": "^10.5.0",
57
+ "eslint": "^10.6.0",
54
58
  "typescript": "^5.7.0",
55
- "typescript-eslint": "^8.61.1"
59
+ "typescript-eslint": "^8.62.1"
56
60
  }
57
61
  }
package/src/cli.ts CHANGED
@@ -35,14 +35,39 @@ switch (command) {
35
35
  break;
36
36
  }
37
37
  case "status": {
38
+ const { initDb, closeDb } = await import("./db-store.js");
39
+ const {
40
+ getConfiguredAdminToken,
41
+ readPersistedAdminToken,
42
+ setPersistedAdminToken,
43
+ } = await import("./admin-auth.js");
38
44
  try {
45
+ await initDb();
46
+ setPersistedAdminToken(readPersistedAdminToken());
47
+ const token = getConfiguredAdminToken();
39
48
  const port = 51200;
40
- const res = await fetch(`http://localhost:${port}/api/status`);
49
+ const res = await fetch(`http://localhost:${port}/api/status`, {
50
+ headers: token ? { "X-Rotator-Admin-Token": token } : {},
51
+ });
52
+ if (res.status === 401) {
53
+ console.error(
54
+ "Rotator status is protected and the local admin token was rejected.",
55
+ );
56
+ process.exitCode = 1;
57
+ break;
58
+ }
59
+ if (!res.ok) {
60
+ console.error(`Rotator status returned HTTP ${res.status}`);
61
+ process.exitCode = 1;
62
+ break;
63
+ }
41
64
  const data = await res.json();
42
65
  console.log(JSON.stringify(data, null, 2));
43
66
  } catch {
44
67
  console.error("Rotator is not running or unreachable on port 51200");
45
- process.exit(1);
68
+ process.exitCode = 1;
69
+ } finally {
70
+ await closeDb();
46
71
  }
47
72
  break;
48
73
  }
@@ -145,6 +145,7 @@ export interface CompatCompletion {
145
145
  thinkingText?: string; // Gemini thought blocks (thought: true), emitted as reasoning_content
146
146
  inputTokens: number;
147
147
  outputTokens: number;
148
+ firstByteMs?: number;
148
149
  responseId?: string;
149
150
  toolCalls?: OpenAIToolCall[];
150
151
  }
@@ -785,6 +786,8 @@ export function openAIToAntigravityBody(
785
786
  const isGeminiThinking = !isClaude && isThinking;
786
787
 
787
788
  const contents: GeminiContent[] = [];
789
+ // Claude requires every tool_result to immediately follow its tool_use turn.
790
+ let pendingClaudeToolCallIds = new Set<string>();
788
791
  for (let i = 0; i < conversationMessages.length; i++) {
789
792
  const msg = conversationMessages[i];
790
793
  if (msg.role === "assistant" || msg.role === "model") {
@@ -882,24 +885,39 @@ export function openAIToAntigravityBody(
882
885
  parts.push({ text: "..." });
883
886
  }
884
887
  if (parts.length > 0) {
888
+ const functionCallIds = new Set<string>();
889
+ for (const part of parts) {
890
+ if (!isRecord(part)) continue;
891
+ const functionCall = part.functionCall;
892
+ if (
893
+ isRecord(functionCall) &&
894
+ typeof functionCall.id === "string" &&
895
+ functionCall.id
896
+ ) {
897
+ functionCallIds.add(functionCall.id);
898
+ }
899
+ }
900
+ const hasFunctionCall = parts.some((p: any) => p.functionCall);
885
901
  if (isClaude) {
886
902
  const lastContent = contents[contents.length - 1];
887
903
  const prevHasFunctionCall =
888
904
  lastContent &&
889
905
  lastContent.role === "model" &&
890
906
  lastContent.parts.some((p: any) => p.functionCall);
891
- const hasFunctionCall = parts.some((p: any) => p.functionCall);
892
907
  if (prevHasFunctionCall && !hasFunctionCall) {
893
908
  // Skip
894
909
  } else if (hasFunctionCall) {
895
910
  const fcOnly = parts.filter((p: any) => p.functionCall);
896
911
  if (prevHasFunctionCall) {
897
912
  lastContent.parts.push(...fcOnly);
913
+ for (const id of functionCallIds) pendingClaudeToolCallIds.add(id);
898
914
  } else {
899
915
  contents.push({ role: "model", parts: fcOnly });
916
+ pendingClaudeToolCallIds = new Set(functionCallIds);
900
917
  }
901
918
  } else {
902
919
  contents.push({ role: "model", parts });
920
+ pendingClaudeToolCallIds.clear();
903
921
  }
904
922
  } else {
905
923
  contents.push({ role: "model", parts });
@@ -912,6 +930,12 @@ export function openAIToAntigravityBody(
912
930
  : extractText(msg.content);
913
931
  const fnName = msg.name || "unknown";
914
932
  const toolCallId = msg.tool_call_id;
933
+ if (
934
+ isClaude &&
935
+ (!toolCallId || !pendingClaudeToolCallIds.has(toolCallId))
936
+ ) {
937
+ continue;
938
+ }
915
939
  let responseData: unknown;
916
940
  try {
917
941
  const parsed = JSON.parse(responseText);
@@ -980,9 +1004,35 @@ export function openAIToAntigravityBody(
980
1004
  } else {
981
1005
  contents.push({ role: "user", parts: [fnResponsePart, ...toolImages] });
982
1006
  }
1007
+ if (isClaude && toolCallId) pendingClaudeToolCallIds.delete(toolCallId);
983
1008
  } else {
984
1009
  const msgParts = extractParts(msg.content);
985
- if (msgParts.length > 0) contents.push({ role: "user", parts: msgParts });
1010
+ if (msgParts.length > 0) {
1011
+ if (isClaude) pendingClaudeToolCallIds.clear();
1012
+ contents.push({ role: "user", parts: msgParts });
1013
+ }
1014
+ }
1015
+ }
1016
+
1017
+ if (isClaude) {
1018
+ while (contents.at(-1)?.role === "model") {
1019
+ const lastContent = contents.at(-1);
1020
+ const hasFunctionCall =
1021
+ lastContent?.parts.some((p: any) => isRecord(p) && p.functionCall) ??
1022
+ false;
1023
+ if (hasFunctionCall) {
1024
+ contents.pop();
1025
+ continue;
1026
+ }
1027
+ contents.push({
1028
+ role: "user",
1029
+ parts: [
1030
+ {
1031
+ text: "Continue from the previous assistant message. Do not repeat completed tool calls unless new user input asks for them.",
1032
+ },
1033
+ ],
1034
+ });
1035
+ break;
986
1036
  }
987
1037
  }
988
1038
 
@@ -1174,12 +1224,6 @@ export function convertAnthropicMessagesToOpenAI(
1174
1224
  const otherBlocks = blocks.filter(
1175
1225
  (b) => !isRecord(b) || b.type !== "tool_result",
1176
1226
  );
1177
- if (otherBlocks.length > 0) {
1178
- result.push({
1179
- role: "user",
1180
- content: otherBlocks as ChatMessage["content"],
1181
- });
1182
- }
1183
1227
  for (const tr of toolResults) {
1184
1228
  const content =
1185
1229
  typeof tr.content === "string"
@@ -1193,6 +1237,12 @@ export function convertAnthropicMessagesToOpenAI(
1193
1237
  tool_call_id: tr.tool_use_id as string,
1194
1238
  });
1195
1239
  }
1240
+ if (otherBlocks.length > 0) {
1241
+ result.push({
1242
+ role: "user",
1243
+ content: otherBlocks as ChatMessage["content"],
1244
+ });
1245
+ }
1196
1246
  continue;
1197
1247
  }
1198
1248
  }
package/src/compat.ts CHANGED
@@ -3,7 +3,13 @@ import { Readable } from "node:stream";
3
3
  import { PayloadTooLargeError, readLimitedBody } from "./body-limit.js";
4
4
  import { logger, redactSensitive } from "./logger.js";
5
5
  import type { AccountRotator } from "./rotator.js";
6
- import { withRotation, flattenHeaders, type RequestBody } from "./proxy.js";
6
+ import {
7
+ withRotation,
8
+ flattenHeaders,
9
+ type RequestBody,
10
+ type RotationAttemptContext,
11
+ type RotationOutcome,
12
+ } from "./proxy.js";
7
13
  import {
8
14
  isRecord,
9
15
  sanitizeGeminiSchema,
@@ -258,7 +264,42 @@ function summarizeCompatRequest(body: RequestBody): string {
258
264
  return `model=${body.model} userAgent=${body.userAgent || "none"} turns=${contents.length} tools=${tools} systemInstruction=${systemInstruction}`;
259
265
  }
260
266
 
267
+ function recordCompatOutcome(
268
+ rotator: AccountRotator,
269
+ body: RequestBody,
270
+ context: RotationAttemptContext,
271
+ statusCode: number,
272
+ completion?: CompatCompletion,
273
+ totalMs = Date.now() - context.requestStartMs,
274
+ ): void {
275
+ const ttfbMs = completion?.firstByteMs ?? totalMs;
276
+ rotator.recordLatency(body.displayModel || body.model, ttfbMs, totalMs);
277
+ rotator.recordRequestLog({
278
+ model: context.displayModelKey,
279
+ account: context.label,
280
+ statusCode,
281
+ ttfbMs,
282
+ totalMs,
283
+ inputTokens: completion?.inputTokens ?? 0,
284
+ outputTokens: completion?.outputTokens ?? 0,
285
+ });
286
+ }
261
287
 
288
+ function recordCompatFailure(
289
+ rotator: AccountRotator,
290
+ body: RequestBody,
291
+ outcome: RotationOutcome<unknown>,
292
+ ): void {
293
+ if (outcome.ok || !outcome.context) return;
294
+ recordCompatOutcome(
295
+ rotator,
296
+ body,
297
+ outcome.context,
298
+ outcome.status,
299
+ undefined,
300
+ outcome.totalMs,
301
+ );
302
+ }
262
303
 
263
304
  async function readJsonBody(req: IncomingMessage): Promise<unknown> {
264
305
  try {
@@ -283,6 +324,8 @@ async function streamCompatSse(
283
324
  let text = "";
284
325
  let inputTokens = 0;
285
326
  let outputTokens = 0;
327
+ const streamStartMs = Date.now();
328
+ let firstByteMs: number | undefined;
286
329
  let responseId: string | undefined;
287
330
  let toolCallIndex = 0;
288
331
 
@@ -317,16 +360,23 @@ async function streamCompatSse(
317
360
 
318
361
  let tailBuffer = "";
319
362
  let reqClosed = false;
320
- req.once("close", () => {
363
+ const closeUpstreamForClient = (): void => {
321
364
  reqClosed = true;
322
- });
365
+ if (!nodeStream.destroyed) nodeStream.destroy();
366
+ };
367
+ const responseEvents = res as {
368
+ once?: (event: "close", listener: () => void) => unknown;
369
+ off?: (event: "close", listener: () => void) => unknown;
370
+ };
371
+ req.once("close", closeUpstreamForClient);
372
+ responseEvents.once?.("close", closeUpstreamForClient);
323
373
 
324
374
  try {
325
375
  for await (const chunk of nodeStream) {
326
376
  if (reqClosed) {
327
- nodeStream.destroy();
328
377
  break;
329
378
  }
379
+ if (firstByteMs === undefined) firstByteMs = Date.now() - streamStartMs;
330
380
  const str = chunk.toString();
331
381
  tailBuffer += str;
332
382
  let newlineIdx;
@@ -496,9 +546,14 @@ async function streamCompatSse(
496
546
  }
497
547
  }
498
548
  } catch (err) {
499
- compatLogger.warn(
500
- `Stream read error: ${redactSensitive(String(err)).slice(0, 200)}`,
501
- );
549
+ if (!reqClosed) {
550
+ compatLogger.warn(
551
+ `Stream read error: ${redactSensitive(String(err)).slice(0, 200)}`,
552
+ );
553
+ }
554
+ } finally {
555
+ req.off("close", closeUpstreamForClient);
556
+ responseEvents.off?.("close", closeUpstreamForClient);
502
557
  }
503
558
 
504
559
  if (!reqClosed && !res.writableEnded) {
@@ -542,6 +597,7 @@ async function streamCompatSse(
542
597
  text,
543
598
  inputTokens,
544
599
  outputTokens,
600
+ firstByteMs,
545
601
  responseId,
546
602
  toolCalls: collectedToolCalls,
547
603
  };
@@ -563,6 +619,8 @@ async function streamResponsesSse(
563
619
  let thinkingText = "";
564
620
  let inputTokens = 0;
565
621
  let outputTokens = 0;
622
+ const streamStartMs = Date.now();
623
+ let firstByteMs: number | undefined;
566
624
  const toolCalls: OpenAIToolCall[] = [];
567
625
  let toolCallIndex = 0;
568
626
  let nextOutputIndex = 0;
@@ -572,9 +630,16 @@ async function streamResponsesSse(
572
630
  let reasoningItemId = "";
573
631
  let reasoningDone = false;
574
632
  let reqClosed = false;
575
- req.once("close", () => {
633
+ const closeUpstreamForClient = (): void => {
576
634
  reqClosed = true;
577
- });
635
+ if (!nodeStream.destroyed) nodeStream.destroy();
636
+ };
637
+ const responseEvents = res as {
638
+ once?: (event: "close", listener: () => void) => unknown;
639
+ off?: (event: "close", listener: () => void) => unknown;
640
+ };
641
+ req.once("close", closeUpstreamForClient);
642
+ responseEvents.once?.("close", closeUpstreamForClient);
578
643
 
579
644
  res.writeHead(200, {
580
645
  "Content-Type": "text/event-stream",
@@ -616,9 +681,9 @@ async function streamResponsesSse(
616
681
  try {
617
682
  for await (const chunk of nodeStream) {
618
683
  if (reqClosed) {
619
- nodeStream.destroy();
620
684
  break;
621
685
  }
686
+ if (firstByteMs === undefined) firstByteMs = Date.now() - streamStartMs;
622
687
  tailBuffer += chunk.toString();
623
688
  let newlineIdx;
624
689
  while ((newlineIdx = tailBuffer.indexOf("\n")) >= 0) {
@@ -810,9 +875,14 @@ async function streamResponsesSse(
810
875
  }
811
876
  }
812
877
  } catch (err) {
813
- compatLogger.warn(
814
- `Responses stream read error: ${redactSensitive(String(err)).slice(0, 200)}`,
815
- );
878
+ if (!reqClosed) {
879
+ compatLogger.warn(
880
+ `Responses stream read error: ${redactSensitive(String(err)).slice(0, 200)}`,
881
+ );
882
+ }
883
+ } finally {
884
+ req.off("close", closeUpstreamForClient);
885
+ responseEvents.off?.("close", closeUpstreamForClient);
816
886
  }
817
887
 
818
888
  const completion: CompatCompletion = {
@@ -820,6 +890,7 @@ async function streamResponsesSse(
820
890
  thinkingText: thinkingText || undefined,
821
891
  inputTokens,
822
892
  outputTokens,
893
+ firstByteMs,
823
894
  toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
824
895
  };
825
896
  if (!reqClosed && !res.writableEnded) {
@@ -899,7 +970,7 @@ async function completeResponsesViaRotator(
899
970
  body.model,
900
971
  flattenHeaders(req.headers),
901
972
  body,
902
- async (response) => {
973
+ async (response, context) => {
903
974
  const completion = await streamResponsesSse(
904
975
  response.body,
905
976
  req,
@@ -916,10 +987,12 @@ async function completeResponsesViaRotator(
916
987
  completion.outputTokens,
917
988
  );
918
989
  }
990
+ recordCompatOutcome(rotator, body, context, response.status, completion);
919
991
  return completion;
920
992
  },
921
993
  );
922
994
  if (!outcome.ok) {
995
+ recordCompatFailure(rotator, body, outcome);
923
996
  return {
924
997
  completion: { text: "", inputTokens: 0, outputTokens: 0 },
925
998
  status: outcome.status,
@@ -949,7 +1022,7 @@ async function completeViaRotator(
949
1022
  body.model,
950
1023
  flattenHeaders(req.headers),
951
1024
  body,
952
- async (response) => {
1025
+ async (response, context) => {
953
1026
  if (streamMode === "none") {
954
1027
  const raw = await response.text();
955
1028
  const completion = parseAntigravitySse(raw);
@@ -960,6 +1033,13 @@ async function completeViaRotator(
960
1033
  completion.outputTokens,
961
1034
  );
962
1035
  }
1036
+ recordCompatOutcome(
1037
+ rotator,
1038
+ body,
1039
+ context,
1040
+ response.status,
1041
+ completion,
1042
+ );
963
1043
  return completion;
964
1044
  } else {
965
1045
  const completion = await streamCompatSse(
@@ -976,11 +1056,19 @@ async function completeViaRotator(
976
1056
  completion.outputTokens,
977
1057
  );
978
1058
  }
1059
+ recordCompatOutcome(
1060
+ rotator,
1061
+ body,
1062
+ context,
1063
+ response.status,
1064
+ completion,
1065
+ );
979
1066
  return completion;
980
1067
  }
981
1068
  },
982
1069
  );
983
1070
  if (!outcome.ok) {
1071
+ recordCompatFailure(rotator, body, outcome);
984
1072
  if (outcome.status === 404) {
985
1073
  compatLogger.warn(
986
1074
  `Compat upstream 404 endpoint=${outcome.endpoint || "unknown"} ${summarizeCompatRequest(body)} error=${(outcome.errorText || "").slice(0, 300)}`,
package/src/dashboard.ts CHANGED
@@ -197,6 +197,41 @@ export function serveClearBreakerApi(
197
197
  res.end(JSON.stringify({ ok: true }));
198
198
  }
199
199
 
200
+ export function serveKickstartApi(
201
+ res: ServerResponse,
202
+ rotator: AccountRotator,
203
+ email: string,
204
+ modelKey?: string,
205
+ ): void {
206
+ if (modelKey) {
207
+ rotator.kickstartTimerForAccount(email, modelKey).then((result) => {
208
+ res.writeHead(200, { "Content-Type": "application/json" });
209
+ res.end(JSON.stringify(result));
210
+ }).catch((err) => {
211
+ res.writeHead(500, { "Content-Type": "application/json" });
212
+ res.end(JSON.stringify({ ok: false, error: String(err) }));
213
+ });
214
+ } else {
215
+ rotator.kickstartAllFreshTimers(email).then((result) => {
216
+ res.writeHead(200, { "Content-Type": "application/json" });
217
+ res.end(JSON.stringify(result));
218
+ }).catch((err) => {
219
+ res.writeHead(500, { "Content-Type": "application/json" });
220
+ res.end(JSON.stringify({ ok: false, error: String(err), results: [] }));
221
+ });
222
+ }
223
+ }
224
+
225
+ export function serveAutoWarmupApi(
226
+ res: ServerResponse,
227
+ rotator: AccountRotator,
228
+ enabled: boolean,
229
+ ): void {
230
+ const changed = rotator.setAutoWarmup(enabled);
231
+ res.writeHead(200, { "Content-Type": "application/json" });
232
+ res.end(JSON.stringify({ ok: true, changed, autoWarmupEnabled: enabled }));
233
+ }
234
+
200
235
  const DASHBOARD_HTML = `<!DOCTYPE html>
201
236
  <html lang="en">
202
237
  <head>