pi-antigravity-rotator 2.1.6 → 2.2.1
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 +41 -0
- package/package.json +9 -3
- package/src/account-store.ts +30 -0
- package/src/admin-auth.ts +84 -1
- package/src/compat.ts +150 -61
- package/src/dashboard.ts +11 -11
- package/src/index.ts +41 -1
- package/src/logger.ts +6 -1
- package/src/notification-poller.ts +4 -1
- package/src/oauth.ts +31 -0
- package/src/onboarding.ts +19 -0
- package/src/paths.ts +23 -3
- package/src/proxy.ts +375 -171
- package/src/responses-store.ts +203 -0
- package/src/rotator.ts +226 -16
- package/src/telemetry.ts +31 -1
- package/src/types.ts +70 -0
- package/src/validators.ts +36 -0
- package/src/antigravity-prompt.ts +0 -80
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,47 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [2.2.1] - 2026-06-20
|
|
6
|
+
|
|
7
|
+
### 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.
|
|
12
|
+
|
|
13
|
+
## [2.2.0] - 2026-06-16
|
|
14
|
+
|
|
15
|
+
### Security
|
|
16
|
+
|
|
17
|
+
### Security
|
|
18
|
+
- **Admin token autogeneration**: On first run with no `PI_ROTATOR_ADMIN_TOKEN` env var, a cryptographically secure token is generated, persisted to `.admin-token` (mode 0600), and printed once to the operator. Dashboard and `/api/*` routes are now protected by default on fresh installs. Override the generated token by setting `PI_ROTATOR_ADMIN_TOKEN` in the env. `.admin-token` added to `.gitignore`.
|
|
19
|
+
- **Querystring secret redaction**: `redactSensitive()` now also redacts `access_token`, `token`, `api_key`, `apikey`, `key`, `refresh_token` when they appear in querystring (`?key=val&...`) or URL fragment.
|
|
20
|
+
- **OAuth fallback warning**: New `warnIfUsingFallbackOAuthCreds()` detects when `ANTIGRAVITY_CLIENT_ID` and/or `ANTIGRAVITY_CLIENT_SECRET` are missing and emits a one-time warning that the rotator is using the public Antigravity IDE client.
|
|
21
|
+
- **Removed open CORS**: `Access-Control-Allow-Origin: *` removed from `/api/status` and `/api/config`. Replaced with `Cache-Control: no-store`. Dashboard still works same-origin.
|
|
22
|
+
- **Truncated/redacted validation logs**: New `logValidationFailure()` truncates payloads to 200 chars and runs them through `redactSensitive` before logging. Applied to OpenAI messages validation and stream error logs in `compat.ts`.
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
- **`Config.modelSpecs`**: Operators can now override the per-model thinking/output spec table used by the compat layer without recompiling. Add a `modelSpecs` field to `accounts.json` and call `setModelSpecsOverride()` at boot (done automatically by `index.ts`).
|
|
26
|
+
- **`warnIfInsecureTelemetryEndpoint()`**: Detects plain `http://` telemetry endpoints and emits a one-time warning. Default endpoint switched to `https://`. Override via `PI_ROTATOR_TELEMETRY_URL`. Silence via `PI_ROTATOR_TELEMETRY_INSECURE_OK=1`.
|
|
27
|
+
- **Persistent `responsesStore`**: The OpenAI Responses API store (used by Codex via `previous_response_id`) is now persisted to `<configDir>/responses.json` with atomic writes and a 1.5s debounce. Restart-safe: in-flight Codex conversations continue across rotator restarts. New `src/responses-store.ts` with `load()`, `flush()`, `flushSync()`. Corrupt files are moved aside to `.corrupt-<ts>.bak` on startup.
|
|
28
|
+
- **Debounced state writes**: Hot paths (`recordRequest`, `markExhausted`, `markError`, `markFlagged`) now call `scheduleStateSave()` instead of `saveState()`, coalescing multiple writes within a 1s window into a single disk write. New `flushPendingStateSaveSync()` in the SIGTERM/SIGINT shutdown handler drains the queue synchronously to minimise data loss.
|
|
29
|
+
- **GitHub Actions CI**: New `.github/workflows/ci.yml` runs `npm ci` + `npm run check` (typecheck + 191 tests) on push and PR to `main`. Node 22 with npm cache. PRs without green checks cannot be merged.
|
|
30
|
+
- **8 e2e proxy tests** (`test/proxy-e2e.test.ts`): Cover the full proxy flow with a local HTTP server as mock Antigravity — 200 happy path, 429 rate-limited (Retry-After and RESOURCE_EXHAUSTED), 401 unauthorized, 403 flagged and non-flagged, 500 server error, endpoint cascade (daily→prod).
|
|
31
|
+
- **7 dashboard tests** (`test/dashboard.test.ts`): Verify utf-8/viewport meta tags, all 12 admin API endpoints are referenced, the `escapeHtml`/`jsString`/`maskText`/`maskEmail` helpers are present and `escapeHtml` correctly escapes the 5 HTML-sensitive characters, no hardcoded OAuth secrets or refresh tokens leak into the HTML.
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
- **Refactored `proxy.ts`**: Extracted `classifyUpstreamResponse()` that returns a discriminated `UpstreamAction`. Both `withRotation()` and `handleProxyRequest()` dispatch against the helper instead of duplicating the 401/403/404/400/429/5xx branches. ~150 lines of parallel code removed. New `UpstreamAction` type with 9 action kinds.
|
|
35
|
+
|
|
36
|
+
### Cleanup
|
|
37
|
+
- **Removed `src/antigravity-prompt.ts`**: 80-line `ANTIGRAVITY_IDENTITY_PROMPT` export with 0 references in the repo.
|
|
38
|
+
- **Consolidated agent docs**: `CLAUDE.md` now points to `AGENTS.md` as the single source of truth. The duplicated BEADS INTEGRATION block is gone.
|
|
39
|
+
- **Moved scripts to `scripts/`**: 10 one-off utility scripts (`mitm.js`, `mock_google.js`, `query_models.{js,ts}`, `test-compat.ts`, `test-direct.js`, `test_generate.js`, `test_loop.js`, `test-http.cjs`, `test-openai.cjs`) moved from the repo root. `query_models.ts` and `test-compat.ts` updated to use `../src/...` relative imports after the move.
|
|
40
|
+
|
|
41
|
+
## [2.2.1] - 2026-06-16
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
- **SSE usage extraction cross-event matching**: The old `extractTokenUsage()` ran a regex on the last 32KB of the upstream body, which could match across SSE event boundaries and return incorrect `(input, output)` pairs. Replaced with `SseEventAccumulator` + `extractUsageFromSseEvent()` that buffer complete SSE events (split on `\n\n`), parse each `data:` payload as JSON, and recursively search for `usageMetadata` (Gemini) or `usage` (OpenAI/Anthropic). Regex remains as a last-resort fallback for malformed JSON. Real-time streaming is preserved — the `res.write(chunk)` in `onData` is unchanged. Resolves ROADMAP §2.
|
|
45
|
+
|
|
5
46
|
## [2.1.6] - 2026-06-12
|
|
6
47
|
|
|
7
48
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-antigravity-rotator",
|
|
3
|
-
"version": "2.1
|
|
3
|
+
"version": "2.2.1",
|
|
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",
|
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
"login": "tsx src/cli.ts login",
|
|
13
13
|
"typecheck": "tsc --noEmit",
|
|
14
14
|
"test": "node --import tsx/esm --test \"test/**/*.test.ts\"",
|
|
15
|
-
"
|
|
15
|
+
"lint": "eslint src test",
|
|
16
|
+
"coverage": "c8 --reporter=text --reporter=html npm test",
|
|
17
|
+
"check": "npm run typecheck && npm test && npm run lint"
|
|
16
18
|
},
|
|
17
19
|
"files": [
|
|
18
20
|
"bin/",
|
|
@@ -43,7 +45,11 @@
|
|
|
43
45
|
"tsx": "^4.19.0"
|
|
44
46
|
},
|
|
45
47
|
"devDependencies": {
|
|
48
|
+
"@eslint/js": "^10.0.1",
|
|
46
49
|
"@types/node": "^22.0.0",
|
|
47
|
-
"
|
|
50
|
+
"c8": "^11.0.0",
|
|
51
|
+
"eslint": "^10.5.0",
|
|
52
|
+
"typescript": "^5.7.0",
|
|
53
|
+
"typescript-eslint": "^8.61.1"
|
|
48
54
|
}
|
|
49
55
|
}
|
package/src/account-store.ts
CHANGED
|
@@ -102,7 +102,37 @@ export function saveAccountsConfig(config: Config): void {
|
|
|
102
102
|
writeJsonFileAtomic(ACCOUNTS_FILE, applyConfigDefaults(config));
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
// Reasonable upper bounds on per-account fields. These are defensive
|
|
106
|
+
// limits to prevent a malicious or buggy caller from growing
|
|
107
|
+
// accounts.json without bound, which would slow every subsequent
|
|
108
|
+
// saveState. The numbers are well above any realistic real value.
|
|
109
|
+
export const MAX_EMAIL_LENGTH = 254; // RFC 5321
|
|
110
|
+
export const MAX_LABEL_LENGTH = 100;
|
|
111
|
+
export const MAX_PROJECT_ID_LENGTH = 100;
|
|
112
|
+
export const MAX_REFRESH_TOKEN_LENGTH = 4096;
|
|
113
|
+
|
|
114
|
+
function validateAccountConfigLengths(entry: AccountConfig): void {
|
|
115
|
+
const checks: Array<[string, number]> = [
|
|
116
|
+
["email", MAX_EMAIL_LENGTH],
|
|
117
|
+
["label", MAX_LABEL_LENGTH],
|
|
118
|
+
["projectId", MAX_PROJECT_ID_LENGTH],
|
|
119
|
+
["refreshToken", MAX_REFRESH_TOKEN_LENGTH],
|
|
120
|
+
];
|
|
121
|
+
for (const [field, max] of checks) {
|
|
122
|
+
const value = entry[field as keyof AccountConfig];
|
|
123
|
+
if (typeof value === "string" && value.length > max) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`Account ${field} exceeds maximum length ${max} (got ${value.length}). ` +
|
|
126
|
+
`This usually indicates a malformed input — refusing to write to accounts.json.`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export { validateAccountConfigLengths };
|
|
133
|
+
|
|
105
134
|
export function addAccountToConfig(entry: AccountConfig): { isNew: boolean } {
|
|
135
|
+
validateAccountConfigLengths(entry);
|
|
106
136
|
const config = loadOrCreateAccountsConfig();
|
|
107
137
|
const existing = config.accounts.findIndex((a) => a.email === entry.email);
|
|
108
138
|
|
package/src/admin-auth.ts
CHANGED
|
@@ -1,13 +1,96 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
2
5
|
|
|
3
6
|
interface AdminAuthRequest {
|
|
4
7
|
url?: string;
|
|
5
8
|
headers: IncomingMessage["headers"];
|
|
6
9
|
}
|
|
7
10
|
|
|
11
|
+
const ADMIN_TOKEN_FILENAME = ".admin-token";
|
|
12
|
+
|
|
13
|
+
let persistedToken: string | null = null;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Set the persisted admin token at runtime. Called by index.ts after
|
|
17
|
+
* ensureAdminToken resolves the effective token. Subsequent calls to
|
|
18
|
+
* getConfiguredAdminToken() will return this token if no env var is set.
|
|
19
|
+
*/
|
|
20
|
+
export function setPersistedAdminToken(token: string | null): void {
|
|
21
|
+
persistedToken = token && token.length > 0 ? token : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
8
24
|
export function getConfiguredAdminToken(env: NodeJS.ProcessEnv = process.env): string | null {
|
|
9
25
|
const token = env.PI_ROTATOR_ADMIN_TOKEN?.trim();
|
|
10
|
-
|
|
26
|
+
if (token) return token;
|
|
27
|
+
return persistedToken;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Generate a cryptographically secure admin token (32 random bytes, hex).
|
|
32
|
+
* 64 hex characters = 256 bits of entropy.
|
|
33
|
+
*/
|
|
34
|
+
export function generateAdminToken(): string {
|
|
35
|
+
return randomBytes(32).toString("hex");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function readPersistedAdminToken(configDir: string): string | null {
|
|
39
|
+
const tokenPath = join(configDir, ADMIN_TOKEN_FILENAME);
|
|
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
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function writePersistedAdminToken(configDir: string, token: string): void {
|
|
50
|
+
const tokenPath = join(configDir, ADMIN_TOKEN_FILENAME);
|
|
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
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ResolvedAdminToken {
|
|
60
|
+
token: string;
|
|
61
|
+
source: "env" | "file" | "generated";
|
|
62
|
+
generated: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Resolve the effective admin token, generating and persisting one if needed.
|
|
67
|
+
* Priority: PI_ROTATOR_ADMIN_TOKEN env var > .admin-token file > generate new.
|
|
68
|
+
*
|
|
69
|
+
* When a token is generated, it is persisted to .admin-token with 0600 perms
|
|
70
|
+
* and returned. The caller is responsible for printing it to the operator.
|
|
71
|
+
*/
|
|
72
|
+
export function ensureAdminToken(
|
|
73
|
+
configDir: string,
|
|
74
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
75
|
+
): ResolvedAdminToken {
|
|
76
|
+
const envToken = env.PI_ROTATOR_ADMIN_TOKEN?.trim();
|
|
77
|
+
if (envToken) {
|
|
78
|
+
return { token: envToken, source: "env", generated: false };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const fileToken = readPersistedAdminToken(configDir);
|
|
82
|
+
if (fileToken) {
|
|
83
|
+
return { token: fileToken, source: "file", generated: false };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const newToken = generateAdminToken();
|
|
87
|
+
try {
|
|
88
|
+
writePersistedAdminToken(configDir, newToken);
|
|
89
|
+
} catch {
|
|
90
|
+
// If we cannot write the file (read-only fs, perms), still return the
|
|
91
|
+
// token for this session. The next restart will simply generate again.
|
|
92
|
+
}
|
|
93
|
+
return { token: newToken, source: "generated", generated: true };
|
|
11
94
|
}
|
|
12
95
|
|
|
13
96
|
export function getRequestAdminToken(req: AdminAuthRequest): string | null {
|
package/src/compat.ts
CHANGED
|
@@ -1,14 +1,25 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
2
|
import { Readable } from "node:stream";
|
|
3
3
|
import { PayloadTooLargeError, readLimitedBody } from "./body-limit.js";
|
|
4
|
-
import { logger } from "./logger.js";
|
|
4
|
+
import { logger, redactSensitive } from "./logger.js";
|
|
5
5
|
import type { AccountRotator } from "./rotator.js";
|
|
6
|
-
import { resolveQuotaModelKey } from "./types.js";
|
|
6
|
+
import { applyModelAlias, resolveQuotaModelKey } from "./types.js";
|
|
7
7
|
import { withRotation, flattenHeaders, type RequestBody } from "./proxy.js";
|
|
8
|
+
import { ResponsesStore, type StoredResponseEntry } from "./responses-store.js";
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
const compatLogger = logger.child("compat");
|
|
11
12
|
|
|
13
|
+
const VALIDATION_LOG_MAX_CHARS = 200;
|
|
14
|
+
|
|
15
|
+
export function logValidationFailure(scope: string, payload: unknown): void {
|
|
16
|
+
const truncated = redactSensitive(JSON.stringify(payload));
|
|
17
|
+
const clipped = truncated.length > VALIDATION_LOG_MAX_CHARS
|
|
18
|
+
? `${truncated.slice(0, VALIDATION_LOG_MAX_CHARS)}…[+${truncated.length - VALIDATION_LOG_MAX_CHARS} chars]`
|
|
19
|
+
: truncated;
|
|
20
|
+
compatLogger.warn(`${scope}: ${clipped}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
12
23
|
export interface ChatMessage {
|
|
13
24
|
role: "system" | "developer" | "user" | "assistant" | "model" | "tool";
|
|
14
25
|
content: string | Array<{ type: string; text?: string;[key: string]: unknown }> | null;
|
|
@@ -131,23 +142,19 @@ interface ResponseFunctionCallOutputItem {
|
|
|
131
142
|
|
|
132
143
|
type ResponseOutputItem = ResponseMessageOutputItem | ResponseFunctionCallOutputItem;
|
|
133
144
|
|
|
134
|
-
|
|
135
|
-
response: Record<string, unknown>;
|
|
136
|
-
inputItems: Array<Record<string, unknown>>;
|
|
137
|
-
conversationMessages: ChatMessage[];
|
|
138
|
-
callIdToName: Map<string, string>;
|
|
139
|
-
expiresAt: number;
|
|
140
|
-
}
|
|
145
|
+
// StoredResponseEntry now lives in responses-store.ts (persistent on disk)
|
|
141
146
|
|
|
142
147
|
// ---------------------------------------------------------------------------
|
|
143
148
|
// Model-specific specs — mirrors Antigravity-Manager model_specs.json
|
|
149
|
+
// Operators can override these via the `modelSpecs` field in accounts.json
|
|
150
|
+
// by calling setModelSpecsOverride() at startup.
|
|
144
151
|
// ---------------------------------------------------------------------------
|
|
145
|
-
interface ModelSpec {
|
|
152
|
+
export interface ModelSpec {
|
|
146
153
|
maxOutputTokens: number;
|
|
147
154
|
thinkingBudget: number; // -1 = adaptive (model decides), >=0 = fixed
|
|
148
155
|
isThinking: boolean;
|
|
149
156
|
}
|
|
150
|
-
const
|
|
157
|
+
const DEFAULT_MODEL_SPECS: Record<string, ModelSpec> = {
|
|
151
158
|
"gemini-pro-agent": { maxOutputTokens: 65535, thinkingBudget: 10001, isThinking: true },
|
|
152
159
|
"gemini-3-flash-agent": { maxOutputTokens: 65536, thinkingBudget: 10000, isThinking: true },
|
|
153
160
|
"gemini-3-pro-high": { maxOutputTokens: 65535, thinkingBudget: 10001, isThinking: true },
|
|
@@ -169,6 +176,20 @@ const MODEL_SPECS: Record<string, ModelSpec> = {
|
|
|
169
176
|
"gpt-oss-120b-medium": { maxOutputTokens: 32768, thinkingBudget: 8192, isThinking: true },
|
|
170
177
|
"gpt-oss-120b": { maxOutputTokens: 32768, thinkingBudget: 8192, isThinking: true },
|
|
171
178
|
};
|
|
179
|
+
let modelSpecsOverride: Record<string, ModelSpec> | null = null;
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Replace the bundled model spec table with operator-provided overrides.
|
|
183
|
+
* Pass `null` to restore defaults. Called once at startup from index.ts.
|
|
184
|
+
*/
|
|
185
|
+
export function setModelSpecsOverride(specs: Record<string, ModelSpec> | null): void {
|
|
186
|
+
modelSpecsOverride = specs && Object.keys(specs).length > 0 ? specs : null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function getActiveModelSpecs(): Record<string, ModelSpec> {
|
|
190
|
+
return modelSpecsOverride ?? DEFAULT_MODEL_SPECS;
|
|
191
|
+
}
|
|
192
|
+
|
|
172
193
|
const GEMINI_MAX_OUTPUT_TOKENS = 65536;
|
|
173
194
|
const CLAUDE_MAX_OUTPUT_TOKENS = 64000;
|
|
174
195
|
const FALLBACK_THINKING_BUDGET = 24576;
|
|
@@ -182,9 +203,10 @@ function getModelFamily(model: string): "claude" | "gemini" | "unknown" {
|
|
|
182
203
|
}
|
|
183
204
|
|
|
184
205
|
function getModelSpec(model: string): ModelSpec {
|
|
206
|
+
const specs = getActiveModelSpecs();
|
|
185
207
|
const lower = model.toLowerCase();
|
|
186
|
-
if (
|
|
187
|
-
for (const [key, spec] of Object.entries(
|
|
208
|
+
if (specs[lower]) return specs[lower];
|
|
209
|
+
for (const [key, spec] of Object.entries(specs)) {
|
|
188
210
|
if (lower.includes(key)) return spec;
|
|
189
211
|
}
|
|
190
212
|
const family = getModelFamily(model);
|
|
@@ -228,40 +250,36 @@ function isNonEmptyString(value: unknown): value is string {
|
|
|
228
250
|
*/
|
|
229
251
|
const thoughtSignatureCache = new Map<string, string>();
|
|
230
252
|
const THOUGHT_SIGNATURE_CACHE_MAX = 500;
|
|
231
|
-
const
|
|
232
|
-
const RESPONSES_STORE_MAX = 500;
|
|
233
|
-
const responsesStore = new Map<string, StoredResponseEntry>();
|
|
253
|
+
const responsesStore = new ResponsesStore();
|
|
234
254
|
|
|
235
255
|
function makeCompatId(prefix: string): string {
|
|
236
256
|
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
|
|
237
257
|
}
|
|
238
258
|
|
|
239
|
-
function pruneResponsesStore(now = Date.now()): void {
|
|
240
|
-
for (const [id, entry] of responsesStore) {
|
|
241
|
-
if (entry.expiresAt <= now) responsesStore.delete(id);
|
|
242
|
-
}
|
|
243
|
-
while (responsesStore.size > RESPONSES_STORE_MAX) {
|
|
244
|
-
const oldest = responsesStore.keys().next();
|
|
245
|
-
if (oldest.done) break;
|
|
246
|
-
responsesStore.delete(oldest.value);
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
|
|
250
259
|
function getStoredResponse(id: string): StoredResponseEntry | null {
|
|
251
|
-
|
|
252
|
-
return responsesStore.get(id) || null;
|
|
260
|
+
return responsesStore.get(id);
|
|
253
261
|
}
|
|
254
262
|
|
|
255
263
|
function setStoredResponse(id: string, entry: StoredResponseEntry): void {
|
|
256
|
-
pruneResponsesStore();
|
|
257
264
|
responsesStore.set(id, entry);
|
|
258
|
-
pruneResponsesStore();
|
|
259
265
|
}
|
|
260
266
|
|
|
261
267
|
export function resetResponsesStoreForTests(): void {
|
|
262
268
|
responsesStore.clear();
|
|
263
269
|
}
|
|
264
270
|
|
|
271
|
+
export async function loadResponsesStore(): Promise<void> {
|
|
272
|
+
await responsesStore.load();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export async function flushResponsesStore(): Promise<void> {
|
|
276
|
+
await responsesStore.flush();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function flushResponsesStoreSync(): void {
|
|
280
|
+
responsesStore.flushSync();
|
|
281
|
+
}
|
|
282
|
+
|
|
265
283
|
function cacheThoughtSignature(callId: string, signature: string): void {
|
|
266
284
|
if (thoughtSignatureCache.size >= THOUGHT_SIGNATURE_CACHE_MAX) {
|
|
267
285
|
// Evict the oldest entry
|
|
@@ -384,6 +402,15 @@ function sanitizeGeminiSchema(schema: unknown): unknown {
|
|
|
384
402
|
continue;
|
|
385
403
|
}
|
|
386
404
|
|
|
405
|
+
// Inline union type: `type: ["number","null"]`. Gemini's proto `type`
|
|
406
|
+
// field is a single enum, not repeating — collapse to the first non-null
|
|
407
|
+
// type and lift nullability into the proto-supported `nullable` flag.
|
|
408
|
+
if (key === "type" && Array.isArray(value)) {
|
|
409
|
+
const nonNull = (value as unknown[]).filter((t) => t !== "null");
|
|
410
|
+
if ((value as unknown[]).includes("null")) out["nullable"] = true;
|
|
411
|
+
out["type"] = (nonNull[0] ?? "string");
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
387
414
|
|
|
388
415
|
if (key === "properties" && isRecord(value)) {
|
|
389
416
|
out[key] = Object.fromEntries(
|
|
@@ -566,6 +593,17 @@ function sanitizeClaudeViaGeminiSchema(schema: unknown): unknown {
|
|
|
566
593
|
continue;
|
|
567
594
|
}
|
|
568
595
|
|
|
596
|
+
// Inline union type: `type: ["number","null"]`. Gemini's proto `type`
|
|
597
|
+
// field is a single enum, not repeating — an array value triggers a 400
|
|
598
|
+
// ('Proto field is not repeating'). Collapse to the first non-null type
|
|
599
|
+
// and lift nullability into the proto-supported `nullable` flag.
|
|
600
|
+
if (key === "type" && Array.isArray(value)) {
|
|
601
|
+
const nonNull = (value as unknown[]).filter((t) => t !== "null");
|
|
602
|
+
if ((value as unknown[]).includes("null")) out["nullable"] = true;
|
|
603
|
+
out["type"] = (nonNull[0] ?? "string");
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
|
|
569
607
|
if (key === "properties" && isRecord(value)) {
|
|
570
608
|
out[key] = Object.fromEntries(
|
|
571
609
|
Object.entries(value).map(([k, v]) => [k, sanitizeClaudeViaGeminiSchema(v)]),
|
|
@@ -808,12 +846,13 @@ function messagesFromAntigravityRequest(value: Record<string, unknown>): ChatMes
|
|
|
808
846
|
|
|
809
847
|
export function normalizeOpenAIChatCompletionRequest(value: unknown): unknown {
|
|
810
848
|
if (!isRecord(value) || Array.isArray(value.messages)) return value;
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
849
|
+
const messages = (() => {
|
|
850
|
+
if ("messages" in value) return messagesFromLooseMessages(value.messages);
|
|
851
|
+
if (typeof value.prompt === "string") return [{ role: "user", content: value.prompt }];
|
|
852
|
+
if (Array.isArray(value.prompt)) return messagesFromResponsesInput(value.prompt) ?? value.prompt.map((prompt) => ({ role: "user", content: String(prompt) }));
|
|
853
|
+
if ("input" in value) return messagesFromResponsesInput(value.input);
|
|
854
|
+
return messagesFromAntigravityRequest(value);
|
|
855
|
+
})();
|
|
817
856
|
return messages ? { ...value, messages } : value;
|
|
818
857
|
}
|
|
819
858
|
|
|
@@ -897,7 +936,7 @@ export function validateOpenAIChatCompletionRequest(value: unknown): { ok: true;
|
|
|
897
936
|
const errors: string[] = [];
|
|
898
937
|
if (!isNonEmptyString(value.model)) errors.push("body.model must be a non-empty string");
|
|
899
938
|
if (!validateMessages(value.messages)) {
|
|
900
|
-
|
|
939
|
+
logValidationFailure("OpenAI messages validation failed", value.messages);
|
|
901
940
|
errors.push("body.messages must be an array of chat messages");
|
|
902
941
|
}
|
|
903
942
|
if (value.stream !== undefined && typeof value.stream !== "boolean") errors.push("body.stream must be boolean when provided");
|
|
@@ -961,9 +1000,15 @@ function convertResponsesToChatRequest(input: OpenAIResponsesRequest): Responses
|
|
|
961
1000
|
throw new Error(`previous_response_id not found: ${previousResponseId}`);
|
|
962
1001
|
}
|
|
963
1002
|
|
|
964
|
-
|
|
1003
|
+
// Re-hydrate the persisted Maps/Arrays back into the runtime types.
|
|
1004
|
+
const previousCallIdToName = previous ? new Map(Object.entries(previous.callIdToName)) : new Map<string, string>();
|
|
1005
|
+
const previousConversationMessages: ChatMessage[] = previous
|
|
1006
|
+
? (previous.conversationMessages as unknown as ChatMessage[])
|
|
1007
|
+
: [];
|
|
1008
|
+
|
|
1009
|
+
const parsed = parseResponsesInput(input.input, previousCallIdToName);
|
|
965
1010
|
const conversationMessages = [
|
|
966
|
-
...
|
|
1011
|
+
...previousConversationMessages,
|
|
967
1012
|
...parsed.messages,
|
|
968
1013
|
];
|
|
969
1014
|
const chatMessages = [
|
|
@@ -1086,12 +1131,13 @@ function saveResponsesEntry(
|
|
|
1086
1131
|
if (!responseId) return;
|
|
1087
1132
|
const { callIdToName } = buildResponsesOutput(completion);
|
|
1088
1133
|
const mergedConversation = [...conversationMessages, buildAssistantMessageFromCompletion(completion)];
|
|
1134
|
+
const expiresAt = Date.now() + 6 * 60 * 60 * 1000;
|
|
1089
1135
|
setStoredResponse(responseId, {
|
|
1090
1136
|
response,
|
|
1091
1137
|
inputItems,
|
|
1092
|
-
conversationMessages: mergedConversation,
|
|
1093
|
-
callIdToName,
|
|
1094
|
-
expiresAt
|
|
1138
|
+
conversationMessages: mergedConversation as unknown as Array<Record<string, unknown>>,
|
|
1139
|
+
callIdToName: Object.fromEntries(callIdToName),
|
|
1140
|
+
expiresAt,
|
|
1095
1141
|
});
|
|
1096
1142
|
}
|
|
1097
1143
|
|
|
@@ -1124,19 +1170,37 @@ export function openAIToAntigravityBody(input: OpenAIChatCompletionRequest): Req
|
|
|
1124
1170
|
for (let i = 0; i < conversationMessages.length; i++) {
|
|
1125
1171
|
const msg = conversationMessages[i];
|
|
1126
1172
|
if (msg.role === "assistant" || msg.role === "model") {
|
|
1173
|
+
// Strip dangling tool calls that do not have corresponding tool results (e.g. if some or all tool calls were cancelled/failed)
|
|
1174
|
+
let msgToolCalls = msg.tool_calls;
|
|
1175
|
+
if (Array.isArray(msgToolCalls) && msgToolCalls.length > 0) {
|
|
1176
|
+
const completedToolCallIds = new Set<string>();
|
|
1177
|
+
let j = i + 1;
|
|
1178
|
+
while (j < conversationMessages.length && conversationMessages[j].role === "tool") {
|
|
1179
|
+
const toolCallId = conversationMessages[j].tool_call_id;
|
|
1180
|
+
if (typeof toolCallId === "string") {
|
|
1181
|
+
completedToolCallIds.add(toolCallId);
|
|
1182
|
+
}
|
|
1183
|
+
j++;
|
|
1184
|
+
}
|
|
1185
|
+
msgToolCalls = msgToolCalls.filter((tc) => tc.id && completedToolCallIds.has(tc.id));
|
|
1186
|
+
if (msgToolCalls.length === 0) {
|
|
1187
|
+
msgToolCalls = undefined;
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1127
1191
|
// Check if this is a thinking model turn with tool calls that have no cached signatures.
|
|
1128
1192
|
// If so, we collapse the tool exchange into a neutral user summary instead of
|
|
1129
1193
|
// injecting [Tool call: ...] text that the model will learn to mimic.
|
|
1130
1194
|
const hasMissingSig =
|
|
1131
1195
|
isGeminiThinking &&
|
|
1132
|
-
Array.isArray(
|
|
1133
|
-
|
|
1134
|
-
!thoughtSignatureCache.has(
|
|
1196
|
+
Array.isArray(msgToolCalls) &&
|
|
1197
|
+
msgToolCalls.length > 0 &&
|
|
1198
|
+
!thoughtSignatureCache.has(msgToolCalls[0].id);
|
|
1135
1199
|
|
|
1136
1200
|
if (hasMissingSig) {
|
|
1137
1201
|
// Build a summary of what the model did and what results came back.
|
|
1138
1202
|
// We collect the paired tool result(s) from the immediately following messages.
|
|
1139
|
-
const toolNames =
|
|
1203
|
+
const toolNames = msgToolCalls!.map((tc) => tc.function.name).join(", ");
|
|
1140
1204
|
const resultParts: string[] = [];
|
|
1141
1205
|
while (i + 1 < conversationMessages.length && conversationMessages[i + 1].role === "tool") {
|
|
1142
1206
|
i++;
|
|
@@ -1156,13 +1220,13 @@ export function openAIToAntigravityBody(input: OpenAIChatCompletionRequest): Req
|
|
|
1156
1220
|
const textContent = typeof msg.content === "string" ? msg.content : extractText(msg.content);
|
|
1157
1221
|
if (textContent) parts.push({ text: textContent });
|
|
1158
1222
|
}
|
|
1159
|
-
if (Array.isArray(
|
|
1223
|
+
if (Array.isArray(msgToolCalls) && msgToolCalls.length > 0) {
|
|
1160
1224
|
// Use native Gemini functionCall parts. Re-inject thought_signature from
|
|
1161
1225
|
// the server-side cache if available. Google only validates signatures on
|
|
1162
1226
|
// the *current turn* (after the last real user text message), so missing
|
|
1163
1227
|
// signatures on older historical turns are silently ignored.
|
|
1164
1228
|
let isFirstInMessage = true;
|
|
1165
|
-
for (const tc of
|
|
1229
|
+
for (const tc of msgToolCalls) {
|
|
1166
1230
|
let args: unknown;
|
|
1167
1231
|
try {
|
|
1168
1232
|
args = typeof tc.function.arguments === "string" ? JSON.parse(tc.function.arguments) : tc.function.arguments;
|
|
@@ -1179,6 +1243,9 @@ export function openAIToAntigravityBody(input: OpenAIChatCompletionRequest): Req
|
|
|
1179
1243
|
isFirstInMessage = false;
|
|
1180
1244
|
}
|
|
1181
1245
|
}
|
|
1246
|
+
if (parts.length === 0) {
|
|
1247
|
+
parts.push({ text: "..." });
|
|
1248
|
+
}
|
|
1182
1249
|
if (parts.length > 0) {
|
|
1183
1250
|
// For Claude: handle two scenarios that break tool_use/tool_result ordering.
|
|
1184
1251
|
// 1. Text-only model turn after a functionCall model turn: Codex sends
|
|
@@ -1221,16 +1288,40 @@ export function openAIToAntigravityBody(input: OpenAIChatCompletionRequest): Req
|
|
|
1221
1288
|
? parsed
|
|
1222
1289
|
: { output: parsed };
|
|
1223
1290
|
} catch { responseData = { output: responseText }; }
|
|
1291
|
+
// Extract any images present in the tool result content
|
|
1292
|
+
const toolImages: AntigravityPart[] = [];
|
|
1293
|
+
if (msg.content && Array.isArray(msg.content)) {
|
|
1294
|
+
for (const part of msg.content) {
|
|
1295
|
+
if (part.type === "image_url" && isRecord(part.image_url) && typeof part.image_url.url === "string") {
|
|
1296
|
+
const inlineData = dataUrlToInlineData(part.image_url.url);
|
|
1297
|
+
if (inlineData) toolImages.push(inlineData);
|
|
1298
|
+
} else if (part.type === "image" && isRecord(part.source) && typeof part.source.data === "string") {
|
|
1299
|
+
const mediaType = typeof part.source.media_type === "string" ? part.source.media_type : "image/png";
|
|
1300
|
+
toolImages.push({ inlineData: { mimeType: mediaType, data: part.source.data } });
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1224
1305
|
// Include id only for Claude — Gemini native models reject the id field in functionResponse
|
|
1225
1306
|
const fnResponsePart = { functionResponse: { ...(isClaude && toolCallId ? { id: toolCallId } : {}), name: fnName, response: responseData } };
|
|
1226
1307
|
// Merge consecutive tool results into a single user turn.
|
|
1227
1308
|
// Claude (via Vertex) requires ALL tool_result blocks in one message
|
|
1228
1309
|
// directly after the assistant message with tool_use blocks.
|
|
1310
|
+
// Crucially, all tool_result (functionResponse) parts must appear before
|
|
1311
|
+
// any other part types (such as inlineData images) in the parts array.
|
|
1229
1312
|
const lastContent = contents[contents.length - 1];
|
|
1230
1313
|
if (lastContent && lastContent.role === "user" && Array.isArray(lastContent.parts) && lastContent.parts.length > 0 && isRecord(lastContent.parts[0] as any) && (lastContent.parts[0] as any).functionResponse !== undefined) {
|
|
1231
|
-
lastContent.parts.
|
|
1314
|
+
const firstNonFnIdx = lastContent.parts.findIndex((p: any) => !isRecord(p) || p.functionResponse === undefined);
|
|
1315
|
+
if (firstNonFnIdx === -1) {
|
|
1316
|
+
lastContent.parts.push(fnResponsePart);
|
|
1317
|
+
} else {
|
|
1318
|
+
lastContent.parts.splice(firstNonFnIdx, 0, fnResponsePart);
|
|
1319
|
+
}
|
|
1320
|
+
if (toolImages.length > 0) {
|
|
1321
|
+
lastContent.parts.push(...toolImages);
|
|
1322
|
+
}
|
|
1232
1323
|
} else {
|
|
1233
|
-
contents.push({ role: "user", parts: [fnResponsePart] });
|
|
1324
|
+
contents.push({ role: "user", parts: [fnResponsePart, ...toolImages] });
|
|
1234
1325
|
}
|
|
1235
1326
|
} else {
|
|
1236
1327
|
// user message
|
|
@@ -1320,10 +1411,7 @@ export function openAIToAntigravityBody(input: OpenAIChatCompletionRequest): Req
|
|
|
1320
1411
|
if (geminiTools.length > 0) request.tools = geminiTools;
|
|
1321
1412
|
if (geminiToolConfig) request.toolConfig = geminiToolConfig;
|
|
1322
1413
|
|
|
1323
|
-
|
|
1324
|
-
if (mappedModel === "gemini-3.1-pro-high") mappedModel = "gemini-pro-agent";
|
|
1325
|
-
if (mappedModel === "gemini-3.5-flash-high" || mappedModel === "gemini-3.5-flash" || mappedModel === "gemini-3.5-flash-medium") mappedModel = "gemini-3-flash-agent";
|
|
1326
|
-
if (mappedModel === "gpt-oss-120b") mappedModel = "gpt-oss-120b-medium";
|
|
1414
|
+
const mappedModel = applyModelAlias(input.model);
|
|
1327
1415
|
|
|
1328
1416
|
return {
|
|
1329
1417
|
project: "compat-placeholder",
|
|
@@ -1661,7 +1749,7 @@ async function readJsonBody(req: IncomingMessage): Promise<unknown> {
|
|
|
1661
1749
|
return JSON.parse(body.toString("utf-8"));
|
|
1662
1750
|
} catch (err) {
|
|
1663
1751
|
if (err instanceof PayloadTooLargeError) throw err;
|
|
1664
|
-
throw new Error("Invalid JSON body");
|
|
1752
|
+
throw new Error("Invalid JSON body", { cause: err });
|
|
1665
1753
|
}
|
|
1666
1754
|
}
|
|
1667
1755
|
|
|
@@ -1810,7 +1898,7 @@ async function streamCompatSse(
|
|
|
1810
1898
|
}
|
|
1811
1899
|
}
|
|
1812
1900
|
} catch (err) {
|
|
1813
|
-
|
|
1901
|
+
compatLogger.warn(`Stream read error: ${redactSensitive(String(err)).slice(0, 200)}`);
|
|
1814
1902
|
}
|
|
1815
1903
|
|
|
1816
1904
|
if (!reqClosed && !res.writableEnded) {
|
|
@@ -1988,7 +2076,7 @@ async function streamResponsesSse(
|
|
|
1988
2076
|
}
|
|
1989
2077
|
}
|
|
1990
2078
|
} catch (err) {
|
|
1991
|
-
|
|
2079
|
+
compatLogger.warn(`Responses stream read error: ${redactSensitive(String(err)).slice(0, 200)}`);
|
|
1992
2080
|
}
|
|
1993
2081
|
|
|
1994
2082
|
const completion: CompatCompletion = {
|
|
@@ -2294,12 +2382,13 @@ export async function handleOpenAIResponsesCreate(req: IncomingMessage, res: Ser
|
|
|
2294
2382
|
requestBody.requestId = responseId;
|
|
2295
2383
|
|
|
2296
2384
|
if (validation.value.store !== false) {
|
|
2385
|
+
const expiresAt = Date.now() + 6 * 60 * 60 * 1000;
|
|
2297
2386
|
setStoredResponse(responseId, {
|
|
2298
2387
|
response: buildResponsesResponse(validation.value, responseId, createdAt, { text: "", inputTokens: 0, outputTokens: 0, toolCalls: [] }, "in_progress", converted.previousResponseId),
|
|
2299
2388
|
inputItems: converted.inputItems,
|
|
2300
|
-
conversationMessages: converted.conversationMessages,
|
|
2301
|
-
callIdToName:
|
|
2302
|
-
expiresAt
|
|
2389
|
+
conversationMessages: converted.conversationMessages as unknown as Array<Record<string, unknown>>,
|
|
2390
|
+
callIdToName: {},
|
|
2391
|
+
expiresAt,
|
|
2303
2392
|
});
|
|
2304
2393
|
}
|
|
2305
2394
|
|
package/src/dashboard.ts
CHANGED
|
@@ -10,19 +10,19 @@ export function serveDashboard(res: ServerResponse): void {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
export function serveStatusApi(res: ServerResponse, rotator: AccountRotator): void {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
res.writeHead(200, {
|
|
14
|
+
"Content-Type": "application/json",
|
|
15
|
+
"Cache-Control": "no-store",
|
|
16
|
+
});
|
|
17
|
+
res.end(JSON.stringify(rotator.getStatus()));
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
export function serveConfigApi(res: ServerResponse, rotator: AccountRotator): void {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
res.writeHead(200, {
|
|
22
|
+
"Content-Type": "application/json",
|
|
23
|
+
"Cache-Control": "no-store",
|
|
24
|
+
});
|
|
25
|
+
res.end(JSON.stringify(rotator.getConfig()));
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
export function serveConfigExportApi(res: ServerResponse, rotator: AccountRotator): void {
|
|
@@ -2843,7 +2843,7 @@ function escapeHtml(text) {
|
|
|
2843
2843
|
.replace(/&/g, '&')
|
|
2844
2844
|
.replace(/</g, '<')
|
|
2845
2845
|
.replace(/>/g, '>')
|
|
2846
|
-
.replace(
|
|
2846
|
+
.replace(/"/g, '"')
|
|
2847
2847
|
.replace(/'/g, ''');
|
|
2848
2848
|
}
|
|
2849
2849
|
|