pi-antigravity-rotator 2.3.1 → 2.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/README.md +17 -11
- package/package.json +11 -7
- package/src/cli.ts +27 -2
- package/src/compat/translators.ts +58 -8
- package/src/compat.ts +103 -15
- package/src/exposure.ts +17 -0
- package/src/index.ts +12 -0
- package/src/login.ts +4 -3
- package/src/notification-poller.ts +23 -10
- package/src/onboarding.ts +16 -1
- package/src/proxy.ts +398 -392
- package/src/rotator.ts +10 -6
- package/src/static/dashboard.js +7 -2
- package/src/telemetry.ts +7 -5
- package/src/types.ts +2 -9
- package/src/version-check.ts +23 -10
- package/tools/telemetry-receiver/README.md +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,8 +2,28 @@
|
|
|
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
|
+
|
|
5
14
|
## [2.3.1] - 2026-06-30
|
|
6
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
|
+
|
|
7
27
|
### Improved
|
|
8
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))
|
|
9
29
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
|
@@ -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 (
|
|
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
|
|
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
|
|
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.
|
|
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",
|
|
17
|
+
"typecheck:test": "tsc --project tsconfig.test.json",
|
|
14
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.
|
|
50
|
+
"tsx": "^4.22.4"
|
|
47
51
|
},
|
|
48
52
|
"devDependencies": {
|
|
49
53
|
"@eslint/js": "^10.0.1",
|
|
50
|
-
"@types/node": "^22.
|
|
54
|
+
"@types/node": "^22.20.0",
|
|
51
55
|
"@types/pg": "^8.20.0",
|
|
52
56
|
"c8": "^11.0.0",
|
|
53
|
-
"eslint": "^10.
|
|
57
|
+
"eslint": "^10.6.0",
|
|
54
58
|
"typescript": "^5.7.0",
|
|
55
|
-
"typescript-eslint": "^8.
|
|
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.
|
|
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)
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
500
|
-
|
|
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
|
-
|
|
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
|
-
|
|
814
|
-
|
|
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/exposure.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Config } from "./types.js";
|
|
2
|
+
|
|
3
|
+
type ExposureConfig = Pick<Config, "bindHost" | "proxyPort">;
|
|
4
|
+
|
|
5
|
+
export function isLoopbackBindHost(bindHost?: string | null): boolean {
|
|
6
|
+
const host = (bindHost || "0.0.0.0").trim().toLowerCase();
|
|
7
|
+
return host === "localhost" || host === "127.0.0.1" || host === "::1";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function getProxyExposureWarning(config: ExposureConfig): string | null {
|
|
11
|
+
const bindHost = config.bindHost || "0.0.0.0";
|
|
12
|
+
if (isLoopbackBindHost(bindHost)) return null;
|
|
13
|
+
return (
|
|
14
|
+
`Native and /v1 compatibility proxy routes are unauthenticated by design and are listening on ${bindHost}:${config.proxyPort}. ` +
|
|
15
|
+
"Restrict this port to localhost/LAN, a firewall, or a trusted reverse proxy, or set bindHost to 127.0.0.1 for local-only use."
|
|
16
|
+
);
|
|
17
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { setModelAliasesOverride } from "./types.js";
|
|
|
24
24
|
import { writeTextFileAtomic } from "./storage.js";
|
|
25
25
|
import { initDb } from "./db-store.js";
|
|
26
26
|
import { stopPendingSessionReaper } from "./onboarding.js";
|
|
27
|
+
import { getProxyExposureWarning } from "./exposure.js";
|
|
27
28
|
|
|
28
29
|
function loadConfig(): Config {
|
|
29
30
|
try {
|
|
@@ -146,6 +147,16 @@ function maybeWarnAboutAdminExposure(config: Config): void {
|
|
|
146
147
|
console.warn();
|
|
147
148
|
}
|
|
148
149
|
|
|
150
|
+
function maybeWarnAboutProxyExposure(config: Config): void {
|
|
151
|
+
const warning = getProxyExposureWarning(config);
|
|
152
|
+
if (!warning) return;
|
|
153
|
+
console.warn(`WARNING: ${warning}`);
|
|
154
|
+
console.warn(
|
|
155
|
+
"WARNING: PI_ROTATOR_ADMIN_TOKEN protects dashboard/admin APIs, but not the native or /v1 proxy routes.",
|
|
156
|
+
);
|
|
157
|
+
console.warn();
|
|
158
|
+
}
|
|
159
|
+
|
|
149
160
|
export async function main(): Promise<void> {
|
|
150
161
|
console.log("=== Pi Antigravity Rotator ===");
|
|
151
162
|
console.log();
|
|
@@ -181,6 +192,7 @@ export async function main(): Promise<void> {
|
|
|
181
192
|
maybeShowStarNudge();
|
|
182
193
|
bootstrapAdminToken();
|
|
183
194
|
maybeWarnAboutAdminExposure(config);
|
|
195
|
+
maybeWarnAboutProxyExposure(config);
|
|
184
196
|
warnIfUsingFallbackOAuthCreds();
|
|
185
197
|
warnIfInsecureTelemetryEndpoint();
|
|
186
198
|
setModelSpecsOverride(config.modelSpecs ?? null);
|