diff --git a/open-sse/config/constants.js b/open-sse/config/constants.js index ee456100..709c932b 100644 --- a/open-sse/config/constants.js +++ b/open-sse/config/constants.js @@ -1,3 +1,76 @@ +import { platform, arch } from "os"; + +// === Antigravity Binary Alignment: Numeric Enums === +// Reference: Antigravity binary analysis - google.internal.cloud.code.v1internal.ClientMetadata + +// IDE Type enum (numeric values as expected by Cloud Code API) +export const IDE_TYPE = { + UNSPECIFIED: 0, + JETSKI: 10, // Internal codename for Gemini CLI + ANTIGRAVITY: 9, + PLUGINS: 7 +}; + +// Platform enum (as specified in Antigravity binary) +export const PLATFORM = { + UNSPECIFIED: 0, + DARWIN_AMD64: 1, + DARWIN_ARM64: 2, + LINUX_AMD64: 3, + LINUX_ARM64: 4, + WINDOWS_AMD64: 5 +}; + +// Plugin type enum (as specified in Antigravity binary) +export const PLUGIN_TYPE = { + UNSPECIFIED: 0, + CLOUD_CODE: 1, + GEMINI: 2 +}; + +/** + * Get the platform enum value based on the current OS. + * @returns {number} Platform enum value + */ +export function getPlatformEnum() { + const os = platform(); + const architecture = arch(); + + if (os === "darwin") { + return architecture === "arm64" ? PLATFORM.DARWIN_ARM64 : PLATFORM.DARWIN_AMD64; + } else if (os === "linux") { + return architecture === "arm64" ? PLATFORM.LINUX_ARM64 : PLATFORM.LINUX_AMD64; + } else if (os === "win32") { + return PLATFORM.WINDOWS_AMD64; + } + return PLATFORM.UNSPECIFIED; +} + +/** + * Generate platform-specific User-Agent string. + * @returns {string} User-Agent in format "antigravity/version os/arch" + */ +export function getPlatformUserAgent() { + const os = platform(); + const architecture = arch(); + return `antigravity/1.16.5 ${os}/${architecture}`; +} + +// Centralized client metadata (used in request bodies for loadCodeAssist, onboardUser, etc.) +// Using numeric enum values as expected by the Cloud Code API +export const CLIENT_METADATA = { + ideType: IDE_TYPE.ANTIGRAVITY, // 9 - identifies as Antigravity client + platform: getPlatformEnum(), // Runtime platform detection + pluginType: PLUGIN_TYPE.GEMINI // 2 +}; + +// Antigravity headers +export const ANTIGRAVITY_HEADERS = { + "X-Client-Name": "antigravity", + "X-Client-Version": "1.107.0", + "x-goog-api-client": "gl-node/18.18.2 fire/0.8.6 grpc/1.10.x" +}; + // Provider configurations export const PROVIDERS = { claude: { @@ -78,7 +151,7 @@ export const PROVIDERS = { ], format: "antigravity", headers: { - "User-Agent": "antigravity/1.104.0 darwin/arm64" + "User-Agent": getPlatformUserAgent() }, clientId: "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com", clientSecret: "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" @@ -181,7 +254,7 @@ export const PROVIDERS = { export const CLAUDE_SYSTEM_PROMPT = "You are Claude Code, Anthropic's official CLI for Claude."; // Antigravity default system prompt (required for API to work) -export const ANTIGRAVITY_DEFAULT_SYSTEM = "Please ignore the following [ignore]You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding.You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question.**Absolute paths only****Proactiveness**[/ignore]"; +export const ANTIGRAVITY_DEFAULT_SYSTEM = "You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding.You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question.**Absolute paths only****Proactiveness**"; // OAuth endpoints export const OAUTH_ENDPOINTS = { diff --git a/open-sse/executors/antigravity.js b/open-sse/executors/antigravity.js index 1e0c1fad..eb19320b 100644 --- a/open-sse/executors/antigravity.js +++ b/open-sse/executors/antigravity.js @@ -1,6 +1,7 @@ import crypto from "crypto"; import { BaseExecutor } from "./base.js"; -import { PROVIDERS, OAUTH_ENDPOINTS, HTTP_STATUS } from "../config/constants.js"; +import { PROVIDERS, OAUTH_ENDPOINTS, HTTP_STATUS, ANTIGRAVITY_HEADERS } from "../config/constants.js"; +import { deriveSessionId, getCachedSignature } from "../utils/sessionManager.js"; const MAX_RETRY_AFTER_MS = 10000; @@ -16,19 +17,31 @@ export class AntigravityExecutor extends BaseExecutor { return `${baseUrl}/v1internal:${action}`; } - buildHeaders(credentials, stream = true) { + buildHeaders(credentials, stream = true, sessionId = null) { return { "Content-Type": "application/json", "Authorization": `Bearer ${credentials.accessToken}`, "User-Agent": this.config.headers?.["User-Agent"] || "antigravity/1.104.0 darwin/arm64", - "X-9Router-Source": "9router", + ...ANTIGRAVITY_HEADERS, + ...(sessionId && { "X-Machine-Session-Id": sessionId }), ...(stream && { "Accept": "text/event-stream" }) }; } transformRequest(model, body, stream, credentials) { const projectId = credentials?.projectId || this.generateProjectId(); - + + const sessionId = body.request?.sessionId || deriveSessionId(credentials?.email || credentials?.connectionId); + + // Get signature for this session (side-channel cache) + const cachedSignature = getCachedSignature(sessionId); + // DEBUG LOG + if (cachedSignature) { + console.log(`[AntigravityExecutor] Injecting cached signature for session ${sessionId.substring(0, 8)}...`); + } else { + // console.log(`[AntigravityExecutor] No cached signature for session ${sessionId.substring(0, 8)}...`); + } + // Fix contents for Claude models via Antigravity const contents = body.request?.contents?.map(c => { let role = c.role; @@ -36,8 +49,19 @@ export class AntigravityExecutor extends BaseExecutor { 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 => { + + // Inject signature into functionCall parts + let parts = c.parts?.map(p => { + if (p.functionCall) { + // Use cached signature or random fallback to avoid static fingerprint + const signature = cachedSignature || crypto.randomUUID(); + return { ...p, thoughtSignature: signature }; + } + return p; + }); + + // Strip thought-only parts + parts = parts?.filter(p => { if (p.thought && !p.functionCall) return false; if (p.thoughtSignature && !p.functionCall && !p.text) return false; return true; @@ -51,13 +75,13 @@ export class AntigravityExecutor extends BaseExecutor { const transformedRequest = { ...body.request, ...(contents && { contents }), - sessionId: body.request?.sessionId || this.generateSessionId(), + sessionId: sessionId, safetySettings: undefined, - toolConfig: body.request?.tools?.length > 0 + toolConfig: body.request?.tools?.length > 0 ? { functionCallingConfig: { mode: "VALIDATED" } } : body.request?.toolConfig }; - + return { ...body, project: projectId, @@ -108,7 +132,7 @@ export class AntigravityExecutor extends BaseExecutor { } generateSessionId() { - return `-${Math.floor(Math.random() * 9_000_000_000_000_000_000)}`; + return crypto.randomUUID() + Date.now().toString(); } parseRetryHeaders(headers) { @@ -118,7 +142,7 @@ export class AntigravityExecutor extends BaseExecutor { 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(); @@ -167,9 +191,10 @@ export class AntigravityExecutor extends BaseExecutor { for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) { const url = this.buildUrl(model, stream, urlIndex); - const headers = this.buildHeaders(credentials, stream); const transformedBody = this.transformRequest(model, body, stream, credentials); - + const sessionId = transformedBody.request?.sessionId; + const headers = this.buildHeaders(credentials, stream, sessionId); + // Initialize retry counter for this URL if (!retryAttemptsByUrl[urlIndex]) { retryAttemptsByUrl[urlIndex] = 0; @@ -200,7 +225,7 @@ export class AntigravityExecutor extends BaseExecutor { } if (retryMs && retryMs <= MAX_RETRY_AFTER_MS) { - log?.debug?.("RETRY", `${response.status} with Retry-After: ${Math.ceil(retryMs/1000)}s, waiting...`); + log?.debug?.("RETRY", `${response.status} with Retry-After: ${Math.ceil(retryMs / 1000)}s, waiting...`); await new Promise(resolve => setTimeout(resolve, retryMs)); urlIndex--; continue; @@ -211,15 +236,15 @@ export class AntigravityExecutor extends BaseExecutor { retryAttemptsByUrl[urlIndex]++; // Exponential backoff: 2s, 4s, 8s... 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`); + 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`); + 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; } diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js index 673cae20..8a34038f 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -2,6 +2,8 @@ * Usage Fetcher - Get usage data from provider APIs */ +import { CLIENT_METADATA, getPlatformUserAgent } from "../config/constants.js"; + // GitHub API config const GITHUB_CONFIG = { apiVersion: "2022-11-28", @@ -15,7 +17,7 @@ const ANTIGRAVITY_CONFIG = { tokenUrl: "https://oauth2.googleapis.com/token", clientId: "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com", clientSecret: "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", - userAgent: "antigravity/1.11.3 Darwin/arm64", + userAgent: getPlatformUserAgent(), }; // Codex (OpenAI) API config @@ -65,23 +67,23 @@ export async function getUsageForProvider(connection) { */ function parseResetTime(resetValue) { if (!resetValue) return null; - + try { // If it's already a Date object if (resetValue instanceof Date) { return resetValue.toISOString(); } - + // If it's a number (Unix timestamp in milliseconds) if (typeof resetValue === 'number') { return new Date(resetValue).toISOString(); } - + // If it's a string (ISO date or any parseable date string) if (typeof resetValue === 'string') { return new Date(resetValue).toISOString(); } - + return null; } catch (error) { console.warn(`Failed to parse reset time: ${resetValue}`, error); @@ -98,7 +100,7 @@ async function getGitHubUsage(accessToken, providerSpecificData) { if (!accessToken) { throw new Error("No GitHub access token available. Please re-authorize the connection."); } - + // copilot_internal/user API requires GitHub OAuth token, not copilotToken const response = await fetch("https://api.github.com/copilot_internal/user", { headers: { @@ -123,7 +125,7 @@ async function getGitHubUsage(accessToken, providerSpecificData) { // Paid plan format const snapshots = data.quota_snapshots; const resetAt = parseResetTime(data.quota_reset_date); - + return { plan: data.copilot_plan, resetDate: data.quota_reset_date, @@ -138,7 +140,7 @@ async function getGitHubUsage(accessToken, providerSpecificData) { const monthlyQuotas = data.monthly_quotas || {}; const usedQuotas = data.limited_user_quotas || {}; const resetAt = parseResetTime(data.limited_user_reset_date); - + return { plan: data.copilot_plan || data.access_type_sku, resetDate: data.limited_user_reset_date, @@ -167,7 +169,7 @@ async function getGitHubUsage(accessToken, providerSpecificData) { function formatGitHubQuotaSnapshot(quota) { if (!quota) return { used: 0, total: 0, unlimited: true }; - + return { used: quota.entitlement - quota.remaining, total: quota.entitlement, @@ -211,7 +213,7 @@ async function getAntigravityUsage(accessToken, providerSpecificData) { try { // First get project ID from subscription info const projectId = await getAntigravityProjectId(accessToken); - + // Fetch quota data const response = await fetch(ANTIGRAVITY_CONFIG.quotaApiUrl, { method: "POST", @@ -220,7 +222,11 @@ async function getAntigravityUsage(accessToken, providerSpecificData) { "User-Agent": ANTIGRAVITY_CONFIG.userAgent, "Content-Type": "application/json", }, - body: JSON.stringify(projectId ? { project: projectId } : {}), + body: JSON.stringify({ + ...(projectId ? { project: projectId } : {}), + metadata: CLIENT_METADATA, + mode: 1 + }), }); if (response.status === 403) { @@ -233,7 +239,7 @@ async function getAntigravityUsage(accessToken, providerSpecificData) { const data = await response.json(); const quotas = {}; - + // Parse model quotas (inspired by vscode-antigravity-cockpit) if (data.models) { // Filter only recommended/important models (must match PROVIDER_MODELS ag ids) @@ -248,26 +254,26 @@ async function getAntigravityUsage(accessToken, providerSpecificData) { 'gemini-3-flash', 'gemini-2.5-flash', ]; - + for (const [modelKey, info] of Object.entries(data.models)) { // Skip models without quota info if (!info.quotaInfo) { continue; } - + // Skip internal models and non-important models if (info.isInternal || !importantModels.includes(modelKey)) { continue; } - + const remainingFraction = info.quotaInfo.remainingFraction || 0; const remainingPercentage = remainingFraction * 100; - + // Convert percentage to used/total for UI compatibility const total = 1000; // Normalized base const remaining = Math.round(total * remainingFraction); const used = total - remaining; - + // Use modelKey as key (matches PROVIDER_MODELS id) quotas[modelKey] = { used, @@ -317,7 +323,7 @@ async function getAntigravitySubscriptionInfo(accessToken) { "User-Agent": ANTIGRAVITY_CONFIG.userAgent, "Content-Type": "application/json", }, - body: JSON.stringify({ metadata: { ideType: "ANTIGRAVITY" } }), + body: JSON.stringify({ metadata: CLIENT_METADATA, mode: 1 }), }); if (!response.ok) return null; @@ -345,7 +351,7 @@ async function getClaudeUsage(accessToken) { if (settingsResponse.ok) { const settings = await settingsResponse.json(); - + // Try usage endpoint if we have org info if (settings.organization_id) { const usageResponse = await fetch( @@ -402,7 +408,7 @@ async function getCodexUsage(accessToken) { } const data = await response.json(); - + // Parse rate limit info const rateLimit = data.rate_limit || {}; const primaryWindow = rateLimit.primary_window || {}; @@ -475,7 +481,7 @@ async function getKiroUsage(accessToken, providerSpecificData) { // Parse usage data from usageBreakdownList const usageList = data.usageBreakdownList || []; const quotaInfo = {}; - + // Parse reset time - supports multiple formats (nextDateReset, resetDate, etc.) const resetAt = parseResetTime(data.nextDateReset || data.resetDate); @@ -483,7 +489,7 @@ async function getKiroUsage(accessToken, providerSpecificData) { const resourceType = breakdown.resourceType?.toLowerCase() || "unknown"; const used = breakdown.currentUsageWithPrecision || 0; const total = breakdown.usageLimitWithPrecision || 0; - + quotaInfo[resourceType] = { used, total, @@ -496,7 +502,7 @@ async function getKiroUsage(accessToken, providerSpecificData) { if (breakdown.freeTrialInfo) { const freeUsed = breakdown.freeTrialInfo.currentUsageWithPrecision || 0; const freeTotal = breakdown.freeTrialInfo.usageLimitWithPrecision || 0; - + quotaInfo[`${resourceType}_freetrial`] = { used: freeUsed, total: freeTotal, diff --git a/open-sse/translator/helpers/geminiHelper.js b/open-sse/translator/helpers/geminiHelper.js index 01368993..8b5ef582 100644 --- a/open-sse/translator/helpers/geminiHelper.js +++ b/open-sse/translator/helpers/geminiHelper.js @@ -35,7 +35,7 @@ export const DEFAULT_SAFETY_SETTINGS = [ // Convert OpenAI content to Gemini parts export function convertOpenAIContentToParts(content) { const parts = []; - + if (typeof content === "string") { parts.push({ text: content }); } else if (Array.isArray(content)) { @@ -57,7 +57,7 @@ export function convertOpenAIContentToParts(content) { } } } - + return parts; } @@ -85,9 +85,9 @@ export function generateRequestId() { return `agent-${crypto.randomUUID()}`; } -// Generate session ID +// Generate session ID (binary-compatible format: UUID + timestamp) export function generateSessionId() { - return `-${Math.floor(Math.random() * 9000000000000000000)}`; + return crypto.randomUUID() + Date.now().toString(); } // Generate project ID @@ -102,7 +102,7 @@ export function generateProjectId() { // Helper: Remove unsupported keywords recursively from object/array function removeUnsupportedKeywords(obj, keywords) { if (!obj || typeof obj !== "object") return; - + if (Array.isArray(obj)) { for (const item of obj) { removeUnsupportedKeywords(item, keywords); @@ -126,12 +126,12 @@ function removeUnsupportedKeywords(obj, keywords) { // Convert const to enum function convertConstToEnum(obj) { if (!obj || typeof obj !== "object") return; - + if (obj.const !== undefined && !obj.enum) { obj.enum = [obj.const]; delete obj.const; } - + for (const value of Object.values(obj)) { if (value && typeof value === "object") { convertConstToEnum(value); @@ -142,11 +142,11 @@ function convertConstToEnum(obj) { // Convert enum values to strings (Gemini requires string enum values) function convertEnumValuesToStrings(obj) { if (!obj || typeof obj !== "object") return; - + if (obj.enum && Array.isArray(obj.enum)) { obj.enum = obj.enum.map(v => String(v)); } - + for (const value of Object.values(obj)) { if (value && typeof value === "object") { convertEnumValuesToStrings(value); @@ -157,10 +157,10 @@ function convertEnumValuesToStrings(obj) { // Merge allOf schemas function mergeAllOf(obj) { if (!obj || typeof obj !== "object") return; - + if (obj.allOf && Array.isArray(obj.allOf)) { const merged = {}; - + for (const item of obj.allOf) { if (item.properties) { if (!merged.properties) merged.properties = {}; @@ -175,12 +175,12 @@ function mergeAllOf(obj) { } } } - + delete obj.allOf; if (merged.properties) obj.properties = { ...obj.properties, ...merged.properties }; if (merged.required) obj.required = [...(obj.required || []), ...merged.required]; } - + for (const value of Object.values(obj)) { if (value && typeof value === "object") { mergeAllOf(value); @@ -192,12 +192,12 @@ function mergeAllOf(obj) { function selectBest(items) { let bestIdx = 0; let bestScore = -1; - + for (let i = 0; i < items.length; i++) { const item = items[i]; let score = 0; const type = item.type; - + if (type === "object" || item.properties) { score = 3; } else if (type === "array" || item.items) { @@ -205,20 +205,20 @@ function selectBest(items) { } else if (type && type !== "null") { score = 1; } - + if (score > bestScore) { bestScore = score; bestIdx = i; } } - + return bestIdx; } // Flatten anyOf/oneOf function flattenAnyOfOneOf(obj) { if (!obj || typeof obj !== "object") return; - + if (obj.anyOf && Array.isArray(obj.anyOf) && obj.anyOf.length > 0) { const nonNullSchemas = obj.anyOf.filter(s => s && s.type !== "null"); if (nonNullSchemas.length > 0) { @@ -228,7 +228,7 @@ function flattenAnyOfOneOf(obj) { Object.assign(obj, selected); } } - + if (obj.oneOf && Array.isArray(obj.oneOf) && obj.oneOf.length > 0) { const nonNullSchemas = obj.oneOf.filter(s => s && s.type !== "null"); if (nonNullSchemas.length > 0) { @@ -238,7 +238,7 @@ function flattenAnyOfOneOf(obj) { Object.assign(obj, selected); } } - + for (const value of Object.values(obj)) { if (value && typeof value === "object") { flattenAnyOfOneOf(value); @@ -249,12 +249,12 @@ function flattenAnyOfOneOf(obj) { // Flatten type arrays function flattenTypeArrays(obj) { if (!obj || typeof obj !== "object") return; - + if (obj.type && Array.isArray(obj.type)) { const nonNullTypes = obj.type.filter(t => t !== "null"); obj.type = nonNullTypes.length > 0 ? nonNullTypes[0] : "string"; } - + for (const value of Object.values(obj)) { if (value && typeof value === "object") { flattenTypeArrays(value); @@ -266,28 +266,28 @@ function flattenTypeArrays(obj) { // Reference: CLIProxyAPI/internal/util/gemini_schema.go export function cleanJSONSchemaForAntigravity(schema) { if (!schema || typeof schema !== "object") return schema; - + // Mutate directly (schema is only used once per request) let cleaned = schema; - + // Phase 1: Convert and prepare convertConstToEnum(cleaned); convertEnumValuesToStrings(cleaned); - + // Phase 2: Flatten complex structures mergeAllOf(cleaned); flattenAnyOfOneOf(cleaned); flattenTypeArrays(cleaned); - + // Phase 3: Remove all unsupported keywords at ALL levels (including inside arrays) removeUnsupportedKeywords(cleaned, UNSUPPORTED_SCHEMA_CONSTRAINTS); - + // Phase 4: Cleanup required fields recursively function cleanupRequired(obj) { if (!obj || typeof obj !== "object") return; - + if (obj.required && Array.isArray(obj.required) && obj.properties) { - const validRequired = obj.required.filter(field => + const validRequired = obj.required.filter(field => Object.prototype.hasOwnProperty.call(obj.properties, field) ); if (validRequired.length === 0) { @@ -296,7 +296,7 @@ export function cleanJSONSchemaForAntigravity(schema) { obj.required = validRequired; } } - + // Recurse into nested objects for (const value of Object.values(obj)) { if (value && typeof value === "object") { @@ -304,13 +304,13 @@ export function cleanJSONSchemaForAntigravity(schema) { } } } - + cleanupRequired(cleaned); - + // Phase 5: Add placeholder for empty object schemas (Antigravity requirement) function addPlaceholders(obj) { if (!obj || typeof obj !== "object") return; - + if (obj.type === "object") { if (!obj.properties || Object.keys(obj.properties).length === 0) { obj.properties = { @@ -322,7 +322,7 @@ export function cleanJSONSchemaForAntigravity(schema) { obj.required = ["reason"]; } } - + // Recurse into nested objects for (const value of Object.values(obj)) { if (value && typeof value === "object") { @@ -330,9 +330,9 @@ export function cleanJSONSchemaForAntigravity(schema) { } } } - + addPlaceholders(cleaned); - + return cleaned; } diff --git a/open-sse/translator/request/openai-to-gemini.js b/open-sse/translator/request/openai-to-gemini.js index b48f8644..42bfe85c 100644 --- a/open-sse/translator/request/openai-to-gemini.js +++ b/open-sse/translator/request/openai-to-gemini.js @@ -1,6 +1,6 @@ import { register } from "../index.js"; import { FORMATS } from "../formats.js"; -import { DEFAULT_THINKING_GEMINI_SIGNATURE } from "../../config/defaultThinkingSignature.js"; + import { ANTIGRAVITY_DEFAULT_SYSTEM } from "../../config/constants.js"; import { openaiToClaudeRequestForAntigravity } from "./openai-to-claude.js"; @@ -18,6 +18,7 @@ import { generateProjectId, cleanJSONSchemaForAntigravity } from "../helpers/geminiHelper.js"; +import { deriveSessionId } from "../../utils/sessionManager.js"; // Core: Convert OpenAI request to Gemini format (base for all variants) function openaiToGeminiBase(model, body, stream) { @@ -86,17 +87,10 @@ function openaiToGeminiBase(model, body, stream) { } else if (role === "assistant") { const parts = []; - // Thinking/reasoning → thought part with signature - if (msg.reasoning_content) { - parts.push({ - thought: true, - text: msg.reasoning_content - }); - parts.push({ - thoughtSignature: DEFAULT_THINKING_GEMINI_SIGNATURE, - text: "" - }); - } + parts.push({ + thought: true, + text: msg.reasoning_content + }); if (content) { const text = typeof content === "string" ? content : extractTextContent(content); @@ -112,7 +106,6 @@ function openaiToGeminiBase(model, body, stream) { const args = tryParseJSON(tc.function?.arguments || "{}"); parts.push({ - thoughtSignature: DEFAULT_THINKING_GEMINI_SIGNATURE, functionCall: { id: tc.id, name: tc.function.name, @@ -259,7 +252,7 @@ function wrapInCloudCodeEnvelope(model, geminiCLI, credentials = null, isAntigra userAgent: isAntigravity ? "antigravity" : "gemini-cli", requestId: isAntigravity ? `agent-${generateUUID()}` : generateRequestId(), request: { - sessionId: generateSessionId(), + sessionId: isAntigravity ? deriveSessionId(credentials?.email || credentials?.connectionId) : generateSessionId(), contents: geminiCLI.contents, systemInstruction: geminiCLI.systemInstruction, generationConfig: geminiCLI.generationConfig, @@ -272,11 +265,16 @@ function wrapInCloudCodeEnvelope(model, geminiCLI, credentials = null, isAntigra envelope.requestType = "agent"; // Inject required default system prompt for Antigravity - const defaultPart = { text: ANTIGRAVITY_DEFAULT_SYSTEM }; + // Inject required default system prompt for Antigravity (double injection) + const systemParts = [ + { text: ANTIGRAVITY_DEFAULT_SYSTEM }, + { text: `Please ignore the following [ignore]${ANTIGRAVITY_DEFAULT_SYSTEM}[/ignore]` } + ]; + if (envelope.request.systemInstruction?.parts) { - envelope.request.systemInstruction.parts.unshift(defaultPart); + envelope.request.systemInstruction.parts.unshift(...systemParts); } else { - envelope.request.systemInstruction = { role: "user", parts: [defaultPart] }; + envelope.request.systemInstruction = { role: "user", parts: systemParts }; } // Add toolConfig for Antigravity @@ -304,7 +302,7 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu requestId: `agent-${generateUUID()}`, requestType: "agent", request: { - sessionId: generateSessionId(), + sessionId: deriveSessionId(credentials?.email || credentials?.connectionId), contents: [], generationConfig: { temperature: claudeRequest.temperature || 1, @@ -378,9 +376,11 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu } } - // Add system instruction (Antigravity default) - const defaultPart = { text: ANTIGRAVITY_DEFAULT_SYSTEM }; - const systemParts = [defaultPart]; + // Add system instruction (Antigravity default - double injection) + const systemParts = [ + { text: ANTIGRAVITY_DEFAULT_SYSTEM }, + { text: `Please ignore the following [ignore]${ANTIGRAVITY_DEFAULT_SYSTEM}[/ignore]` } + ]; if (claudeRequest.system) { if (Array.isArray(claudeRequest.system)) { diff --git a/open-sse/translator/response/gemini-to-openai.js b/open-sse/translator/response/gemini-to-openai.js index 000cc1f7..3cc55b08 100644 --- a/open-sse/translator/response/gemini-to-openai.js +++ b/open-sse/translator/response/gemini-to-openai.js @@ -1,5 +1,6 @@ import { register } from "../index.js"; import { FORMATS } from "../formats.js"; +import { cacheSignature } from "../../utils/sessionManager.js"; // Convert Gemini response chunk to OpenAI format export function geminiToOpenAIResponse(chunk, state) { @@ -39,6 +40,13 @@ export function geminiToOpenAIResponse(chunk, state) { // Handle thought signature (thinking mode) if (hasThoughtSig) { + // Cache signature if session ID is available + const signature = part.thoughtSignature || part.thought_signature; + if (state.sessionId && signature) { + // DEBUG LOG + console.log(`[GeminiToOpenAI] Found signature for session ${state.sessionId.substring(0, 8)}...`); + cacheSignature(state.sessionId, signature); + } const hasTextContent = part.text !== undefined && part.text !== ""; const hasFunctionCall = !!part.functionCall; diff --git a/open-sse/utils/sessionManager.js b/open-sse/utils/sessionManager.js new file mode 100644 index 00000000..d447fbb4 --- /dev/null +++ b/open-sse/utils/sessionManager.js @@ -0,0 +1,89 @@ +/** + * Session Manager for Antigravity Cloud Code + * + * Handles session ID generation and caching for prompt caching continuity. + * Mimics the Antigravity binary behavior: generates a session ID at startup + * and keeps it for the process lifetime, scoped per account/connection. + * + * Reference: antigravity-claude-proxy/src/cloudcode/session-manager.js + */ + +import crypto from "crypto"; + +// Runtime storage for session IDs (per connection/account) +// Key: connectionId (email or identifier), Value: sessionId +const runtimeSessionStore = new Map(); + +/** + * Get or create a session ID for the given connection. + * + * The binary generates a session ID once at startup: `rs() + Date.now()`. + * Since 9router is long-running, we simulate this "per-launch" behavior by + * storing a generated ID in memory for each connection. + * + * - If 9router restarts, the ID changes (matching binary restart behavior). + * - Within a running instance, the ID is stable for that connection. + * - This enables prompt caching while using the EXACT random logic of the binary. +// Map to store latest thinking signatures by session ID for side-channel passing +const runtimeSignatureStore = new Map(); + +/** + * Get or create a session ID for a given connection/account. + * Uses the binary-compatible format: UUID + timestamp + * uniqueKey should be accountEmail (preferred) or connectionId + */ +export function deriveSessionId(uniqueKey) { + if (!uniqueKey) { + // Fallback if no key provided, but this won't be cached per connection + return generateBinaryStyleId(); + } + + if (runtimeSessionStore.has(uniqueKey)) { + return runtimeSessionStore.get(uniqueKey); + } + + const newSessionId = generateBinaryStyleId(); + runtimeSessionStore.set(uniqueKey, newSessionId); + return newSessionId; +} + +/** + * Cache the latest thinking signature for a session. + * This is used to pass signatures from response handling (gemini-to-openai) + * to request handling (antigravity executor) side-channel, bypassing + * the OpenAI intermediate format which drops them. + */ +export function cacheSignature(sessionId, signature) { + if (!sessionId || !signature) return; + // DEBUG LOG: Caching signature + console.log(`[SessionManager] Caching signature for ${sessionId.substring(0, 8)}...: ${signature.substring(0, 20)}...`); + runtimeSignatureStore.set(sessionId, signature); +} + +/** + * Retrieve the cached thinking signature for a session. + */ +export function getCachedSignature(sessionId) { + const sig = runtimeSignatureStore.get(sessionId); + if (sig) { + // DEBUG LOG: Retrieving signature + console.log(`[SessionManager] Retrieved signature for ${sessionId.substring(0, 8)}...: ${sig.substring(0, 20)}...`); + } + return sig; +} + +/** + * Generate a session ID in the format expected by Antigravity: + * randomUUID + Date.now() + */ +export function generateBinaryStyleId() { + return crypto.randomUUID() + Date.now().toString(); +} + +/** + * Clear session store (e.g., on restart or config change) + */ +export function clearSessionStore() { + runtimeSessionStore.clear(); + runtimeSignatureStore.clear(); +} diff --git a/open-sse/utils/stream.js b/open-sse/utils/stream.js index bcfff891..c042db9a 100644 --- a/open-sse/utils/stream.js +++ b/open-sse/utils/stream.js @@ -3,6 +3,7 @@ import { FORMATS } from "../translator/formats.js"; import { trackPendingRequest, appendRequestLog } from "@/lib/usageDb.js"; import { extractUsage, hasValidUsage, estimateUsage, logUsage, addBufferToUsage, filterUsageForFormat, COLORS } from "./usageTracking.js"; import { parseSSELine, hasValuableContent, fixInvalidId, formatSSE } from "./streamHelpers.js"; +import { deriveSessionId } from "./sessionManager.js"; export { COLORS, formatSSE }; @@ -49,7 +50,15 @@ export function createSSEStream(options = {}) { let buffer = ""; let usage = null; - const state = mode === STREAM_MODE.TRANSLATE ? { ...initState(sourceFormat), provider, toolNameMap, model } : null; + // Derive session ID for signature caching (if connectionId is available) + const sessionId = connectionId ? deriveSessionId(connectionId) : null; + + // DEBUG LOG + if (sessionId) { + console.log(`[Stream] Associated stream with sessionId: ${sessionId.substring(0, 8)}...`); + } + + const state = mode === STREAM_MODE.TRANSLATE ? { ...initState(sourceFormat), provider, toolNameMap, model, sessionId } : null; let totalContentLength = 0; let accumulatedContent = ""; diff --git a/src/lib/oauth/constants/oauth.js b/src/lib/oauth/constants/oauth.js index d8c59af1..fd1329c4 100644 --- a/src/lib/oauth/constants/oauth.js +++ b/src/lib/oauth/constants/oauth.js @@ -1,6 +1,20 @@ /** * OAuth Configuration Constants */ +import { platform, arch } from "os"; + +/** + * Get the platform enum value based on the current OS. + * Matches Antigravity binary's ClientMetadata.Platform enum. + */ +function getOAuthPlatformEnum() { + const os = platform(); + const architecture = arch(); + if (os === "darwin") return architecture === "arm64" ? 2 : 1; + if (os === "linux") return architecture === "arm64" ? 4 : 3; + if (os === "win32") return 5; + return 0; +} // Claude OAuth Configuration (Authorization Code Flow with PKCE) export const CLAUDE_CONFIG = { @@ -83,9 +97,17 @@ export const ANTIGRAVITY_CONFIG = { onboardUserEndpoint: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser", loadCodeAssistUserAgent: "google-api-nodejs-client/9.15.1", loadCodeAssistApiClient: "google-cloud-sdk vscode_cloudshelleditor/0.1", - loadCodeAssistClientMetadata: `{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}`, + loadCodeAssistClientMetadata: JSON.stringify({ ideType: 9, platform: getOAuthPlatformEnum(), pluginType: 2 }), }; +/** + * Get client metadata using numeric enum values for API calls. + * @returns {{ ideType: number, platform: number, pluginType: number }} + */ +export function getOAuthClientMetadata() { + return { ideType: 9, platform: getOAuthPlatformEnum(), pluginType: 2 }; +} + // OpenAI OAuth Configuration (Authorization Code Flow with PKCE) export const OPENAI_CONFIG = { clientId: "app_EMoamEEZ73f0CkXaXp7hrann", diff --git a/src/lib/oauth/providers.js b/src/lib/oauth/providers.js index 0ff356b2..8124553d 100644 --- a/src/lib/oauth/providers.js +++ b/src/lib/oauth/providers.js @@ -14,6 +14,7 @@ import { GITHUB_CONFIG, KIRO_CONFIG, CURSOR_CONFIG, + getOAuthClientMetadata, } from "./constants/oauth"; // Provider configurations @@ -184,7 +185,8 @@ const PROVIDERS = { "Content-Type": "application/json", }, body: JSON.stringify({ - metadata: { ideType: "IDE_UNSPECIFIED", platform: "PLATFORM_UNSPECIFIED", pluginType: "GEMINI" }, + metadata: getOAuthClientMetadata(), + mode: 1, }), } ); @@ -254,7 +256,7 @@ const PROVIDERS = { "X-Goog-Api-Client": ANTIGRAVITY_CONFIG.loadCodeAssistApiClient, "Client-Metadata": ANTIGRAVITY_CONFIG.loadCodeAssistClientMetadata, }; - const metadata = { ideType: "IDE_UNSPECIFIED", platform: "PLATFORM_UNSPECIFIED", pluginType: "GEMINI" }; + const metadata = getOAuthClientMetadata(); // Fetch user info const userInfoRes = await fetch(`${ANTIGRAVITY_CONFIG.userInfoUrl}?alt=json`, { @@ -269,7 +271,7 @@ const PROVIDERS = { const loadRes = await fetch(ANTIGRAVITY_CONFIG.loadCodeAssistEndpoint, { method: "POST", headers, - body: JSON.stringify({ metadata }), + body: JSON.stringify({ metadata, mode: 1 }), }); if (loadRes.ok) { const data = await loadRes.json(); @@ -295,7 +297,7 @@ const PROVIDERS = { const onboardRes = await fetch(ANTIGRAVITY_CONFIG.onboardUserEndpoint, { method: "POST", headers, - body: JSON.stringify({ tierId, metadata, cloudaicompanionProject: projectId }), + body: JSON.stringify({ tierId, metadata, cloudaicompanionProject: projectId, mode: 1 }), }); if (onboardRes.ok) { const result = await onboardRes.json(); @@ -727,9 +729,9 @@ export function generateAuthData(providerName, redirectUri) { */ export async function exchangeTokens(providerName, code, redirectUri, codeVerifier, state) { const provider = getProvider(providerName); - + const tokens = await provider.exchangeToken(provider.config, code, redirectUri, codeVerifier, state); - + let extra = null; if (provider.postExchange) { extra = await provider.postExchange(tokens); @@ -761,9 +763,9 @@ export async function pollForToken(providerName, deviceCode, codeVerifier, extra if (provider.flowType !== "device_code") { throw new Error(`Provider ${providerName} does not support device code flow`); } - + const result = await provider.pollToken(provider.config, deviceCode, codeVerifier, extraData); - + if (result.ok) { // For device code flows, success is only when we have an access token if (result.data.access_token) { @@ -777,23 +779,23 @@ export async function pollForToken(providerName, deviceCode, codeVerifier, extra // Check if it's still pending authorization if (result.data.error === 'authorization_pending' || result.data.error === 'slow_down') { // This is not a failure, just still waiting - return { - success: false, - error: result.data.error, + return { + success: false, + error: result.data.error, errorDescription: result.data.error_description || result.data.message, pending: result.data.error === 'authorization_pending' }; } else { // Actual error - return { - success: false, - error: result.data.error || 'no_access_token', - errorDescription: result.data.error_description || result.data.message || 'No access token received' + return { + success: false, + error: result.data.error || 'no_access_token', + errorDescription: result.data.error_description || result.data.message || 'No access token received' }; } } } - + return { success: false, error: result.data.error, errorDescription: result.data.error_description }; } diff --git a/src/lib/oauth/services/antigravity.js b/src/lib/oauth/services/antigravity.js index d9d5eaa6..4d8611ca 100644 --- a/src/lib/oauth/services/antigravity.js +++ b/src/lib/oauth/services/antigravity.js @@ -1,4 +1,5 @@ import crypto from "crypto"; +import { platform, arch } from "os"; import open from "open"; import { ANTIGRAVITY_CONFIG } from "../constants/oauth.js"; import { getServerCredentials } from "../config/index.js"; @@ -92,12 +93,20 @@ export class AntigravityService { /** * Get metadata object for API calls + * Uses numeric enum values matching Antigravity binary specifications */ getMetadata() { + const os = platform(); + const architecture = arch(); + let platformEnum = 0; // UNSPECIFIED + if (os === "darwin") platformEnum = architecture === "arm64" ? 2 : 1; + else if (os === "linux") platformEnum = architecture === "arm64" ? 4 : 3; + else if (os === "win32") platformEnum = 5; + return { - ideType: "IDE_UNSPECIFIED", - platform: "PLATFORM_UNSPECIFIED", - pluginType: "GEMINI", + ideType: 9, // ANTIGRAVITY + platform: platformEnum, + pluginType: 2, // GEMINI }; } @@ -108,7 +117,7 @@ export class AntigravityService { const response = await fetch(this.config.loadCodeAssistEndpoint, { method: "POST", headers: this.getApiHeaders(accessToken), - body: JSON.stringify({ metadata: this.getMetadata() }), + body: JSON.stringify({ metadata: this.getMetadata(), mode: 1 }), }); if (!response.ok) { @@ -117,7 +126,7 @@ export class AntigravityService { } const data = await response.json(); - + // Extract project ID let projectId = data.cloudaicompanionProject; if (typeof projectId === 'object' && projectId !== null && projectId.id) { @@ -149,6 +158,7 @@ export class AntigravityService { tierId, metadata: this.getMetadata(), cloudaicompanionProject: projectId, + mode: 1, }), }); @@ -166,7 +176,7 @@ export class AntigravityService { async completeOnboarding(accessToken, projectId, tierId, maxRetries = 10) { for (let i = 0; i < maxRetries; i++) { const result = await this.onboardUser(accessToken, projectId, tierId); - + if (result.done === true) { // Extract final project ID from response let finalProjectId = projectId; @@ -301,7 +311,7 @@ export class AntigravityService { // Load Code Assist to get project ID and tier const { projectId, tierId } = await this.loadCodeAssist(tokens.access_token); - + if (!projectId) { throw new Error("No Google Cloud Project found. Please ensure you have a GCP project with Gemini Code Assist enabled."); } diff --git a/src/lib/oauth/services/gemini.js b/src/lib/oauth/services/gemini.js index 1830c94a..fd8dca71 100644 --- a/src/lib/oauth/services/gemini.js +++ b/src/lib/oauth/services/gemini.js @@ -1,6 +1,6 @@ import crypto from "crypto"; import open from "open"; -import { GEMINI_CONFIG } from "../constants/oauth.js"; +import { GEMINI_CONFIG, getOAuthClientMetadata } from "../constants/oauth.js"; import { getServerCredentials } from "../config/index.js"; import { startLocalServer } from "../utils/server.js"; import { spinner as createSpinner } from "../utils/ui.js"; @@ -71,18 +71,11 @@ export class GeminiCLIService { "Content-Type": "application/json", "User-Agent": "google-api-nodejs-client/9.15.1", "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", - "Client-Metadata": JSON.stringify({ - ideType: "IDE_UNSPECIFIED", - platform: "PLATFORM_UNSPECIFIED", - pluginType: "GEMINI" - }) + "Client-Metadata": JSON.stringify(getOAuthClientMetadata()) }, body: JSON.stringify({ - metadata: { - ideType: "IDE_UNSPECIFIED", - platform: "PLATFORM_UNSPECIFIED", - pluginType: "GEMINI" - } + metadata: getOAuthClientMetadata(), + mode: 1 }) } ); @@ -93,7 +86,7 @@ export class GeminiCLIService { } const data = await response.json(); - + // Extract project ID let projectId = ""; if (typeof data.cloudaicompanionProject === "string") {