| 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 { deriveSessionId } from "../utils/sessionManager.js"; |
| import { proxyAwareFetch } from "../utils/proxyFetch.js"; |
| import { cleanJSONSchemaForAntigravity } from "../translator/helpers/geminiHelper.js"; |
|
|
| |
| 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; |
|
|
| 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(); |
|
|
| |
| const contents = body.request?.contents?.map(c => { |
| let role = c.role; |
| |
| if (c.parts?.some(p => p.functionResponse)) { |
| role = "user"; |
| } |
| |
| 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; |
| }); |
|
|
| |
| let tools = body.request?.tools; |
|
|
| if (tools && tools.length > 0) { |
| |
| 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 }] : []; |
| } |
|
|
| 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; |
| } |
|
|
| const transformedRequest = { |
| ...requestWithoutTools, |
| generationConfig, |
| ...(contents && { contents }), |
| ...(tools && { tools }), |
| sessionId: body.request?.sessionId || deriveSessionId(credentials?.email || credentials?.connectionId), |
| safetySettings: undefined, |
| ...(tools?.length > 0 && { toolConfig: { functionCallingConfig: { mode: "VALIDATED" } } }) |
| }; |
|
|
| 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; |
| } |
|
|
| |
| |
| 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; |
| if (match[2]) totalMs += parseInt(match[2]) * 60 * 1000; |
| if (match[3]) totalMs += parseInt(match[3]) * 1000; |
|
|
| return totalMs > 0 ? totalMs : null; |
| } |
|
|
| async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) { |
| const fallbackCount = this.getFallbackCount(); |
| let lastError = null; |
| let lastStatus = 0; |
| const MAX_AUTO_RETRIES = 3; |
| const MAX_RETRY_AFTER_RETRIES = 3; |
| const retryAttemptsByUrl = {}; |
| const retryAfterAttemptsByUrl = {}; |
|
|
| 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); |
|
|
| |
| if (!retryAttemptsByUrl[urlIndex]) { |
| retryAttemptsByUrl[urlIndex] = 0; |
| } |
| if (!retryAfterAttemptsByUrl[urlIndex]) { |
| retryAfterAttemptsByUrl[urlIndex] = 0; |
| } |
|
|
| try { |
| const response = await proxyAwareFetch(url, { |
| method: "POST", |
| headers, |
| body: JSON.stringify(transformedBody), |
| signal |
| }, proxyOptions); |
|
|
| if (response.status === HTTP_STATUS.RATE_LIMITED || response.status === HTTP_STATUS.SERVICE_UNAVAILABLE) { |
| |
| let retryMs = this.parseRetryHeaders(response.headers); |
|
|
| |
| 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) { |
| |
| } |
| } |
|
|
| 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})`); |
| await new Promise(resolve => setTimeout(resolve, retryMs)); |
| urlIndex--; |
| continue; |
| } |
|
|
| |
| if (response.status === HTTP_STATUS.RATE_LIMITED && (!retryMs || retryMs === 0) && retryAttemptsByUrl[urlIndex] < MAX_AUTO_RETRIES) { |
| retryAttemptsByUrl[urlIndex]++; |
| |
| 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`); |
| await new Promise(resolve => setTimeout(resolve, backoffMs)); |
| urlIndex--; |
| continue; |
| } |
|
|
| log?.debug?.("RETRY", `${response.status}, Retry-After ${retryMs ? `too long (${Math.ceil(retryMs / 1000)}s)` : 'missing'}, trying fallback`); |
| lastStatus = response.status; |
|
|
| 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}`); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| 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)); |
|
|
| |
| for (const toolGroup of tools) { |
| if (!toolGroup.functionDeclarations) continue; |
|
|
| for (const func of toolGroup.functionDeclarations) { |
| |
| |
| if (isCopilot && AG_DEFAULT_TOOLS.has(func.name)) { |
| continue; |
| } |
|
|
| |
| if (isCopilot && decoyNames.has(func.name)) { |
| continue; |
| } |
|
|
| |
| 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 }); |
| } |
| } |
|
|
| |
| 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); |
| } |
|
|
| |
| const cloakedContents = body.request?.contents?.map(msg => { |
| if (!msg.parts) return msg; |
| |
| const cloakedParts = msg.parts.map(part => { |
| |
| if (part.functionCall && !AG_DEFAULT_TOOLS.has(part.functionCall.name)) { |
| return { |
| ...part, |
| functionCall: { |
| ...part.functionCall, |
| name: `${part.functionCall.name}${AG_TOOL_SUFFIX}` |
| } |
| }; |
| } |
| |
| |
| 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 }; |
| }); |
|
|
| |
| return { |
| cloakedBody: { |
| ...body, |
| request: { |
| ...body.request, |
| tools: [{ functionDeclarations: allDeclarations }], |
| contents: cloakedContents || body.request.contents |
| } |
| }, |
| toolNameMap |
| }; |
| } |
| } |
|
|
| |
| 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; |
|
|