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 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.6",
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
- "check": "npm run typecheck && npm test"
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
- "typescript": "^5.7.0"
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
  }
@@ -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
- return token ? token : null;
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
- interface StoredResponseEntry {
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 MODEL_SPECS: Record<string, ModelSpec> = {
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 (MODEL_SPECS[lower]) return MODEL_SPECS[lower];
187
- for (const [key, spec] of Object.entries(MODEL_SPECS)) {
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 RESPONSES_STORE_TTL_MS = 6 * 60 * 60 * 1000;
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
- pruneResponsesStore();
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
- let messages: ChatMessage[] | null = null;
812
- if ("messages" in value) messages = messagesFromLooseMessages(value.messages);
813
- else if (typeof value.prompt === "string") messages = [{ role: "user", content: value.prompt }];
814
- else if (Array.isArray(value.prompt)) messages = messagesFromResponsesInput(value.prompt) ?? value.prompt.map((prompt) => ({ role: "user", content: String(prompt) }));
815
- else if ("input" in value) messages = messagesFromResponsesInput(value.input);
816
- else messages = messagesFromAntigravityRequest(value);
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
- compatLogger.warn(`OpenAI messages validation failed: ${JSON.stringify(value.messages)}`);
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
- const parsed = parseResponsesInput(input.input, previous?.callIdToName);
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
- ...(previous?.conversationMessages ?? []),
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: Date.now() + RESPONSES_STORE_TTL_MS,
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(msg.tool_calls) &&
1133
- msg.tool_calls.length > 0 &&
1134
- !thoughtSignatureCache.has(msg.tool_calls[0].id);
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 = msg.tool_calls!.map((tc) => tc.function.name).join(", ");
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(msg.tool_calls) && msg.tool_calls.length > 0) {
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 msg.tool_calls) {
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.push(fnResponsePart);
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
- let mappedModel = input.model;
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
- compatLogger.warn(`Stream read error: ${err}`);
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
- compatLogger.warn(`Responses stream read error: ${err}`);
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: new Map(),
2302
- expiresAt: Date.now() + RESPONSES_STORE_TTL_MS,
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
- res.writeHead(200, {
14
- "Content-Type": "application/json",
15
- "Access-Control-Allow-Origin": "*",
16
- });
17
- res.end(JSON.stringify(rotator.getStatus()));
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
- res.writeHead(200, {
22
- "Content-Type": "application/json",
23
- "Access-Control-Allow-Origin": "*",
24
- });
25
- res.end(JSON.stringify(rotator.getConfig()));
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, '&amp;')
2844
2844
  .replace(/</g, '&lt;')
2845
2845
  .replace(/>/g, '&gt;')
2846
- .replace(/\"/g, '&quot;')
2846
+ .replace(/"/g, '&quot;')
2847
2847
  .replace(/'/g, '&#39;');
2848
2848
  }
2849
2849