pi-antigravity-rotator 2.2.1 → 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 +23 -4
- package/README.md +14 -5
- package/package.json +3 -1
- package/src/account-store.ts +109 -168
- package/src/admin-auth.ts +74 -83
- package/src/cli.ts +62 -46
- package/src/compat/cache.ts +42 -0
- package/src/compat/model-specs.ts +78 -0
- package/src/compat/schema-sanitizer.ts +277 -0
- package/src/compat/translators.ts +1538 -0
- package/src/compat.ts +1535 -2444
- 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/notification-poller.ts +1 -1
- 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/telemetry.ts +1 -1
- package/src/types.ts +501 -397
package/CHANGELOG.md
CHANGED
|
@@ -2,13 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [2.3.0] - 2026-06-22
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **PostgreSQL Storage Layer**: Optional PostgreSQL persistence support via a clean Repository Pattern (`ISettingsRepository`), enabling deployments without local disk writes. (PR [#13](https://github.com/tuxevil/pi-antigravity-rotator/pull/13) by [@josenicomaia](https://github.com/josenicomaia))
|
|
9
|
+
- **Web-based Account Management**: Add, remove, and update accounts and their tier directly from the dashboard UI without manually editing JSON files.
|
|
10
|
+
- **Improved Auth Flow**: Added `/login-cli` endpoint to dramatically simplify the OAuth login callback process.
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- **Token Regeneration**: As part of the refactor to the new storage abstraction, upgrading to this version will automatically regenerate the `PI_ROTATOR_ADMIN_TOKEN` on first boot. The new token will be printed to the console output.
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
## [2.2.2] - 2026-06-21
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
- **Telemetry HTTPS Error**: Reverted the default telemetry endpoint from `https://` back to `http://` because the backend server does not support SSL, which was causing silent "packet length too long" errors and preventing telemetry and broadcast notifications from working.
|
|
20
|
+
|
|
21
|
+
### Refactored
|
|
22
|
+
- **Compatibility Layer**: Massively refactored the 2,400-line `src/compat.ts` into smaller, single-responsibility modules under `src/compat/` for easier maintenance. (PR [#11](https://github.com/tuxevil/pi-antigravity-rotator/pull/11) by [@josenicomaia](https://github.com/josenicomaia))
|
|
23
|
+
|
|
5
24
|
## [2.2.1] - 2026-06-20
|
|
6
25
|
|
|
7
26
|
### Fixed
|
|
8
|
-
- **Support tool response images**: Extracted any image content (`image_url` or `image` formats) from OpenAI tool response content and mapped them to Antigravity `inlineData` parts.
|
|
9
|
-
- **Ordered tool results**: Guaranteed all tool results remain clustered at the top of the parts array, complying with Claude's strict layout requirements.
|
|
10
|
-
- **Dangling tool call filtering**: Filtered out any `tool_calls` that do not have a matching `tool_result` in the subsequent message to prevent `400 Bad Request`.
|
|
11
|
-
- **Inline JSON-Schema union types**: Collapsed Draft-2020-12 inline union type arrays (e.g. `type: ["number", "null"]`) to the first non-null type and lifted nullability into the proto-supported `nullable` flag to fix 400 errors.
|
|
27
|
+
- **Support tool response images**: Extracted any image content (`image_url` or `image` formats) from OpenAI tool response content and mapped them to Antigravity `inlineData` parts. (PR [#9](https://github.com/tuxevil/pi-antigravity-rotator/pull/9) by [@josenicomaia](https://github.com/josenicomaia))
|
|
28
|
+
- **Ordered tool results**: Guaranteed all tool results remain clustered at the top of the parts array, complying with Claude's strict layout requirements. (PR [#9](https://github.com/tuxevil/pi-antigravity-rotator/pull/9) by [@josenicomaia](https://github.com/josenicomaia))
|
|
29
|
+
- **Dangling tool call filtering**: Filtered out any `tool_calls` that do not have a matching `tool_result` in the subsequent message to prevent `400 Bad Request`. (PR [#9](https://github.com/tuxevil/pi-antigravity-rotator/pull/9) by [@josenicomaia](https://github.com/josenicomaia))
|
|
30
|
+
- **Inline JSON-Schema union types**: Collapsed Draft-2020-12 inline union type arrays (e.g. `type: ["number", "null"]`) to the first non-null type and lifted nullability into the proto-supported `nullable` flag to fix 400 errors. (PR [#10](https://github.com/tuxevil/pi-antigravity-rotator/pull/10) by [@yashyadav711](https://github.com/yashyadav711))
|
|
12
31
|
|
|
13
32
|
## [2.2.0] - 2026-06-16
|
|
14
33
|
|
package/README.md
CHANGED
|
@@ -153,7 +153,7 @@ If login fails at project discovery:
|
|
|
153
153
|
|
|
154
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`).
|
|
155
155
|
|
|
156
|
-
If `PI_ROTATOR_ADMIN_TOKEN` is unset,
|
|
156
|
+
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
157
|
|
|
158
158
|
The dashboard shows:
|
|
159
159
|
|
|
@@ -164,6 +164,7 @@ The dashboard shows:
|
|
|
164
164
|
- **Quota Forecast** -- Predictive modeling showing when each model's quota will run out based on the current requests/hour burn rate.
|
|
165
165
|
- **Searchable Request Log** -- Live feed of the last 200 requests with exact timestamps, models, masked accounts, status codes, and latency.
|
|
166
166
|
- **Account Cards** -- Sorted by total quota. Shows status (`active`, `ready`, `cooldown`, `flagged`, `disabled`), quota bars with timers, and precise error messages.
|
|
167
|
+
- **Web-based Account Management** -- Add, remove, and manage account credentials and tier configurations directly from the UI without touching JSON files.
|
|
167
168
|
- **Operator Panels** -- "Attention Needed" summaries for quarantined accounts, unroutable models, token-bucket pressure, and a real-time event feed of rotator actions.
|
|
168
169
|
- **Routing Inspector** -- On-demand modal showing the active routing policy, candidate scores, local token bucket state, and rejection reasons per model.
|
|
169
170
|
|
|
@@ -282,12 +283,19 @@ The dashboard is intended to replace day-to-day `journalctl` digging for normal
|
|
|
282
283
|
Config files (`accounts.json`, `state.json`) are stored in `~/.pi-antigravity-rotator/` by default. Override with:
|
|
283
284
|
|
|
284
285
|
```bash
|
|
286
|
+
|
|
285
287
|
# Environment variables
|
|
286
288
|
export PI_ROTATOR_DIR=/path/to/config
|
|
287
289
|
export PI_ROTATOR_QUOTA_USER_AGENT="antigravity/1.107.0 darwin/arm64"
|
|
288
|
-
|
|
290
|
+
|
|
291
|
+
# Postgres Storage Support (v2.3.0+)
|
|
292
|
+
# Optional: Set this to use a Postgres database instead of local JSON files for persistence.
|
|
293
|
+
export PI_ROTATOR_DATABASE_URL="postgres://user:pass@localhost:5432/rotatordb"
|
|
294
|
+
export DATABASE_URL="postgres://user:pass@localhost:5432/rotatordb" # Fallback
|
|
295
|
+
|
|
296
|
+
# Optional: require this token for dashboard/API access. If unset, a secure token is auto-generated.
|
|
297
|
+
# Note: Upgrading to v2.3.0+ will regenerate your existing auto-generated token. Check logs!
|
|
289
298
|
export PI_ROTATOR_ADMIN_TOKEN="change-me"
|
|
290
|
-
# Optional: bind the proxy to a safer local-only interface.
|
|
291
299
|
export PI_ROTATOR_BIND_HOST="127.0.0.1"
|
|
292
300
|
# Optional: max accepted proxy request body size in bytes. Default: 26214400 (25 MiB).
|
|
293
301
|
export PI_ROTATOR_MAX_BODY_BYTES=26214400
|
|
@@ -410,7 +418,7 @@ Login now fails if Google does not return a project ID. No shared fallback.
|
|
|
410
418
|
| `POST` | `/v1/chat/completions` | OpenAI-compatible non-streaming chat adapter |
|
|
411
419
|
| `POST` | `/v1/messages` | Anthropic-compatible non-streaming messages adapter |
|
|
412
420
|
|
|
413
|
-
|
|
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.
|
|
414
422
|
|
|
415
423
|
### Compatibility Adapters
|
|
416
424
|
|
|
@@ -471,7 +479,8 @@ Current adapter scope:
|
|
|
471
479
|
|
|
472
480
|
Thanks to these amazing people who have contributed to the project:
|
|
473
481
|
|
|
474
|
-
- **[@josenicomaia](https://github.com/josenicomaia)** (José Nicodemos Maia Neto) —
|
|
482
|
+
- **[@josenicomaia](https://github.com/josenicomaia)** (José Nicodemos Maia Neto) — Modularized the compatibility layer architecture, added multimodal tool response support, and fixed streaming pass-through for tool executions. ([PR #8](https://github.com/tuxevil/pi-antigravity-rotator/pull/8), [PR #9](https://github.com/tuxevil/pi-antigravity-rotator/pull/9), [PR #11](https://github.com/tuxevil/pi-antigravity-rotator/pull/11))
|
|
483
|
+
- **[@yashyadav711](https://github.com/yashyadav711)** (Yash) — Fixed Draft-2020-12 inline JSON-Schema union type mapping for Gemini tools support. ([PR #10](https://github.com/tuxevil/pi-antigravity-rotator/pull/10))
|
|
475
484
|
- **[@javargasm](https://github.com/javargasm)** (Jeisson Alexander Vargas Marroquin) — Anthropic tool-use compatibility layer (`tool_use`/`tool_result` content block conversion), JSON schema round-trip fixes, and compat test suite expansion. ([PR #3](https://github.com/tuxevil/pi-antigravity-rotator/pull/3), [PR #7](https://github.com/tuxevil/pi-antigravity-rotator/pull/7))
|
|
476
485
|
|
|
477
486
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-antigravity-rotator",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
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",
|
|
@@ -42,11 +42,13 @@
|
|
|
42
42
|
},
|
|
43
43
|
"author": "Sebastián Real (tuxevil)",
|
|
44
44
|
"dependencies": {
|
|
45
|
+
"pg": "^8.22.0",
|
|
45
46
|
"tsx": "^4.19.0"
|
|
46
47
|
},
|
|
47
48
|
"devDependencies": {
|
|
48
49
|
"@eslint/js": "^10.0.1",
|
|
49
50
|
"@types/node": "^22.0.0",
|
|
51
|
+
"@types/pg": "^8.20.0",
|
|
50
52
|
"c8": "^11.0.0",
|
|
51
53
|
"eslint": "^10.5.0",
|
|
52
54
|
"typescript": "^5.7.0",
|
package/src/account-store.ts
CHANGED
|
@@ -2,9 +2,22 @@ import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { getAccountsPath } from "./paths.js";
|
|
5
|
-
import type { AccountConfig
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
5
|
+
import type { AccountConfig } from "./types.js";
|
|
6
|
+
import { writeJsonFileAtomic } from "./storage.js";
|
|
7
|
+
import {
|
|
8
|
+
loadConfig,
|
|
9
|
+
loadOrCreateAccountsConfig,
|
|
10
|
+
saveAccountsConfig,
|
|
11
|
+
} from "./config-storage.js";
|
|
12
|
+
import { applyConfigDefaults, getDefaultConfig } from "./config-defaults.js";
|
|
13
|
+
|
|
14
|
+
export {
|
|
15
|
+
loadConfig,
|
|
16
|
+
loadOrCreateAccountsConfig,
|
|
17
|
+
saveAccountsConfig,
|
|
18
|
+
applyConfigDefaults,
|
|
19
|
+
getDefaultConfig,
|
|
20
|
+
};
|
|
8
21
|
|
|
9
22
|
const ACCOUNTS_FILE = getAccountsPath();
|
|
10
23
|
const PI_DIR = join(homedir(), ".pi", "agent");
|
|
@@ -13,93 +26,7 @@ const PI_AUTH_FILE = join(PI_DIR, "auth.json");
|
|
|
13
26
|
const TOKEN_USAGE_FILE = join(join(ACCOUNTS_FILE, ".."), "token-usage.json");
|
|
14
27
|
|
|
15
28
|
export function getTokenUsagePath(): string {
|
|
16
|
-
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export function applyConfigDefaults(config: Config): Config {
|
|
20
|
-
return {
|
|
21
|
-
proxyPort: config.proxyPort || 51200,
|
|
22
|
-
bindHost: config.bindHost || process.env.PI_ROTATOR_BIND_HOST || "0.0.0.0",
|
|
23
|
-
routingPolicy: config.routingPolicy || "timer-first",
|
|
24
|
-
requestsPerRotation: config.requestsPerRotation || 5,
|
|
25
|
-
rotateOnQuotaDrop: config.rotateOnQuotaDrop ?? 20,
|
|
26
|
-
quotaPollIntervalMs: config.quotaPollIntervalMs || 300000,
|
|
27
|
-
maxConcurrentRequestsPerAccount: config.maxConcurrentRequestsPerAccount ?? 1,
|
|
28
|
-
maxConcurrentRequestsPerProjectModel: config.maxConcurrentRequestsPerProjectModel ?? 1,
|
|
29
|
-
projectCircuitBreaker429Threshold: config.projectCircuitBreaker429Threshold ?? 3,
|
|
30
|
-
projectCircuitBreakerWindowMs: config.projectCircuitBreakerWindowMs ?? 10 * 60 * 1000,
|
|
31
|
-
projectCircuitBreakerCooldownMs: config.projectCircuitBreakerCooldownMs ?? 60 * 60 * 1000,
|
|
32
|
-
modelCircuitBreaker429Threshold: config.modelCircuitBreaker429Threshold ?? 3,
|
|
33
|
-
modelCircuitBreakerCooldownMs: config.modelCircuitBreakerCooldownMs ?? 6 * 60 * 60 * 1000,
|
|
34
|
-
dailyAccountSlowRequests: config.dailyAccountSlowRequests ?? 250,
|
|
35
|
-
dailyAccountStopRequests: config.dailyAccountStopRequests ?? 350,
|
|
36
|
-
dailyProjectSlowRequests: config.dailyProjectSlowRequests ?? 900,
|
|
37
|
-
dailyProjectStopRequests: config.dailyProjectStopRequests ?? 1200,
|
|
38
|
-
slowModeJitterMinMs: config.slowModeJitterMinMs ?? 8_000,
|
|
39
|
-
slowModeJitterMaxMs: config.slowModeJitterMaxMs ?? 25_000,
|
|
40
|
-
protectivePauseMs: config.protectivePauseMs ?? 21600000,
|
|
41
|
-
useRequestCountRotationWhenQuotaUnknownOnly: config.useRequestCountRotationWhenQuotaUnknownOnly ?? true,
|
|
42
|
-
tokenBucketEnabled: config.tokenBucketEnabled ?? false,
|
|
43
|
-
tokenBucketMaxTokens: config.tokenBucketMaxTokens ?? 50,
|
|
44
|
-
tokenBucketRefillPerMinute: config.tokenBucketRefillPerMinute ?? 6,
|
|
45
|
-
tokenBucketInitialTokens: config.tokenBucketInitialTokens ?? (config.tokenBucketMaxTokens ?? 50),
|
|
46
|
-
accounts: config.accounts.map((account) => ({
|
|
47
|
-
...account,
|
|
48
|
-
tier: account.tier || "unknown",
|
|
49
|
-
})),
|
|
50
|
-
};
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export function getDefaultConfig(): Config {
|
|
54
|
-
return applyConfigDefaults({
|
|
55
|
-
proxyPort: 51200,
|
|
56
|
-
accounts: [],
|
|
57
|
-
requestsPerRotation: 5,
|
|
58
|
-
rotateOnQuotaDrop: 20,
|
|
59
|
-
quotaPollIntervalMs: 300000,
|
|
60
|
-
maxConcurrentRequestsPerAccount: 1,
|
|
61
|
-
maxConcurrentRequestsPerProjectModel: 1,
|
|
62
|
-
projectCircuitBreaker429Threshold: 3,
|
|
63
|
-
projectCircuitBreakerWindowMs: 10 * 60 * 1000,
|
|
64
|
-
projectCircuitBreakerCooldownMs: 60 * 60 * 1000,
|
|
65
|
-
modelCircuitBreaker429Threshold: 3,
|
|
66
|
-
modelCircuitBreakerCooldownMs: 6 * 60 * 60 * 1000,
|
|
67
|
-
dailyAccountSlowRequests: 250,
|
|
68
|
-
dailyAccountStopRequests: 350,
|
|
69
|
-
dailyProjectSlowRequests: 900,
|
|
70
|
-
dailyProjectStopRequests: 1200,
|
|
71
|
-
slowModeJitterMinMs: 8_000,
|
|
72
|
-
slowModeJitterMaxMs: 25_000,
|
|
73
|
-
protectivePauseMs: 21600000,
|
|
74
|
-
useRequestCountRotationWhenQuotaUnknownOnly: true,
|
|
75
|
-
tokenBucketEnabled: false,
|
|
76
|
-
tokenBucketMaxTokens: 50,
|
|
77
|
-
tokenBucketRefillPerMinute: 6,
|
|
78
|
-
tokenBucketInitialTokens: 50,
|
|
79
|
-
});
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export function loadConfigFromDisk(): Config {
|
|
83
|
-
const parsed = readJsonFile<unknown>(ACCOUNTS_FILE);
|
|
84
|
-
if (parsed === null) return getDefaultConfig();
|
|
85
|
-
const validation = validateConfig(parsed);
|
|
86
|
-
if (!validation.ok || !validation.value) {
|
|
87
|
-
throw new Error(formatValidationErrors(validation.errors));
|
|
88
|
-
}
|
|
89
|
-
return applyConfigDefaults(validation.value);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export function loadOrCreateAccountsConfig(): Config {
|
|
93
|
-
try {
|
|
94
|
-
return loadConfigFromDisk();
|
|
95
|
-
} catch {
|
|
96
|
-
return getDefaultConfig();
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
export function saveAccountsConfig(config: Config): void {
|
|
101
|
-
backupFile(ACCOUNTS_FILE, "accounts");
|
|
102
|
-
writeJsonFileAtomic(ACCOUNTS_FILE, applyConfigDefaults(config));
|
|
29
|
+
return TOKEN_USAGE_FILE;
|
|
103
30
|
}
|
|
104
31
|
|
|
105
32
|
// Reasonable upper bounds on per-account fields. These are defensive
|
|
@@ -112,93 +39,107 @@ export const MAX_PROJECT_ID_LENGTH = 100;
|
|
|
112
39
|
export const MAX_REFRESH_TOKEN_LENGTH = 4096;
|
|
113
40
|
|
|
114
41
|
function validateAccountConfigLengths(entry: AccountConfig): void {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
42
|
+
const checks: Array<[string, number]> = [
|
|
43
|
+
["email", MAX_EMAIL_LENGTH],
|
|
44
|
+
["label", MAX_LABEL_LENGTH],
|
|
45
|
+
["projectId", MAX_PROJECT_ID_LENGTH],
|
|
46
|
+
["refreshToken", MAX_REFRESH_TOKEN_LENGTH],
|
|
47
|
+
];
|
|
48
|
+
for (const [field, max] of checks) {
|
|
49
|
+
const value = entry[field as keyof AccountConfig];
|
|
50
|
+
if (typeof value === "string" && value.length > max) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`Account ${field} exceeds maximum length ${max} (got ${value.length}). ` +
|
|
53
|
+
`This usually indicates a malformed input — refusing to write to accounts.json.`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
130
57
|
}
|
|
131
58
|
|
|
132
59
|
export { validateAccountConfigLengths };
|
|
133
60
|
|
|
134
61
|
export function addAccountToConfig(entry: AccountConfig): { isNew: boolean } {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
62
|
+
validateAccountConfigLengths(entry);
|
|
63
|
+
const config = loadOrCreateAccountsConfig();
|
|
64
|
+
const existing = config.accounts.findIndex((a) => a.email === entry.email);
|
|
65
|
+
|
|
66
|
+
if (existing >= 0) {
|
|
67
|
+
config.accounts[existing] = { ...config.accounts[existing], ...entry };
|
|
68
|
+
saveAccountsConfig(config);
|
|
69
|
+
return { isNew: false };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
config.accounts.push(entry);
|
|
73
|
+
saveAccountsConfig(config);
|
|
74
|
+
return { isNew: true };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function removeAccountFromConfig(email: string): boolean {
|
|
78
|
+
const config = loadOrCreateAccountsConfig();
|
|
79
|
+
const idx = config.accounts.findIndex((a) => a.email === email);
|
|
80
|
+
if (idx < 0) return false;
|
|
81
|
+
config.accounts.splice(idx, 1);
|
|
82
|
+
saveAccountsConfig(config);
|
|
83
|
+
return true;
|
|
148
84
|
}
|
|
149
85
|
|
|
150
86
|
export function ensurePiModelsConfig(): void {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
87
|
+
mkdirSync(PI_DIR, { recursive: true });
|
|
88
|
+
|
|
89
|
+
let models: Record<string, unknown> = {};
|
|
90
|
+
if (existsSync(PI_MODELS_FILE)) {
|
|
91
|
+
try {
|
|
92
|
+
models = JSON.parse(readFileSync(PI_MODELS_FILE, "utf-8"));
|
|
93
|
+
} catch {
|
|
94
|
+
// Corrupted, will overwrite
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const providers = (models.providers || {}) as Record<
|
|
99
|
+
string,
|
|
100
|
+
Record<string, unknown>
|
|
101
|
+
>;
|
|
102
|
+
const antigravity = providers["google-antigravity"] || {};
|
|
103
|
+
|
|
104
|
+
if (antigravity.baseUrl === "http://localhost:51200") {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
antigravity.baseUrl = "http://localhost:51200";
|
|
109
|
+
providers["google-antigravity"] = antigravity;
|
|
110
|
+
models.providers = providers;
|
|
111
|
+
|
|
112
|
+
writeJsonFileAtomic(PI_MODELS_FILE, models);
|
|
113
|
+
console.log(` Updated ${PI_MODELS_FILE}`);
|
|
175
114
|
}
|
|
176
115
|
|
|
177
116
|
export function ensurePiAuthConfig(): void {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
117
|
+
mkdirSync(PI_DIR, { recursive: true });
|
|
118
|
+
|
|
119
|
+
let auth: Record<string, unknown> = {};
|
|
120
|
+
if (existsSync(PI_AUTH_FILE)) {
|
|
121
|
+
try {
|
|
122
|
+
auth = JSON.parse(readFileSync(PI_AUTH_FILE, "utf-8"));
|
|
123
|
+
} catch {
|
|
124
|
+
// Corrupted, will overwrite
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const existing = auth["google-antigravity"] as
|
|
129
|
+
| Record<string, unknown>
|
|
130
|
+
| undefined;
|
|
131
|
+
if (existing?.type === "oauth" && existing?.refresh === "proxy-managed") {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
auth["google-antigravity"] = {
|
|
136
|
+
type: "oauth",
|
|
137
|
+
refresh: "proxy-managed",
|
|
138
|
+
access: "proxy-managed",
|
|
139
|
+
expires: 32503680000000,
|
|
140
|
+
projectId: "proxy-managed",
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
writeJsonFileAtomic(PI_AUTH_FILE, auth);
|
|
144
|
+
console.log(` Updated ${PI_AUTH_FILE}`);
|
|
204
145
|
}
|
package/src/admin-auth.ts
CHANGED
|
@@ -1,15 +1,12 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
|
-
import {
|
|
4
|
-
import { join } from "node:path";
|
|
3
|
+
import { getCachedAdminToken, setCachedAdminToken } from "./db-store.js";
|
|
5
4
|
|
|
6
5
|
interface AdminAuthRequest {
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
url?: string;
|
|
7
|
+
headers: IncomingMessage["headers"];
|
|
9
8
|
}
|
|
10
9
|
|
|
11
|
-
const ADMIN_TOKEN_FILENAME = ".admin-token";
|
|
12
|
-
|
|
13
10
|
let persistedToken: string | null = null;
|
|
14
11
|
|
|
15
12
|
/**
|
|
@@ -18,13 +15,15 @@ let persistedToken: string | null = null;
|
|
|
18
15
|
* getConfiguredAdminToken() will return this token if no env var is set.
|
|
19
16
|
*/
|
|
20
17
|
export function setPersistedAdminToken(token: string | null): void {
|
|
21
|
-
|
|
18
|
+
persistedToken = token && token.length > 0 ? token : null;
|
|
22
19
|
}
|
|
23
20
|
|
|
24
|
-
export function getConfiguredAdminToken(
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
21
|
+
export function getConfiguredAdminToken(
|
|
22
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
23
|
+
): string | null {
|
|
24
|
+
const token = env.PI_ROTATOR_ADMIN_TOKEN?.trim();
|
|
25
|
+
if (token) return token;
|
|
26
|
+
return persistedToken;
|
|
28
27
|
}
|
|
29
28
|
|
|
30
29
|
/**
|
|
@@ -32,100 +31,92 @@ export function getConfiguredAdminToken(env: NodeJS.ProcessEnv = process.env): s
|
|
|
32
31
|
* 64 hex characters = 256 bits of entropy.
|
|
33
32
|
*/
|
|
34
33
|
export function generateAdminToken(): string {
|
|
35
|
-
|
|
34
|
+
return randomBytes(32).toString("hex");
|
|
36
35
|
}
|
|
37
36
|
|
|
38
|
-
export function readPersistedAdminToken(
|
|
39
|
-
|
|
40
|
-
if (!existsSync(tokenPath)) return null;
|
|
41
|
-
try {
|
|
42
|
-
const raw = readFileSync(tokenPath, "utf-8").trim();
|
|
43
|
-
return raw ? raw : null;
|
|
44
|
-
} catch {
|
|
45
|
-
return null;
|
|
46
|
-
}
|
|
37
|
+
export function readPersistedAdminToken(): string | null {
|
|
38
|
+
return getCachedAdminToken();
|
|
47
39
|
}
|
|
48
40
|
|
|
49
|
-
export function writePersistedAdminToken(
|
|
50
|
-
|
|
51
|
-
writeFileSync(tokenPath, token, { mode: 0o600, encoding: "utf-8" });
|
|
52
|
-
try {
|
|
53
|
-
chmodSync(tokenPath, 0o600);
|
|
54
|
-
} catch {
|
|
55
|
-
// Best effort: some filesystems (e.g. Windows) don't support POSIX perms.
|
|
56
|
-
}
|
|
41
|
+
export function writePersistedAdminToken(token: string): void {
|
|
42
|
+
setCachedAdminToken(token);
|
|
57
43
|
}
|
|
58
44
|
|
|
59
45
|
export interface ResolvedAdminToken {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
46
|
+
token: string;
|
|
47
|
+
source: "env" | "repository" | "generated";
|
|
48
|
+
generated: boolean;
|
|
63
49
|
}
|
|
64
50
|
|
|
65
51
|
/**
|
|
66
52
|
* Resolve the effective admin token, generating and persisting one if needed.
|
|
67
|
-
* Priority: PI_ROTATOR_ADMIN_TOKEN env var >
|
|
53
|
+
* Priority: PI_ROTATOR_ADMIN_TOKEN env var > repository > generate new.
|
|
68
54
|
*
|
|
69
|
-
* When a token is generated, it is persisted to
|
|
70
|
-
*
|
|
55
|
+
* When a token is generated, it is persisted to the repository and returned.
|
|
56
|
+
* The caller is responsible for printing it to the operator.
|
|
71
57
|
*/
|
|
72
58
|
export function ensureAdminToken(
|
|
73
|
-
|
|
74
|
-
env: NodeJS.ProcessEnv = process.env,
|
|
59
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
75
60
|
): ResolvedAdminToken {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
61
|
+
const envToken = env.PI_ROTATOR_ADMIN_TOKEN?.trim();
|
|
62
|
+
if (envToken) {
|
|
63
|
+
return { token: envToken, source: "env", generated: false };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const existing = readPersistedAdminToken();
|
|
67
|
+
if (existing) {
|
|
68
|
+
return { token: existing, source: "repository", generated: false };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const newToken = generateAdminToken();
|
|
72
|
+
try {
|
|
73
|
+
writePersistedAdminToken(newToken);
|
|
74
|
+
} catch {
|
|
75
|
+
// If we cannot persist (e.g. DB down, read-only fs), still return the
|
|
76
|
+
// token for this session. The next restart will simply generate again.
|
|
77
|
+
}
|
|
78
|
+
return { token: newToken, source: "generated", generated: true };
|
|
94
79
|
}
|
|
95
80
|
|
|
96
81
|
export function getRequestAdminToken(req: AdminAuthRequest): string | null {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
82
|
+
const headerToken = req.headers["x-rotator-admin-token"];
|
|
83
|
+
if (typeof headerToken === "string" && headerToken) return headerToken;
|
|
84
|
+
if (Array.isArray(headerToken) && headerToken[0]) return headerToken[0];
|
|
85
|
+
|
|
86
|
+
const authorization = req.headers.authorization;
|
|
87
|
+
if (
|
|
88
|
+
typeof authorization === "string" &&
|
|
89
|
+
authorization.toLowerCase().startsWith("bearer ")
|
|
90
|
+
) {
|
|
91
|
+
return authorization.slice("bearer ".length).trim();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const requestUrl = new URL(req.url || "/", "http://localhost");
|
|
96
|
+
return requestUrl.searchParams.get("token");
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
112
100
|
}
|
|
113
101
|
|
|
114
102
|
export function isAdminAuthorized(
|
|
115
|
-
|
|
116
|
-
|
|
103
|
+
req: AdminAuthRequest,
|
|
104
|
+
expectedToken: string | null = getConfiguredAdminToken(),
|
|
117
105
|
): boolean {
|
|
118
|
-
|
|
119
|
-
|
|
106
|
+
if (!expectedToken) return true;
|
|
107
|
+
return getRequestAdminToken(req) === expectedToken;
|
|
120
108
|
}
|
|
121
109
|
|
|
122
|
-
export function requireAdmin(
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
110
|
+
export function requireAdmin(
|
|
111
|
+
req: IncomingMessage,
|
|
112
|
+
res: ServerResponse,
|
|
113
|
+
): boolean {
|
|
114
|
+
if (isAdminAuthorized(req)) return true;
|
|
115
|
+
res.writeHead(401, {
|
|
116
|
+
"Content-Type": "application/json",
|
|
117
|
+
"Cache-Control": "no-store",
|
|
118
|
+
"WWW-Authenticate": "Bearer",
|
|
119
|
+
});
|
|
120
|
+
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
121
|
+
return false;
|
|
131
122
|
}
|