merge: integrate upstream/main (v1.2.15) into feature/webui
- Resolved conflict in src/constants.js: kept config-driven approach - Adopted upstream 10-second cooldown default - Added MAX_EMPTY_RESPONSE_RETRIES constant from upstream - Incorporated new test files and GitHub issue templates
This commit is contained in:
@@ -6,10 +6,80 @@
|
||||
* - Windows compatibility (no CLI dependency)
|
||||
* - Native performance
|
||||
* - Synchronous API (simple error handling)
|
||||
*
|
||||
* Includes auto-rebuild capability for handling Node.js version updates
|
||||
* that cause native module incompatibility.
|
||||
*/
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
import { createRequire } from 'module';
|
||||
import { ANTIGRAVITY_DB_PATH } from '../constants.js';
|
||||
import { isModuleVersionError, attemptAutoRebuild, clearRequireCache } from '../utils/native-module-helper.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { NativeModuleError } from '../errors.js';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
// Lazy-loaded Database constructor
|
||||
let Database = null;
|
||||
let moduleLoadError = null;
|
||||
|
||||
/**
|
||||
* Load the better-sqlite3 module with auto-rebuild on version mismatch
|
||||
* Uses synchronous require to maintain API compatibility
|
||||
* @returns {Function} The Database constructor
|
||||
* @throws {Error} If module cannot be loaded even after rebuild
|
||||
*/
|
||||
function loadDatabaseModule() {
|
||||
// Return cached module if already loaded
|
||||
if (Database) return Database;
|
||||
|
||||
// Re-throw cached error if previous load failed permanently
|
||||
if (moduleLoadError) throw moduleLoadError;
|
||||
|
||||
try {
|
||||
Database = require('better-sqlite3');
|
||||
return Database;
|
||||
} catch (error) {
|
||||
if (isModuleVersionError(error)) {
|
||||
logger.warn('[Database] Native module version mismatch detected');
|
||||
|
||||
if (attemptAutoRebuild(error)) {
|
||||
// Clear require cache and retry
|
||||
try {
|
||||
const resolvedPath = require.resolve('better-sqlite3');
|
||||
// Clear the module and all its dependencies from cache
|
||||
clearRequireCache(resolvedPath, require.cache);
|
||||
|
||||
Database = require('better-sqlite3');
|
||||
logger.success('[Database] Module reloaded successfully after rebuild');
|
||||
return Database;
|
||||
} catch (retryError) {
|
||||
// Rebuild succeeded but reload failed - user needs to restart
|
||||
moduleLoadError = new NativeModuleError(
|
||||
'Native module rebuild completed. Please restart the server to apply the fix.',
|
||||
true, // rebuildSucceeded
|
||||
true // restartRequired
|
||||
);
|
||||
logger.info('[Database] Rebuild succeeded - server restart required');
|
||||
throw moduleLoadError;
|
||||
}
|
||||
} else {
|
||||
moduleLoadError = new NativeModuleError(
|
||||
'Failed to auto-rebuild native module. Please run manually:\n' +
|
||||
' npm rebuild better-sqlite3\n' +
|
||||
'Or if using npx, find the package location in the error and run:\n' +
|
||||
' cd /path/to/better-sqlite3 && npm rebuild',
|
||||
false, // rebuildSucceeded
|
||||
false // restartRequired
|
||||
);
|
||||
throw moduleLoadError;
|
||||
}
|
||||
}
|
||||
|
||||
// Non-version-mismatch error, just throw it
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query Antigravity database for authentication status
|
||||
@@ -18,10 +88,11 @@ import { ANTIGRAVITY_DB_PATH } from '../constants.js';
|
||||
* @throws {Error} If database doesn't exist, query fails, or no auth status found
|
||||
*/
|
||||
export function getAuthStatus(dbPath = ANTIGRAVITY_DB_PATH) {
|
||||
const Db = loadDatabaseModule();
|
||||
let db;
|
||||
try {
|
||||
// Open database in read-only mode
|
||||
db = new Database(dbPath, {
|
||||
db = new Db(dbPath, {
|
||||
readonly: true,
|
||||
fileMustExist: true
|
||||
});
|
||||
@@ -56,6 +127,10 @@ export function getAuthStatus(dbPath = ANTIGRAVITY_DB_PATH) {
|
||||
if (error.message.includes('No auth status') || error.message.includes('missing apiKey')) {
|
||||
throw error;
|
||||
}
|
||||
// Re-throw native module errors from loadDatabaseModule without wrapping
|
||||
if (error instanceof NativeModuleError) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(`Failed to read Antigravity database: ${error.message}`);
|
||||
} finally {
|
||||
// Always close database connection
|
||||
@@ -73,7 +148,8 @@ export function getAuthStatus(dbPath = ANTIGRAVITY_DB_PATH) {
|
||||
export function isDatabaseAccessible(dbPath = ANTIGRAVITY_DB_PATH) {
|
||||
let db;
|
||||
try {
|
||||
db = new Database(dbPath, {
|
||||
const Db = loadDatabaseModule();
|
||||
db = new Db(dbPath, {
|
||||
readonly: true,
|
||||
fileMustExist: true
|
||||
});
|
||||
|
||||
@@ -58,6 +58,56 @@ export function getAuthorizationUrl(customRedirectUri = null) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract authorization code and state from user input.
|
||||
* User can paste either:
|
||||
* - Full callback URL: http://localhost:51121/oauth-callback?code=xxx&state=xxx
|
||||
* - Just the code parameter: 4/0xxx...
|
||||
*
|
||||
* @param {string} input - User input (URL or code)
|
||||
* @returns {{code: string, state: string|null}} Extracted code and optional state
|
||||
*/
|
||||
export function extractCodeFromInput(input) {
|
||||
if (!input || typeof input !== 'string') {
|
||||
throw new Error('No input provided');
|
||||
}
|
||||
|
||||
const trimmed = input.trim();
|
||||
|
||||
// Check if it looks like a URL
|
||||
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
const code = url.searchParams.get('code');
|
||||
const state = url.searchParams.get('state');
|
||||
const error = url.searchParams.get('error');
|
||||
|
||||
if (error) {
|
||||
throw new Error(`OAuth error: ${error}`);
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
throw new Error('No authorization code found in URL');
|
||||
}
|
||||
|
||||
return { code, state };
|
||||
} catch (e) {
|
||||
if (e.message.includes('OAuth error') || e.message.includes('No authorization code')) {
|
||||
throw e;
|
||||
}
|
||||
throw new Error('Invalid URL format');
|
||||
}
|
||||
}
|
||||
|
||||
// Assume it's a raw code
|
||||
// Google auth codes typically start with "4/" and are long
|
||||
if (trimmed.length < 10) {
|
||||
throw new Error('Input is too short to be a valid authorization code');
|
||||
}
|
||||
|
||||
return { code: trimmed, state: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a local server to receive the OAuth callback
|
||||
* Returns a promise that resolves with the authorization code
|
||||
@@ -82,10 +132,10 @@ export function startCallbackServer(expectedState, timeoutMs = 120000) {
|
||||
const error = url.searchParams.get('error');
|
||||
|
||||
if (error) {
|
||||
res.writeHead(400, { 'Content-Type': 'text/html' });
|
||||
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(`
|
||||
<html>
|
||||
<head><title>Authentication Failed</title></head>
|
||||
<head><meta charset="UTF-8"><title>Authentication Failed</title></head>
|
||||
<body style="font-family: system-ui; padding: 40px; text-align: center;">
|
||||
<h1 style="color: #dc3545;">❌ Authentication Failed</h1>
|
||||
<p>Error: ${error}</p>
|
||||
@@ -99,10 +149,10 @@ export function startCallbackServer(expectedState, timeoutMs = 120000) {
|
||||
}
|
||||
|
||||
if (state !== expectedState) {
|
||||
res.writeHead(400, { 'Content-Type': 'text/html' });
|
||||
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(`
|
||||
<html>
|
||||
<head><title>Authentication Failed</title></head>
|
||||
<head><meta charset="UTF-8"><title>Authentication Failed</title></head>
|
||||
<body style="font-family: system-ui; padding: 40px; text-align: center;">
|
||||
<h1 style="color: #dc3545;">❌ Authentication Failed</h1>
|
||||
<p>State mismatch - possible CSRF attack.</p>
|
||||
@@ -116,10 +166,10 @@ export function startCallbackServer(expectedState, timeoutMs = 120000) {
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
res.writeHead(400, { 'Content-Type': 'text/html' });
|
||||
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(`
|
||||
<html>
|
||||
<head><title>Authentication Failed</title></head>
|
||||
<head><meta charset="UTF-8"><title>Authentication Failed</title></head>
|
||||
<body style="font-family: system-ui; padding: 40px; text-align: center;">
|
||||
<h1 style="color: #dc3545;">❌ Authentication Failed</h1>
|
||||
<p>No authorization code received.</p>
|
||||
@@ -133,10 +183,10 @@ export function startCallbackServer(expectedState, timeoutMs = 120000) {
|
||||
}
|
||||
|
||||
// Success!
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(`
|
||||
<html>
|
||||
<head><title>Authentication Successful</title></head>
|
||||
<head><meta charset="UTF-8"><title>Authentication Successful</title></head>
|
||||
<body style="font-family: system-ui; padding: 40px; text-align: center;">
|
||||
<h1 style="color: #28a745;">✅ Authentication Successful!</h1>
|
||||
<p>You can close this window and return to the terminal.</p>
|
||||
@@ -339,6 +389,7 @@ export async function completeOAuthFlow(code, verifier) {
|
||||
|
||||
export default {
|
||||
getAuthorizationUrl,
|
||||
extractCodeFromInput,
|
||||
startCallbackServer,
|
||||
exchangeCode,
|
||||
refreshAccessToken,
|
||||
|
||||
@@ -25,7 +25,8 @@ import {
|
||||
startCallbackServer,
|
||||
completeOAuthFlow,
|
||||
refreshAccessToken,
|
||||
getUserEmail
|
||||
getUserEmail,
|
||||
extractCodeFromInput
|
||||
} from '../auth/oauth.js';
|
||||
|
||||
const SERVER_PORT = process.env.PORT || DEFAULT_PORT;
|
||||
@@ -229,6 +230,63 @@ async function addAccount(existingAccounts) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new account via OAuth with manual code input (no-browser mode)
|
||||
* For headless servers without a desktop environment
|
||||
*/
|
||||
async function addAccountNoBrowser(existingAccounts, rl) {
|
||||
console.log('\n=== Add Google Account (No-Browser Mode) ===\n');
|
||||
|
||||
// Generate authorization URL
|
||||
const { url, verifier, state } = getAuthorizationUrl();
|
||||
|
||||
console.log('Copy the following URL and open it in a browser on another device:\n');
|
||||
console.log(` ${url}\n`);
|
||||
console.log('After signing in, you will be redirected to a localhost URL.');
|
||||
console.log('Copy the ENTIRE redirect URL or just the authorization code.\n');
|
||||
|
||||
const input = await rl.question('Paste the callback URL or authorization code: ');
|
||||
|
||||
try {
|
||||
const { code, state: extractedState } = extractCodeFromInput(input);
|
||||
|
||||
// Validate state if present
|
||||
if (extractedState && extractedState !== state) {
|
||||
console.log('\n⚠ State mismatch detected. This could indicate a security issue.');
|
||||
console.log('Proceeding anyway as this is manual mode...');
|
||||
}
|
||||
|
||||
console.log('\nExchanging authorization code for tokens...');
|
||||
const result = await completeOAuthFlow(code, verifier);
|
||||
|
||||
// Check if account already exists
|
||||
const existing = existingAccounts.find(a => a.email === result.email);
|
||||
if (existing) {
|
||||
console.log(`\n⚠ Account ${result.email} already exists. Updating tokens.`);
|
||||
existing.refreshToken = result.refreshToken;
|
||||
existing.projectId = result.projectId;
|
||||
existing.addedAt = new Date().toISOString();
|
||||
return null; // Don't add duplicate
|
||||
}
|
||||
|
||||
console.log(`\n✓ Successfully authenticated: ${result.email}`);
|
||||
if (result.projectId) {
|
||||
console.log(` Project ID: ${result.projectId}`);
|
||||
}
|
||||
|
||||
return {
|
||||
email: result.email,
|
||||
refreshToken: result.refreshToken,
|
||||
projectId: result.projectId,
|
||||
addedAt: new Date().toISOString(),
|
||||
modelRateLimits: {}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`\n✗ Authentication failed: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive remove accounts flow
|
||||
*/
|
||||
@@ -275,8 +333,14 @@ async function interactiveRemove(rl) {
|
||||
|
||||
/**
|
||||
* Interactive add accounts flow (Main Menu)
|
||||
* @param {Object} rl - readline interface
|
||||
* @param {boolean} noBrowser - if true, use manual code input mode
|
||||
*/
|
||||
async function interactiveAdd(rl) {
|
||||
async function interactiveAdd(rl, noBrowser = false) {
|
||||
if (noBrowser) {
|
||||
console.log('\n📋 No-browser mode: You will manually paste the authorization code.\n');
|
||||
}
|
||||
|
||||
const accounts = loadAccounts();
|
||||
|
||||
if (accounts.length > 0) {
|
||||
@@ -307,7 +371,11 @@ async function interactiveAdd(rl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newAccount = await addAccount(accounts);
|
||||
// Use appropriate add function based on mode
|
||||
const newAccount = noBrowser
|
||||
? await addAccountNoBrowser(accounts, rl)
|
||||
: await addAccount(accounts);
|
||||
|
||||
if (newAccount) {
|
||||
accounts.push(newAccount);
|
||||
saveAccounts(accounts);
|
||||
@@ -388,9 +456,11 @@ async function verifyAccounts() {
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const command = args[0] || 'add';
|
||||
const noBrowser = args.includes('--no-browser');
|
||||
|
||||
console.log('╔════════════════════════════════════════╗');
|
||||
console.log('║ Antigravity Proxy Account Manager ║');
|
||||
console.log('║ Use --no-browser for headless mode ║');
|
||||
console.log('╚════════════════════════════════════════╝');
|
||||
|
||||
const rl = createRL();
|
||||
@@ -399,7 +469,7 @@ async function main() {
|
||||
switch (command) {
|
||||
case 'add':
|
||||
await ensureServerStopped();
|
||||
await interactiveAdd(rl);
|
||||
await interactiveAdd(rl, noBrowser);
|
||||
break;
|
||||
case 'list':
|
||||
await listAccounts();
|
||||
@@ -418,6 +488,8 @@ async function main() {
|
||||
console.log(' node src/cli/accounts.js verify Verify account tokens');
|
||||
console.log(' node src/cli/accounts.js clear Remove all accounts');
|
||||
console.log(' node src/cli/accounts.js help Show this help');
|
||||
console.log('\nOptions:');
|
||||
console.log(' --no-browser Manual authorization code input (for headless servers)');
|
||||
break;
|
||||
case 'remove':
|
||||
await ensureServerStopped();
|
||||
|
||||
@@ -72,8 +72,19 @@ export async function sendMessage(anthropicRequest, accountManager, fallbackEnab
|
||||
const accountCount = accountManager.getAccountCount();
|
||||
logger.warn(`[CloudCode] All ${accountCount} account(s) rate-limited. Waiting ${formatDuration(allWaitMs)}...`);
|
||||
await sleep(allWaitMs);
|
||||
|
||||
// Add small buffer after waiting to ensure rate limits have truly expired
|
||||
await sleep(500);
|
||||
accountManager.clearExpiredLimits();
|
||||
account = accountManager.pickNext(model);
|
||||
|
||||
// If still no account after waiting, try optimistic reset
|
||||
// This handles cases where the API rate limit is transient
|
||||
if (!account) {
|
||||
logger.warn('[CloudCode] No account available after wait, attempting optimistic reset...');
|
||||
accountManager.resetAllRateLimits();
|
||||
account = accountManager.pickNext(model);
|
||||
}
|
||||
}
|
||||
|
||||
if (!account) {
|
||||
@@ -197,10 +208,10 @@ export async function sendMessage(anthropicRequest, accountManager, fallbackEnab
|
||||
}
|
||||
|
||||
if (isNetworkError(error)) {
|
||||
logger.warn(`[CloudCode] Network error for ${account.email}, trying next account... (${error.message})`);
|
||||
await sleep(1000); // Brief pause before retry
|
||||
accountManager.pickNext(model); // Advance to next account
|
||||
continue;
|
||||
logger.warn(`[CloudCode] Network error for ${account.email}, trying next account... (${error.message})`);
|
||||
await sleep(1000); // Brief pause before retry
|
||||
accountManager.pickNext(model); // Advance to next account
|
||||
continue;
|
||||
}
|
||||
|
||||
throw error;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import crypto from 'crypto';
|
||||
import {
|
||||
ANTIGRAVITY_HEADERS,
|
||||
ANTIGRAVITY_SYSTEM_INSTRUCTION,
|
||||
getModelFamily,
|
||||
isThinkingModel
|
||||
} from '../constants.js';
|
||||
@@ -27,14 +28,38 @@ export function buildCloudCodeRequest(anthropicRequest, projectId) {
|
||||
// Use stable session ID derived from first user message for cache continuity
|
||||
googleRequest.sessionId = deriveSessionId(anthropicRequest);
|
||||
|
||||
// Build system instruction parts array with [ignore] tags to prevent model from
|
||||
// identifying as "Antigravity" (fixes GitHub issue #76)
|
||||
// Reference: CLIProxyAPI, gcli2api, AIClient-2-API all use this approach
|
||||
const systemParts = [
|
||||
{ text: ANTIGRAVITY_SYSTEM_INSTRUCTION },
|
||||
{ text: `Please ignore the following [ignore]${ANTIGRAVITY_SYSTEM_INSTRUCTION}[/ignore]` }
|
||||
];
|
||||
|
||||
// Append any existing system instructions from the request
|
||||
if (googleRequest.systemInstruction && googleRequest.systemInstruction.parts) {
|
||||
for (const part of googleRequest.systemInstruction.parts) {
|
||||
if (part.text) {
|
||||
systemParts.push({ text: part.text });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
project: projectId,
|
||||
model: model,
|
||||
request: googleRequest,
|
||||
userAgent: 'antigravity',
|
||||
requestType: 'agent', // CLIProxyAPI v6.6.89 compatibility
|
||||
requestId: 'agent-' + crypto.randomUUID()
|
||||
};
|
||||
|
||||
// Inject systemInstruction with role: "user" at the top level (CLIProxyAPI v6.6.89 behavior)
|
||||
payload.request.systemInstruction = {
|
||||
role: 'user',
|
||||
parts: systemParts
|
||||
};
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import crypto from 'crypto';
|
||||
import { MIN_SIGNATURE_LENGTH, getModelFamily } from '../constants.js';
|
||||
import { EmptyResponseError } from '../errors.js';
|
||||
import { cacheSignature, cacheThinkingSignature } from '../format/signature-cache.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
@@ -226,39 +227,10 @@ export async function* streamSSEResponse(response, originalModel) {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle no content received
|
||||
// Handle no content received - throw error to trigger retry in streaming-handler
|
||||
if (!hasEmittedStart) {
|
||||
logger.warn('[CloudCode] No content parts received, emitting empty message');
|
||||
yield {
|
||||
type: 'message_start',
|
||||
message: {
|
||||
id: messageId,
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [],
|
||||
model: originalModel,
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: {
|
||||
input_tokens: inputTokens - cacheReadTokens,
|
||||
output_tokens: 0,
|
||||
cache_read_input_tokens: cacheReadTokens,
|
||||
cache_creation_input_tokens: 0
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
yield {
|
||||
type: 'content_block_start',
|
||||
index: 0,
|
||||
content_block: { type: 'text', text: '' }
|
||||
};
|
||||
yield {
|
||||
type: 'content_block_delta',
|
||||
index: 0,
|
||||
delta: { type: 'text_delta', text: '[No response received from API]' }
|
||||
};
|
||||
yield { type: 'content_block_stop', index: 0 };
|
||||
logger.warn('[CloudCode] No content parts received, throwing for retry');
|
||||
throw new EmptyResponseError('No content parts received from API');
|
||||
} else {
|
||||
// Close any open block
|
||||
if (currentBlockType !== null) {
|
||||
|
||||
@@ -8,16 +8,17 @@
|
||||
import {
|
||||
ANTIGRAVITY_ENDPOINT_FALLBACKS,
|
||||
MAX_RETRIES,
|
||||
MAX_EMPTY_RESPONSE_RETRIES,
|
||||
MAX_WAIT_BEFORE_ERROR_MS
|
||||
} from '../constants.js';
|
||||
import { isRateLimitError, isAuthError } from '../errors.js';
|
||||
import { isRateLimitError, isAuthError, isEmptyResponseError } from '../errors.js';
|
||||
import { formatDuration, sleep, isNetworkError } from '../utils/helpers.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { parseResetTime } from './rate-limit-parser.js';
|
||||
import { buildCloudCodeRequest, buildHeaders } from './request-builder.js';
|
||||
import { streamSSEResponse } from './sse-streamer.js';
|
||||
import { getFallbackModel } from '../fallback-config.js';
|
||||
|
||||
import crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* Send a streaming request to Cloud Code with multi-account support
|
||||
@@ -70,8 +71,19 @@ export async function* sendMessageStream(anthropicRequest, accountManager, fallb
|
||||
const accountCount = accountManager.getAccountCount();
|
||||
logger.warn(`[CloudCode] All ${accountCount} account(s) rate-limited. Waiting ${formatDuration(allWaitMs)}...`);
|
||||
await sleep(allWaitMs);
|
||||
|
||||
// Add small buffer after waiting to ensure rate limits have truly expired
|
||||
await sleep(500);
|
||||
accountManager.clearExpiredLimits();
|
||||
account = accountManager.pickNext(model);
|
||||
|
||||
// If still no account after waiting, try optimistic reset
|
||||
// This handles cases where the API rate limit is transient
|
||||
if (!account) {
|
||||
logger.warn('[CloudCode] No account available after wait, attempting optimistic reset...');
|
||||
accountManager.resetAllRateLimits();
|
||||
account = accountManager.pickNext(model);
|
||||
}
|
||||
}
|
||||
|
||||
if (!account) {
|
||||
@@ -143,16 +155,90 @@ export async function* sendMessageStream(anthropicRequest, accountManager, fallb
|
||||
continue;
|
||||
}
|
||||
|
||||
// Stream the response - yield events as they arrive
|
||||
yield* streamSSEResponse(response, anthropicRequest.model);
|
||||
// Stream the response with retry logic for empty responses
|
||||
// Uses a for-loop for clearer retry semantics
|
||||
let currentResponse = response;
|
||||
|
||||
logger.debug('[CloudCode] Stream completed');
|
||||
return;
|
||||
for (let emptyRetries = 0; emptyRetries <= MAX_EMPTY_RESPONSE_RETRIES; emptyRetries++) {
|
||||
try {
|
||||
yield* streamSSEResponse(currentResponse, anthropicRequest.model);
|
||||
logger.debug('[CloudCode] Stream completed');
|
||||
return;
|
||||
} catch (streamError) {
|
||||
// Only retry on EmptyResponseError
|
||||
if (!isEmptyResponseError(streamError)) {
|
||||
throw streamError;
|
||||
}
|
||||
|
||||
// Check if we have retries left
|
||||
if (emptyRetries >= MAX_EMPTY_RESPONSE_RETRIES) {
|
||||
logger.error(`[CloudCode] Empty response after ${MAX_EMPTY_RESPONSE_RETRIES} retries`);
|
||||
yield* emitEmptyResponseFallback(anthropicRequest.model);
|
||||
return;
|
||||
}
|
||||
|
||||
// Exponential backoff: 500ms, 1000ms, 2000ms
|
||||
const backoffMs = 500 * Math.pow(2, emptyRetries);
|
||||
logger.warn(`[CloudCode] Empty response, retry ${emptyRetries + 1}/${MAX_EMPTY_RESPONSE_RETRIES} after ${backoffMs}ms...`);
|
||||
await sleep(backoffMs);
|
||||
|
||||
// Refetch the response
|
||||
currentResponse = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: buildHeaders(token, model, 'text/event-stream'),
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
// Handle specific error codes on retry
|
||||
if (!currentResponse.ok) {
|
||||
const retryErrorText = await currentResponse.text();
|
||||
|
||||
// Rate limit error - mark account and throw to trigger account switch
|
||||
if (currentResponse.status === 429) {
|
||||
const resetMs = parseResetTime(currentResponse, retryErrorText);
|
||||
accountManager.markRateLimited(account.email, resetMs, model);
|
||||
throw new Error(`429 RESOURCE_EXHAUSTED during retry: ${retryErrorText}`);
|
||||
}
|
||||
|
||||
// Auth error - clear caches and throw with recognizable message
|
||||
if (currentResponse.status === 401) {
|
||||
accountManager.clearTokenCache(account.email);
|
||||
accountManager.clearProjectCache(account.email);
|
||||
throw new Error(`401 AUTH_INVALID during retry: ${retryErrorText}`);
|
||||
}
|
||||
|
||||
// For 5xx errors, don't pass to streamer - just continue to next retry
|
||||
if (currentResponse.status >= 500) {
|
||||
logger.warn(`[CloudCode] Retry got ${currentResponse.status}, will retry...`);
|
||||
// Don't continue here - let the loop increment and refetch
|
||||
// Set currentResponse to null to force refetch at loop start
|
||||
emptyRetries--; // Compensate for loop increment since we didn't actually try
|
||||
await sleep(1000);
|
||||
// Refetch immediately for 5xx
|
||||
currentResponse = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: buildHeaders(token, model, 'text/event-stream'),
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (currentResponse.ok) {
|
||||
continue; // Try streaming with new response
|
||||
}
|
||||
// If still failing, let it fall through to throw
|
||||
}
|
||||
|
||||
throw new Error(`Empty response retry failed: ${currentResponse.status} - ${retryErrorText}`);
|
||||
}
|
||||
// Response is OK, loop will continue to try streamSSEResponse
|
||||
}
|
||||
}
|
||||
|
||||
} catch (endpointError) {
|
||||
if (isRateLimitError(endpointError)) {
|
||||
throw endpointError; // Re-throw to trigger account switch
|
||||
}
|
||||
if (isEmptyResponseError(endpointError)) {
|
||||
throw endpointError; // Re-throw empty response errors to outer handler
|
||||
}
|
||||
logger.warn(`[CloudCode] Stream error at ${endpoint}:`, endpointError.message);
|
||||
lastError = endpointError;
|
||||
}
|
||||
@@ -189,10 +275,10 @@ export async function* sendMessageStream(anthropicRequest, accountManager, fallb
|
||||
}
|
||||
|
||||
if (isNetworkError(error)) {
|
||||
logger.warn(`[CloudCode] Network error for ${account.email} (stream), trying next account... (${error.message})`);
|
||||
await sleep(1000); // Brief pause before retry
|
||||
accountManager.pickNext(model); // Advance to next account
|
||||
continue;
|
||||
logger.warn(`[CloudCode] Network error for ${account.email} (stream), trying next account... (${error.message})`);
|
||||
await sleep(1000); // Brief pause before retry
|
||||
accountManager.pickNext(model); // Advance to next account
|
||||
continue;
|
||||
}
|
||||
|
||||
throw error;
|
||||
@@ -201,3 +287,49 @@ export async function* sendMessageStream(anthropicRequest, accountManager, fallb
|
||||
|
||||
throw new Error('Max retries exceeded');
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a fallback message when all retry attempts fail with empty response
|
||||
* @param {string} model - The model name
|
||||
* @yields {Object} Anthropic-format SSE events for empty response fallback
|
||||
*/
|
||||
function* emitEmptyResponseFallback(model) {
|
||||
// Use proper message ID format consistent with Anthropic API
|
||||
const messageId = `msg_${crypto.randomBytes(16).toString('hex')}`;
|
||||
|
||||
yield {
|
||||
type: 'message_start',
|
||||
message: {
|
||||
id: messageId,
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [],
|
||||
model: model,
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: { input_tokens: 0, output_tokens: 0 }
|
||||
}
|
||||
};
|
||||
|
||||
yield {
|
||||
type: 'content_block_start',
|
||||
index: 0,
|
||||
content_block: { type: 'text', text: '' }
|
||||
};
|
||||
|
||||
yield {
|
||||
type: 'content_block_delta',
|
||||
index: 0,
|
||||
delta: { type: 'text_delta', text: '[No response after retries - please try again]' }
|
||||
};
|
||||
|
||||
yield { type: 'content_block_stop', index: 0 };
|
||||
|
||||
yield {
|
||||
type: 'message_delta',
|
||||
delta: { stop_reason: 'end_turn', stop_sequence: null },
|
||||
usage: { output_tokens: 0 }
|
||||
};
|
||||
|
||||
yield { type: 'message_stop' };
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ function getPlatformUserAgent() {
|
||||
}
|
||||
|
||||
// Cloud Code API endpoints (in fallback order)
|
||||
const ANTIGRAVITY_ENDPOINT_DAILY = 'https://daily-cloudcode-pa.sandbox.googleapis.com';
|
||||
const ANTIGRAVITY_ENDPOINT_DAILY = 'https://daily-cloudcode-pa.googleapis.com';
|
||||
const ANTIGRAVITY_ENDPOINT_PROD = 'https://cloudcode-pa.googleapis.com';
|
||||
|
||||
// Endpoint fallback order (daily → prod)
|
||||
@@ -76,8 +76,9 @@ export const ACCOUNT_CONFIG_PATH = config?.accountConfigPath || join(
|
||||
// Uses platform-specific path detection
|
||||
export const ANTIGRAVITY_DB_PATH = getAntigravityDbPath();
|
||||
|
||||
export const DEFAULT_COOLDOWN_MS = config?.defaultCooldownMs || (60 * 1000); // From config or 1 minute
|
||||
export const DEFAULT_COOLDOWN_MS = config?.defaultCooldownMs || (10 * 1000); // From config or 10 seconds (upstream default)
|
||||
export const MAX_RETRIES = config?.maxRetries || 5; // From config or 5
|
||||
export const MAX_EMPTY_RESPONSE_RETRIES = 2; // Max retries for empty API responses (from upstream)
|
||||
export const MAX_ACCOUNTS = config?.maxAccounts || 10; // From config or 10
|
||||
|
||||
// Rate limit wait thresholds
|
||||
@@ -146,6 +147,11 @@ export const OAUTH_CONFIG = {
|
||||
};
|
||||
export const OAUTH_REDIRECT_URI = `http://localhost:${OAUTH_CONFIG.callbackPort}/oauth-callback`;
|
||||
|
||||
// Minimal Antigravity system instruction (from CLIProxyAPI)
|
||||
// Only includes the essential identity portion to reduce token usage and improve response quality
|
||||
// Reference: GitHub issue #76, CLIProxyAPI, gcli2api
|
||||
export const ANTIGRAVITY_SYSTEM_INSTRUCTION = `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**`;
|
||||
|
||||
// Model fallback mapping - maps primary model to fallback when quota exhausted
|
||||
export const MODEL_FALLBACK_MAP = {
|
||||
'gemini-3-pro-high': 'claude-opus-4-5-thinking',
|
||||
@@ -168,6 +174,7 @@ export default {
|
||||
ANTIGRAVITY_DB_PATH,
|
||||
DEFAULT_COOLDOWN_MS,
|
||||
MAX_RETRIES,
|
||||
MAX_EMPTY_RESPONSE_RETRIES,
|
||||
MAX_ACCOUNTS,
|
||||
MAX_WAIT_BEFORE_ERROR_MS,
|
||||
MIN_SIGNATURE_LENGTH,
|
||||
@@ -178,5 +185,6 @@ export default {
|
||||
isThinkingModel,
|
||||
OAUTH_CONFIG,
|
||||
OAUTH_REDIRECT_URI,
|
||||
MODEL_FALLBACK_MAP
|
||||
MODEL_FALLBACK_MAP,
|
||||
ANTIGRAVITY_SYSTEM_INSTRUCTION
|
||||
};
|
||||
|
||||
@@ -118,6 +118,37 @@ export class ApiError extends AntigravityError {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Native module error (version mismatch, rebuild required)
|
||||
*/
|
||||
export class NativeModuleError extends AntigravityError {
|
||||
/**
|
||||
* @param {string} message - Error message
|
||||
* @param {boolean} rebuildSucceeded - Whether auto-rebuild succeeded
|
||||
* @param {boolean} restartRequired - Whether server restart is needed
|
||||
*/
|
||||
constructor(message, rebuildSucceeded = false, restartRequired = false) {
|
||||
super(message, 'NATIVE_MODULE_ERROR', false, { rebuildSucceeded, restartRequired });
|
||||
this.name = 'NativeModuleError';
|
||||
this.rebuildSucceeded = rebuildSucceeded;
|
||||
this.restartRequired = restartRequired;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty response error - thrown when API returns no content
|
||||
* Used to trigger retry logic in streaming handler
|
||||
*/
|
||||
export class EmptyResponseError extends AntigravityError {
|
||||
/**
|
||||
* @param {string} message - Error message
|
||||
*/
|
||||
constructor(message = 'No content received from API') {
|
||||
super(message, 'EMPTY_RESPONSE', true, {});
|
||||
this.name = 'EmptyResponseError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is a rate limit error
|
||||
* Works with both custom error classes and legacy string-based errors
|
||||
@@ -147,6 +178,16 @@ export function isAuthError(error) {
|
||||
msg.includes('TOKEN REFRESH FAILED');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is an empty response error
|
||||
* @param {Error} error - Error to check
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isEmptyResponseError(error) {
|
||||
return error instanceof EmptyResponseError ||
|
||||
error?.name === 'EmptyResponseError';
|
||||
}
|
||||
|
||||
export default {
|
||||
AntigravityError,
|
||||
RateLimitError,
|
||||
@@ -154,6 +195,9 @@ export default {
|
||||
NoAccountsError,
|
||||
MaxRetriesError,
|
||||
ApiError,
|
||||
NativeModuleError,
|
||||
EmptyResponseError,
|
||||
isRateLimitError,
|
||||
isAuthError
|
||||
isAuthError,
|
||||
isEmptyResponseError
|
||||
};
|
||||
|
||||
162
src/utils/native-module-helper.js
Normal file
162
src/utils/native-module-helper.js
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Native Module Helper
|
||||
* Detects and auto-rebuilds native Node.js modules when they become
|
||||
* incompatible after a Node.js version update.
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import { dirname, join } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
/**
|
||||
* Check if an error is a NODE_MODULE_VERSION mismatch error
|
||||
* @param {Error} error - The error to check
|
||||
* @returns {boolean} True if it's a version mismatch error
|
||||
*/
|
||||
export function isModuleVersionError(error) {
|
||||
const message = error?.message || '';
|
||||
return message.includes('NODE_MODULE_VERSION') &&
|
||||
message.includes('was compiled against a different Node.js version');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the module path from a NODE_MODULE_VERSION error message
|
||||
* @param {Error} error - The error containing the module path
|
||||
* @returns {string|null} The path to the .node file, or null if not found
|
||||
*/
|
||||
export function extractModulePath(error) {
|
||||
const message = error?.message || '';
|
||||
// Match pattern like: "The module '/path/to/module.node'"
|
||||
const match = message.match(/The module '([^']+\.node)'/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the package root directory from a .node file path
|
||||
* @param {string} nodeFilePath - Path to the .node file
|
||||
* @returns {string|null} Path to the package root, or null if not found
|
||||
*/
|
||||
export function findPackageRoot(nodeFilePath) {
|
||||
// Walk up from the .node file to find package.json
|
||||
let dir = dirname(nodeFilePath);
|
||||
while (dir) {
|
||||
const packageJsonPath = join(dir, 'package.json');
|
||||
if (existsSync(packageJsonPath)) {
|
||||
return dir;
|
||||
}
|
||||
const parentDir = dirname(dir);
|
||||
// Stop when we've reached the filesystem root (dirname returns same path)
|
||||
if (parentDir === dir) {
|
||||
break;
|
||||
}
|
||||
dir = parentDir;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to rebuild a native module
|
||||
* @param {string} packagePath - Path to the package root directory
|
||||
* @returns {boolean} True if rebuild succeeded, false otherwise
|
||||
*/
|
||||
export function rebuildModule(packagePath) {
|
||||
try {
|
||||
logger.info(`[NativeModule] Rebuilding native module at: ${packagePath}`);
|
||||
|
||||
// Run npm rebuild in the package directory
|
||||
const output = execSync('npm rebuild', {
|
||||
cwd: packagePath,
|
||||
stdio: 'pipe', // Capture output instead of printing
|
||||
timeout: 120000 // 2 minute timeout
|
||||
});
|
||||
|
||||
// Log rebuild output for debugging
|
||||
const outputStr = output?.toString().trim();
|
||||
if (outputStr) {
|
||||
logger.debug(`[NativeModule] Rebuild output:\n${outputStr}`);
|
||||
}
|
||||
|
||||
logger.success('[NativeModule] Rebuild completed successfully');
|
||||
return true;
|
||||
} catch (error) {
|
||||
// Include stdout/stderr from the failed command for troubleshooting
|
||||
const stdout = error.stdout?.toString().trim();
|
||||
const stderr = error.stderr?.toString().trim();
|
||||
let errorDetails = `[NativeModule] Rebuild failed: ${error.message}`;
|
||||
if (stdout) {
|
||||
errorDetails += `\n[NativeModule] stdout: ${stdout}`;
|
||||
}
|
||||
if (stderr) {
|
||||
errorDetails += `\n[NativeModule] stderr: ${stderr}`;
|
||||
}
|
||||
logger.error(errorDetails);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to auto-rebuild a native module from an error
|
||||
* @param {Error} error - The NODE_MODULE_VERSION error
|
||||
* @returns {boolean} True if rebuild succeeded, false otherwise
|
||||
*/
|
||||
export function attemptAutoRebuild(error) {
|
||||
const nodePath = extractModulePath(error);
|
||||
if (!nodePath) {
|
||||
logger.error('[NativeModule] Could not extract module path from error');
|
||||
return false;
|
||||
}
|
||||
|
||||
const packagePath = findPackageRoot(nodePath);
|
||||
if (!packagePath) {
|
||||
logger.error('[NativeModule] Could not find package root');
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.warn('[NativeModule] Native module version mismatch detected');
|
||||
logger.info('[NativeModule] Attempting automatic rebuild...');
|
||||
|
||||
return rebuildModule(packagePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively clear a module and its dependencies from the require cache
|
||||
* This is needed after rebuilding a native module to force re-import
|
||||
* @param {string} modulePath - Resolved path to the module
|
||||
* @param {object} cache - The require.cache object
|
||||
* @param {Set} [visited] - Set of already-visited paths to prevent cycles
|
||||
*/
|
||||
export function clearRequireCache(modulePath, cache, visited = new Set()) {
|
||||
if (visited.has(modulePath)) return;
|
||||
visited.add(modulePath);
|
||||
|
||||
const mod = cache[modulePath];
|
||||
if (!mod) return;
|
||||
|
||||
// Recursively clear children first
|
||||
if (mod.children) {
|
||||
for (const child of mod.children) {
|
||||
clearRequireCache(child.id, cache, visited);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from parent's children array
|
||||
if (mod.parent && mod.parent.children) {
|
||||
const idx = mod.parent.children.indexOf(mod);
|
||||
if (idx !== -1) {
|
||||
mod.parent.children.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete from cache
|
||||
delete cache[modulePath];
|
||||
}
|
||||
|
||||
export default {
|
||||
isModuleVersionError,
|
||||
extractModulePath,
|
||||
findPackageRoot,
|
||||
rebuildModule,
|
||||
attemptAutoRebuild,
|
||||
clearRequireCache
|
||||
};
|
||||
Reference in New Issue
Block a user