9router/open-sse/executors/antigravity.js

512 lines
18 KiB
JavaScript
Raw Normal View History

import crypto from "crypto";
import { BaseExecutor } from "./base.js";
2026-03-12 05:20:46 -04:00
import { PROVIDERS } from "../config/providers.js";
2026-04-01 00:48:38 -04:00
import { OAUTH_ENDPOINTS, ANTIGRAVITY_HEADERS, INTERNAL_REQUEST_HEADER, AG_DEFAULT_TOOLS, AG_TOOL_SUFFIX } from "../config/appConstants.js";
2026-03-12 05:20:46 -04:00
import { HTTP_STATUS } from "../config/runtimeConfig.js";
import { deriveSessionId } from "../utils/sessionManager.js";
import { proxyAwareFetch } from "../utils/proxyFetch.js";
import { cleanJSONSchemaForAntigravity } from "../translator/helpers/geminiHelper.js";
// Sanitize function name: Gemini requires [a-zA-Z_][a-zA-Z0-9_.:\-]{0,63}
function sanitizeFunctionName(name) {
if (!name) return "_unknown";
let s = name.replace(/[^a-zA-Z0-9_.:\-]/g, "_");
if (!/^[a-zA-Z_]/.test(s)) s = "_" + s;
return s.substring(0, 64);
}
2026-01-28 04:21:12 -05:00
const MAX_RETRY_AFTER_MS = 10000;
const MAX_ANTIGRAVITY_OUTPUT_TOKENS = 16384;
2026-01-12 04:53:27 -05:00
export class AntigravityExecutor extends BaseExecutor {
constructor() {
super("antigravity", PROVIDERS.antigravity);
}
buildUrl(model, stream, urlIndex = 0) {
const baseUrls = this.getBaseUrls();
const baseUrl = baseUrls[urlIndex] || baseUrls[0];
const action = stream ? "streamGenerateContent?alt=sse" : "generateContent";
return `${baseUrl}/v1internal:${action}`;
}
buildHeaders(credentials, stream = true, sessionId = null) {
return {
"Content-Type": "application/json",
"Authorization": `Bearer ${credentials.accessToken}`,
"User-Agent": this.config.headers?.["User-Agent"] || ANTIGRAVITY_HEADERS["User-Agent"],
[INTERNAL_REQUEST_HEADER.name]: INTERNAL_REQUEST_HEADER.value,
...(sessionId && { "X-Machine-Session-Id": sessionId }),
"Accept": stream ? "text/event-stream" : "application/json"
};
}
transformRequest(model, body, stream, credentials) {
const projectId = credentials?.projectId || this.generateProjectId();
// Fix contents for Claude models via Antigravity
const contents = body.request?.contents?.map(c => {
let role = c.role;
// functionResponse must be role "user" for Claude models
if (c.parts?.some(p => p.functionResponse)) {
role = "user";
}
// Strip thought-only parts, keep thoughtSignature on functionCall parts (Gemini 3+ requires it)
const parts = c.parts?.filter(p => {
if (p.thought && !p.functionCall) return false;
if (p.thoughtSignature && !p.functionCall && !p.text) return false;
return true;
});
if (role !== c.role || parts?.length !== c.parts?.length) {
return { ...c, role, parts };
}
return c;
});
// Sanitize tool schemas and function names before sending to Antigravity.
let tools = body.request?.tools;
if (tools && tools.length > 0) {
tools = tools
.map(group => {
if (!group.functionDeclarations) return group;
const cleanedDeclarations = group.functionDeclarations.map(fn => ({
...fn,
name: sanitizeFunctionName(fn.name),
parameters: fn.parameters
? cleanJSONSchemaForAntigravity(structuredClone(fn.parameters))
: { type: "object", properties: { reason: { type: "string", description: "Brief explanation" } }, required: ["reason"] }
}));
return {
...group,
functionDeclarations: cleanedDeclarations
};
})
.filter(group => group.functionDeclarations?.length > 0)
.slice(0, 1);
}
const { tools: _originalTools, toolConfig: _originalToolConfig, ...requestWithoutTools } = body.request || {};
const generationConfig = { ...(requestWithoutTools.generationConfig || {}) };
if (generationConfig.maxOutputTokens > MAX_ANTIGRAVITY_OUTPUT_TOKENS) {
generationConfig.maxOutputTokens = MAX_ANTIGRAVITY_OUTPUT_TOKENS;
}
2026-01-12 04:53:27 -05:00
const transformedRequest = {
...requestWithoutTools,
generationConfig,
...(contents && { contents }),
...(tools && { tools }),
sessionId: body.request?.sessionId || deriveSessionId(credentials?.email || credentials?.connectionId),
2026-01-12 04:53:27 -05:00
safetySettings: undefined,
...(tools?.length > 0 && { toolConfig: { functionCallingConfig: { mode: "VALIDATED" } } })
2026-01-12 04:53:27 -05:00
};
return {
...body,
project: projectId,
model: model,
userAgent: "antigravity",
requestType: "agent",
requestId: `agent-${crypto.randomUUID()}`,
2026-01-12 04:53:27 -05:00
request: transformedRequest
};
}
async refreshCredentials(credentials, log, proxyOptions = null) {
if (!credentials.refreshToken) return null;
try {
const response = await proxyAwareFetch(OAUTH_ENDPOINTS.google.token, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: credentials.refreshToken,
client_id: this.config.clientId,
client_secret: this.config.clientSecret
})
}, proxyOptions);
if (!response.ok) return null;
const tokens = await response.json();
log?.info?.("TOKEN", "Antigravity refreshed");
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || credentials.refreshToken,
expiresIn: tokens.expires_in,
projectId: credentials.projectId
};
} catch (error) {
log?.error?.("TOKEN", `Antigravity refresh error: ${error.message}`);
return null;
}
}
generateProjectId() {
const adj = ["useful", "bright", "swift", "calm", "bold"][Math.floor(Math.random() * 5)];
const noun = ["fuze", "wave", "spark", "flow", "core"][Math.floor(Math.random() * 5)];
return `${adj}-${noun}-${crypto.randomUUID().slice(0, 5)}`;
}
generateSessionId() {
return crypto.randomUUID() + Date.now().toString();
}
2026-01-12 04:53:27 -05:00
parseRetryHeaders(headers) {
if (!headers?.get) return null;
const retryAfter = headers.get('retry-after');
if (retryAfter) {
const seconds = parseInt(retryAfter, 10);
if (!isNaN(seconds) && seconds > 0) return seconds * 1000;
2026-01-12 04:53:27 -05:00
const date = new Date(retryAfter);
if (!isNaN(date.getTime())) {
const diff = date.getTime() - Date.now();
return diff > 0 ? diff : null;
}
}
const resetAfter = headers.get('x-ratelimit-reset-after');
if (resetAfter) {
const seconds = parseInt(resetAfter, 10);
if (!isNaN(seconds) && seconds > 0) return seconds * 1000;
}
const resetTimestamp = headers.get('x-ratelimit-reset');
if (resetTimestamp) {
const ts = parseInt(resetTimestamp, 10) * 1000;
const diff = ts - Date.now();
return diff > 0 ? diff : null;
}
return null;
}
2026-01-14 02:55:47 -05:00
// Parse retry time from Antigravity error message body
// Format: "Your quota will reset after 2h7m23s" or "1h30m" or "45m" or "30s"
parseRetryFromErrorMessage(errorMessage) {
if (!errorMessage || typeof errorMessage !== "string") return null;
const match = errorMessage.match(/reset after (\d+h)?(\d+m)?(\d+s)?/i);
if (!match) return null;
let totalMs = 0;
if (match[1]) totalMs += parseInt(match[1]) * 3600 * 1000; // hours
if (match[2]) totalMs += parseInt(match[2]) * 60 * 1000; // minutes
if (match[3]) totalMs += parseInt(match[3]) * 1000; // seconds
return totalMs > 0 ? totalMs : null;
}
async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) {
2026-01-12 04:53:27 -05:00
const fallbackCount = this.getFallbackCount();
let lastError = null;
let lastStatus = 0;
2026-01-26 22:49:16 -05:00
const MAX_AUTO_RETRIES = 3;
2026-03-13 22:37:29 -04:00
const MAX_RETRY_AFTER_RETRIES = 3;
2026-01-13 05:19:41 -05:00
const retryAttemptsByUrl = {}; // Track retry attempts per URL
2026-03-13 22:37:29 -04:00
const retryAfterAttemptsByUrl = {}; // Track Retry-After retries per URL
2026-01-12 04:53:27 -05:00
for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) {
const url = this.buildUrl(model, stream, urlIndex);
const transformedBody = this.transformRequest(model, body, stream, credentials);
const sessionId = transformedBody.request?.sessionId;
const headers = this.buildHeaders(credentials, stream, sessionId);
2026-03-13 22:37:29 -04:00
// Initialize retry counters for this URL
2026-01-13 05:19:41 -05:00
if (!retryAttemptsByUrl[urlIndex]) {
retryAttemptsByUrl[urlIndex] = 0;
}
2026-03-13 22:37:29 -04:00
if (!retryAfterAttemptsByUrl[urlIndex]) {
retryAfterAttemptsByUrl[urlIndex] = 0;
}
2026-01-12 04:53:27 -05:00
try {
const response = await proxyAwareFetch(url, {
2026-01-12 04:53:27 -05:00
method: "POST",
headers,
body: JSON.stringify(transformedBody),
signal
}, proxyOptions);
2026-01-12 04:53:27 -05:00
if (response.status === HTTP_STATUS.RATE_LIMITED || response.status === HTTP_STATUS.SERVICE_UNAVAILABLE) {
2026-01-14 02:55:47 -05:00
// Try to get retry time from headers first
let retryMs = this.parseRetryHeaders(response.headers);
// If no retry time in headers, try to parse from error message body
if (!retryMs) {
try {
const errorBody = await response.clone().text();
const errorJson = JSON.parse(errorBody);
const errorMessage = errorJson?.error?.message || errorJson?.message || "";
retryMs = this.parseRetryFromErrorMessage(errorMessage);
} catch (e) {
// Ignore parse errors, will fall back to exponential backoff
}
}
2026-01-12 04:53:27 -05:00
2026-03-13 22:37:29 -04:00
if (retryMs && retryMs <= MAX_RETRY_AFTER_MS && retryAfterAttemptsByUrl[urlIndex] < MAX_RETRY_AFTER_RETRIES) {
retryAfterAttemptsByUrl[urlIndex]++;
log?.debug?.("RETRY", `${response.status} with Retry-After: ${Math.ceil(retryMs / 1000)}s, waiting... (${retryAfterAttemptsByUrl[urlIndex]}/${MAX_RETRY_AFTER_RETRIES})`);
2026-01-12 04:53:27 -05:00
await new Promise(resolve => setTimeout(resolve, retryMs));
urlIndex--;
continue;
}
// Auto retry only for 429 when retryMs is 0 or undefined
if (response.status === HTTP_STATUS.RATE_LIMITED && (!retryMs || retryMs === 0) && retryAttemptsByUrl[urlIndex] < MAX_AUTO_RETRIES) {
2026-01-13 05:19:41 -05:00
retryAttemptsByUrl[urlIndex]++;
// Exponential backoff: 2s, 4s, 8s...
2026-01-14 02:55:47 -05:00
const backoffMs = Math.min(1000 * (2 ** retryAttemptsByUrl[urlIndex]), MAX_RETRY_AFTER_MS);
log?.debug?.("RETRY", `429 auto retry ${retryAttemptsByUrl[urlIndex]}/${MAX_AUTO_RETRIES} after ${backoffMs / 1000}s`);
2026-01-13 05:19:41 -05:00
await new Promise(resolve => setTimeout(resolve, backoffMs));
2026-01-12 04:53:27 -05:00
urlIndex--;
continue;
}
log?.debug?.("RETRY", `${response.status}, Retry-After ${retryMs ? `too long (${Math.ceil(retryMs / 1000)}s)` : 'missing'}, trying fallback`);
2026-01-12 04:53:27 -05:00
lastStatus = response.status;
2026-01-12 04:53:27 -05:00
if (urlIndex + 1 < fallbackCount) {
continue;
}
}
if (this.shouldRetry(response.status, urlIndex)) {
log?.debug?.("RETRY", `${response.status} on ${url}, trying fallback ${urlIndex + 1}`);
lastStatus = response.status;
continue;
}
return { response, url, headers, transformedBody };
} catch (error) {
lastError = error;
if (urlIndex + 1 < fallbackCount) {
log?.debug?.("RETRY", `Error on ${url}, trying fallback ${urlIndex + 1}`);
continue;
}
throw error;
}
}
throw lastError || new Error(`All ${fallbackCount} URLs failed with status ${lastStatus}`);
}
2026-03-30 01:21:24 -04:00
/**
* Cloak tools before sending to Antigravity provider (anti-ban):
2026-04-01 00:48:38 -04:00
* - Rename client tools with _ide suffix
* - Inject AG default decoy tools after client tools
* Returns { cloakedBody, toolNameMap } where toolNameMap maps suffixed → original
2026-03-30 01:21:24 -04:00
*/
static cloakTools(body, clientTool = null) {
2026-03-30 01:21:24 -04:00
const tools = body.request?.tools;
if (!tools || tools.length === 0) {
return { cloakedBody: body, toolNameMap: null };
}
const isCopilot = clientTool === "github-copilot";
2026-03-30 01:21:24 -04:00
const toolNameMap = new Map();
2026-04-01 00:48:38 -04:00
const clientDeclarations = [];
const decoyNames = new Set(AG_DECOY_TOOLS.map(tool => tool.name));
2026-03-30 01:21:24 -04:00
2026-04-01 00:48:38 -04:00
// First: collect renamed client tools
2026-03-30 01:21:24 -04:00
for (const toolGroup of tools) {
if (!toolGroup.functionDeclarations) continue;
for (const func of toolGroup.functionDeclarations) {
// For GitHub Copilot, avoid emitting duplicate native Antigravity tool names.
// Keep the decoys only once in the final declaration list.
if (isCopilot && AG_DEFAULT_TOOLS.has(func.name)) {
continue;
}
// Skip if already covered by decoys for Copilot
if (isCopilot && decoyNames.has(func.name)) {
continue;
}
// Preserve native AG names for non-Copilot clients
2026-03-30 01:21:24 -04:00
if (AG_DEFAULT_TOOLS.has(func.name)) {
2026-04-01 00:48:38 -04:00
clientDeclarations.push(func);
2026-03-30 01:21:24 -04:00
continue;
}
2026-04-01 00:48:38 -04:00
const suffixed = `${func.name}${AG_TOOL_SUFFIX}`;
toolNameMap.set(suffixed, func.name);
clientDeclarations.push({ ...func, name: suffixed });
2026-03-30 01:21:24 -04:00
}
}
2026-04-01 00:48:38 -04:00
// Client tools first, then AG decoy tools
const allDeclarations = [];
const seenNames = new Set();
for (const decl of [...clientDeclarations, ...AG_DECOY_TOOLS]) {
if (!decl?.name || seenNames.has(decl.name)) continue;
seenNames.add(decl.name);
allDeclarations.push(decl);
}
2026-04-01 00:48:38 -04:00
2026-03-30 01:21:24 -04:00
// Rename tool names in conversation history (contents)
const cloakedContents = body.request?.contents?.map(msg => {
if (!msg.parts) return msg;
const cloakedParts = msg.parts.map(part => {
// Rename functionCall.name
if (part.functionCall && !AG_DEFAULT_TOOLS.has(part.functionCall.name)) {
return {
...part,
functionCall: {
...part.functionCall,
2026-04-01 00:48:38 -04:00
name: `${part.functionCall.name}${AG_TOOL_SUFFIX}`
2026-03-30 01:21:24 -04:00
}
};
}
// Rename functionResponse.name
if (part.functionResponse && !AG_DEFAULT_TOOLS.has(part.functionResponse.name)) {
return {
...part,
functionResponse: {
...part.functionResponse,
2026-04-01 00:48:38 -04:00
name: `${part.functionResponse.name}${AG_TOOL_SUFFIX}`
2026-03-30 01:21:24 -04:00
}
};
}
return part;
});
return { ...msg, parts: cloakedParts };
});
2026-04-01 00:48:38 -04:00
// Single functionDeclarations group: client tools first, then decoys
2026-03-30 01:21:24 -04:00
return {
cloakedBody: {
...body,
request: {
...body.request,
tools: [{ functionDeclarations: allDeclarations }],
contents: cloakedContents || body.request.contents
}
},
toolNameMap
};
}
}
2026-04-01 00:48:38 -04:00
// AG decoy tools — same names as AG native defaults, redirect to _ide suffixed tools
2026-03-30 01:21:24 -04:00
const AG_DECOY_TOOLS = [
{
name: "browser_subagent",
2026-04-01 00:48:38 -04:00
description: "This tool is currently unavailable.",
2026-03-30 01:21:24 -04:00
parameters: { type: "OBJECT", properties: {}, required: [] }
},
{
name: "command_status",
2026-04-01 00:48:38 -04:00
description: "This tool is currently unavailable.",
2026-03-30 01:21:24 -04:00
parameters: { type: "OBJECT", properties: {}, required: [] }
},
{
name: "find_by_name",
2026-04-01 00:48:38 -04:00
description: "This tool is currently unavailable.",
2026-03-30 01:21:24 -04:00
parameters: { type: "OBJECT", properties: {}, required: [] }
},
{
name: "generate_image",
2026-04-01 00:48:38 -04:00
description: "This tool is currently unavailable.",
2026-03-30 01:21:24 -04:00
parameters: { type: "OBJECT", properties: {}, required: [] }
},
{
name: "grep_search",
2026-04-01 00:48:38 -04:00
description: "This tool is currently unavailable.",
2026-03-30 01:21:24 -04:00
parameters: { type: "OBJECT", properties: {}, required: [] }
},
{
name: "list_dir",
2026-04-01 00:48:38 -04:00
description: "This tool is currently unavailable.",
2026-03-30 01:21:24 -04:00
parameters: { type: "OBJECT", properties: {}, required: [] }
},
{
name: "list_resources",
2026-04-01 00:48:38 -04:00
description: "This tool is currently unavailable.",
2026-03-30 01:21:24 -04:00
parameters: { type: "OBJECT", properties: {}, required: [] }
},
{
name: "mcp_sequential-thinking_sequentialthinking",
2026-04-01 00:48:38 -04:00
description: "This tool is currently unavailable.",
2026-03-30 01:21:24 -04:00
parameters: { type: "OBJECT", properties: {}, required: [] }
},
{
name: "multi_replace_file_content",
2026-04-01 00:48:38 -04:00
description: "This tool is currently unavailable.",
2026-03-30 01:21:24 -04:00
parameters: { type: "OBJECT", properties: {}, required: [] }
},
{
name: "notify_user",
2026-04-01 00:48:38 -04:00
description: "This tool is currently unavailable.",
2026-03-30 01:21:24 -04:00
parameters: { type: "OBJECT", properties: {}, required: [] }
},
{
name: "read_resource",
2026-04-01 00:48:38 -04:00
description: "This tool is currently unavailable.",
2026-03-30 01:21:24 -04:00
parameters: { type: "OBJECT", properties: {}, required: [] }
},
{
name: "read_terminal",
2026-04-01 00:48:38 -04:00
description: "This tool is currently unavailable.",
2026-03-30 01:21:24 -04:00
parameters: { type: "OBJECT", properties: {}, required: [] }
},
{
name: "read_url_content",
2026-04-01 00:48:38 -04:00
description: "This tool is currently unavailable.",
2026-03-30 01:21:24 -04:00
parameters: { type: "OBJECT", properties: {}, required: [] }
},
{
name: "replace_file_content",
2026-04-01 00:48:38 -04:00
description: "This tool is currently unavailable.",
2026-03-30 01:21:24 -04:00
parameters: { type: "OBJECT", properties: {}, required: [] }
},
{
name: "run_command",
2026-04-01 00:48:38 -04:00
description: "This tool is currently unavailable.",
2026-03-30 01:21:24 -04:00
parameters: { type: "OBJECT", properties: {}, required: [] }
},
{
name: "search_web",
2026-04-01 00:48:38 -04:00
description: "This tool is currently unavailable.",
2026-03-30 01:21:24 -04:00
parameters: { type: "OBJECT", properties: {}, required: [] }
},
{
name: "send_command_input",
2026-04-01 00:48:38 -04:00
description: "This tool is currently unavailable.",
2026-03-30 01:21:24 -04:00
parameters: { type: "OBJECT", properties: {}, required: [] }
},
{
name: "task_boundary",
2026-04-01 00:48:38 -04:00
description: "This tool is currently unavailable.",
2026-03-30 01:21:24 -04:00
parameters: { type: "OBJECT", properties: {}, required: [] }
},
{
name: "view_content_chunk",
2026-04-01 00:48:38 -04:00
description: "This tool is currently unavailable.",
2026-03-30 01:21:24 -04:00
parameters: { type: "OBJECT", properties: {}, required: [] }
},
{
name: "view_file",
2026-04-01 00:48:38 -04:00
description: "This tool is currently unavailable.",
2026-03-30 01:21:24 -04:00
parameters: { type: "OBJECT", properties: {}, required: [] }
},
{
name: "write_to_file",
2026-04-01 00:48:38 -04:00
description: "This tool is currently unavailable.",
2026-03-30 01:21:24 -04:00
parameters: { type: "OBJECT", properties: {}, required: [] }
}
];
export default AntigravityExecutor;