import crypto from "crypto"; import { BaseExecutor } from "./base.js"; import { PROVIDERS } from "../config/providers.js"; import { OAUTH_ENDPOINTS, ANTIGRAVITY_HEADERS, INTERNAL_REQUEST_HEADER, AG_DEFAULT_TOOLS, AG_TOOL_SUFFIX } from "../config/appConstants.js"; import { HTTP_STATUS } from "../config/runtimeConfig.js"; import { resolveSessionId } from "../utils/sessionManager.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js"; import { cleanJSONSchemaForAntigravity } from "../translator/formats/gemini.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); } const MAX_RETRY_AFTER_MS = 10000; const MAX_ANTIGRAVITY_OUTPUT_TOKENS = 16384; // Fields Google generateContent rejects (Claude/OpenAI/Qwen thinking fields set at body root by thinkingUnified.js) const ANTIGRAVITY_REQUEST_BLACKLIST = [ "output_config", "thinking", "reasoning_effort", "reasoning", "enable_thinking", "thinking_budget", "thinkingConfig", ]; // Image generation model name patterns const IMAGE_MODEL_PATTERNS = [ /image/i, /imagen/i, /image-generation/i, ]; // Detect if a model is an image generation model function isImageModel(model) { if (!model) return false; return IMAGE_MODEL_PATTERNS.some(p => p.test(model)); } // Parse aspect ratio / resolution from model name suffixes // e.g. "gemini-3.1-flash-image-16x9" -> { aspectRatio: "16:9" } // e.g. "gemini-3.1-flash-image-1024x768" -> { aspectRatio: "4:3" } function parseImageConfig(model) { const config = { aspectRatio: "1:1" }; const resMatch = model.match(/(\d+)x(\d+)$/); if (resMatch) { const w = parseInt(resMatch[1]); const h = parseInt(resMatch[2]); if (w <= 16 && h <= 16) { config.aspectRatio = `${w}:${h}`; } else { // Resolution like 1024x768 — derive aspect ratio const gcd = (a, b) => b ? gcd(b, a % b) : a; const d = gcd(w, h); config.aspectRatio = `${w/d}:${h/d}`; } } return config; } 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]; // Image generation MUST use non-streaming generateContent const forceNonStream = isImageModel(model); const action = (stream && !forceNonStream) ? "streamGenerateContent?alt=sse" : "generateContent"; return `${baseUrl}/v1internal:${action}`; } // sessionId comes from transformRequest output; base.execute runs transformRequest before // buildHeaders, so we read it from instance state cached there (fallback: explicit arg). buildHeaders(credentials, stream = true, sessionId = null) { const sid = sessionId || this._lastSessionId; 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, ...(sid && { "X-Machine-Session-Id": sid }), "Accept": stream ? "text/event-stream" : "application/json" }; } transformRequest(model, body, stream, credentials) { const projectId = credentials?.projectId || this.generateProjectId(); // ─── Image generation: completely different request structure ─── if (isImageModel(model)) { const imageConfig = parseImageConfig(model); // Strip model name suffixes for the actual API model name const cleanModel = model.replace(/-(\d+)x(\d+)$/, ""); // Build simplified contents — text-only, merge all user messages const contents = []; const srcContents = body.request?.contents || body.contents || []; for (const c of srcContents) { const textParts = (c.parts || []).filter(p => p.text !== undefined).map(p => ({ text: p.text })); if (textParts.length > 0) { contents.push({ role: c.role || "user", parts: textParts }); } } const sessionId = resolveSessionId({ headers: credentials?.rawHeaders, body, connectionId: credentials?.email || credentials?.connectionId, scope: "antigravity", }); this._lastSessionId = sessionId; return { project: projectId, model: cleanModel, userAgent: "antigravity", requestType: "image_gen", requestId: `agent-${crypto.randomUUID()}`, request: { contents, generationConfig: { temperature: 1.0, topP: 0.95, topK: 40, maxOutputTokens: 8192, imageConfig, }, sessionId, // No tools, no systemInstruction, no safetySettings for image gen }, }; } // ─── Standard (non-image) request ─── // 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) { // Merge all groups into a single functionDeclarations group (Gemini expects 1 group) const allDeclarations = tools.flatMap(group => (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"] } })) ); tools = allDeclarations.length > 0 ? [{ functionDeclarations: allDeclarations }] : []; } // Strip tools/toolConfig (handled separately) and blacklisted fields that Google rejects const { tools: _originalTools, toolConfig: _originalToolConfig, ...requestWithoutTools } = body.request || {}; for (const key of ANTIGRAVITY_REQUEST_BLACKLIST) delete requestWithoutTools[key]; const generationConfig = { ...(requestWithoutTools.generationConfig || {}) }; if (generationConfig.maxOutputTokens > MAX_ANTIGRAVITY_OUTPUT_TOKENS) { generationConfig.maxOutputTokens = MAX_ANTIGRAVITY_OUTPUT_TOKENS; } const transformedRequest = { ...requestWithoutTools, generationConfig, ...(contents && { contents }), ...(tools && { tools }), sessionId: body.request?.sessionId || resolveSessionId({ headers: credentials?.rawHeaders, body, connectionId: credentials?.email || credentials?.connectionId, scope: "antigravity" }), safetySettings: undefined, ...(tools?.length > 0 && { toolConfig: { functionCallingConfig: { mode: "VALIDATED" } } }) }; this._lastSessionId = transformedRequest.sessionId; // cached for buildHeaders (base.execute order) return { ...body, project: projectId, model: model, userAgent: "antigravity", requestType: "agent", requestId: `agent-${crypto.randomUUID()}`, 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(); } 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; 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; } // 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; } // Hook called by BaseExecutor.tryRetry: derive delay from Retry-After (header → body), // cap at MAX_RETRY_AFTER_MS, else exponential backoff for 429. Return false to veto (fallback URL). async computeRetryDelay(response, attempt) { let retryMs = this.parseRetryHeaders(response.headers); if (!retryMs) { try { const errorJson = JSON.parse(await response.clone().text()); retryMs = this.parseRetryFromErrorMessage(errorJson?.error?.message || errorJson?.message || ""); } catch { // ignore parse errors → fall through to backoff } } if (retryMs) return retryMs <= MAX_RETRY_AFTER_MS ? retryMs : false; if (response.status === HTTP_STATUS.RATE_LIMITED) { return Math.min(1000 * (2 ** attempt), MAX_RETRY_AFTER_MS); // exponential backoff } return false; } /** * Cloak tools before sending to Antigravity provider (anti-ban): * - Rename client tools with _ide suffix * - Inject AG default decoy tools after client tools * Returns { cloakedBody, toolNameMap } where toolNameMap maps suffixed → original */ static cloakTools(body, clientTool = null) { const tools = body.request?.tools; if (!tools || tools.length === 0) { return { cloakedBody: body, toolNameMap: null }; } const isCopilot = clientTool === "github-copilot"; const toolNameMap = new Map(); const clientDeclarations = []; const decoyNames = new Set(AG_DECOY_TOOLS.map(tool => tool.name)); // First: collect renamed client tools 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 if (AG_DEFAULT_TOOLS.has(func.name)) { clientDeclarations.push(func); continue; } const suffixed = `${func.name}${AG_TOOL_SUFFIX}`; toolNameMap.set(suffixed, func.name); clientDeclarations.push({ ...func, name: suffixed }); } } // 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); } // 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, name: `${part.functionCall.name}${AG_TOOL_SUFFIX}` } }; } // Rename functionResponse.name if (part.functionResponse && !AG_DEFAULT_TOOLS.has(part.functionResponse.name)) { return { ...part, functionResponse: { ...part.functionResponse, name: `${part.functionResponse.name}${AG_TOOL_SUFFIX}` } }; } return part; }); return { ...msg, parts: cloakedParts }; }); // Single functionDeclarations group: client tools first, then decoys return { cloakedBody: { ...body, request: { ...body.request, tools: [{ functionDeclarations: allDeclarations }], contents: cloakedContents || body.request.contents } }, toolNameMap }; } } // AG decoy tools — same names as AG native defaults, redirect to _ide suffixed tools const AG_DECOY_TOOLS = [ { name: "browser_subagent", description: "This tool is currently unavailable.", parameters: { type: "OBJECT", properties: {}, required: [] } }, { name: "command_status", description: "This tool is currently unavailable.", parameters: { type: "OBJECT", properties: {}, required: [] } }, { name: "find_by_name", description: "This tool is currently unavailable.", parameters: { type: "OBJECT", properties: {}, required: [] } }, { name: "generate_image", description: "This tool is currently unavailable.", parameters: { type: "OBJECT", properties: {}, required: [] } }, { name: "grep_search", description: "This tool is currently unavailable.", parameters: { type: "OBJECT", properties: {}, required: [] } }, { name: "list_dir", description: "This tool is currently unavailable.", parameters: { type: "OBJECT", properties: {}, required: [] } }, { name: "list_resources", description: "This tool is currently unavailable.", parameters: { type: "OBJECT", properties: {}, required: [] } }, { name: "mcp_sequential-thinking_sequentialthinking", description: "This tool is currently unavailable.", parameters: { type: "OBJECT", properties: {}, required: [] } }, { name: "multi_replace_file_content", description: "This tool is currently unavailable.", parameters: { type: "OBJECT", properties: {}, required: [] } }, { name: "notify_user", description: "This tool is currently unavailable.", parameters: { type: "OBJECT", properties: {}, required: [] } }, { name: "read_resource", description: "This tool is currently unavailable.", parameters: { type: "OBJECT", properties: {}, required: [] } }, { name: "read_terminal", description: "This tool is currently unavailable.", parameters: { type: "OBJECT", properties: {}, required: [] } }, { name: "read_url_content", description: "This tool is currently unavailable.", parameters: { type: "OBJECT", properties: {}, required: [] } }, { name: "replace_file_content", description: "This tool is currently unavailable.", parameters: { type: "OBJECT", properties: {}, required: [] } }, { name: "run_command", description: "This tool is currently unavailable.", parameters: { type: "OBJECT", properties: {}, required: [] } }, { name: "search_web", description: "This tool is currently unavailable.", parameters: { type: "OBJECT", properties: {}, required: [] } }, { name: "send_command_input", description: "This tool is currently unavailable.", parameters: { type: "OBJECT", properties: {}, required: [] } }, { name: "task_boundary", description: "This tool is currently unavailable.", parameters: { type: "OBJECT", properties: {}, required: [] } }, { name: "view_content_chunk", description: "This tool is currently unavailable.", parameters: { type: "OBJECT", properties: {}, required: [] } }, { name: "view_file", description: "This tool is currently unavailable.", parameters: { type: "OBJECT", properties: {}, required: [] } }, { name: "write_to_file", description: "This tool is currently unavailable.", parameters: { type: "OBJECT", properties: {}, required: [] } } ]; export default AntigravityExecutor;