diff --git a/package.json b/package.json index f6d129d..2917fc5 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,17 @@ "type": "module", "scripts": { "start": "node src/index.js", - "dev": "node --watch src/index.js" + "dev": "node --watch src/index.js", + "accounts": "node src/accounts-cli.js", + "accounts:add": "node src/accounts-cli.js add", + "accounts:list": "node src/accounts-cli.js list", + "accounts:verify": "node src/accounts-cli.js verify", + "test": "node tests/run-all.cjs", + "test:signatures": "node tests/test-thinking-signatures.cjs", + "test:multiturn": "node tests/test-multiturn-thinking-tools.cjs", + "test:streaming": "node tests/test-multiturn-thinking-tools-streaming.cjs", + "test:interleaved": "node tests/test-interleaved-thinking.cjs", + "test:images": "node tests/test-images.cjs" }, "keywords": [ "claude", @@ -19,7 +29,6 @@ "license": "MIT", "dependencies": { "cors": "^2.8.5", - "express": "^4.18.2", - "node-fetch": "^3.3.2" + "express": "^4.18.2" } } diff --git a/src/account-manager.js b/src/account-manager.js new file mode 100644 index 0000000..58c0640 --- /dev/null +++ b/src/account-manager.js @@ -0,0 +1,514 @@ +/** + * Account Manager + * Manages multiple Antigravity accounts with round-robin selection, + * automatic failover, and smart cooldown for rate-limited accounts. + */ + +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; +import { dirname } from 'path'; +import { execSync } from 'child_process'; +import { homedir } from 'os'; +import { join } from 'path'; +import { + ACCOUNT_CONFIG_PATH, + DEFAULT_COOLDOWN_MS, + TOKEN_REFRESH_INTERVAL_MS, + ANTIGRAVITY_ENDPOINT_FALLBACKS, + ANTIGRAVITY_HEADERS, + DEFAULT_PROJECT_ID +} from './constants.js'; +import { refreshAccessToken } from './oauth.js'; + +// Default Antigravity database path +const ANTIGRAVITY_DB_PATH = join( + homedir(), + 'Library/Application Support/Antigravity/User/globalStorage/state.vscdb' +); + +/** + * Format duration in milliseconds to human-readable string (e.g., "1h23m45s") + */ +function formatDuration(ms) { + const seconds = Math.floor(ms / 1000); + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + if (hours > 0) { + return `${hours}h${minutes}m${secs}s`; + } else if (minutes > 0) { + return `${minutes}m${secs}s`; + } + return `${secs}s`; +} + +/** + * Sleep for specified milliseconds + */ +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +export class AccountManager { + #accounts = []; + #currentIndex = 0; + #configPath; + #settings = {}; + #initialized = false; + + // Per-account caches + #tokenCache = new Map(); // email -> { token, extractedAt } + #projectCache = new Map(); // email -> projectId + + constructor(configPath = ACCOUNT_CONFIG_PATH) { + this.#configPath = configPath; + } + + /** + * Initialize the account manager by loading config + */ + async initialize() { + if (this.#initialized) return; + + try { + if (existsSync(this.#configPath)) { + const configData = readFileSync(this.#configPath, 'utf-8'); + const config = JSON.parse(configData); + + this.#accounts = (config.accounts || []).map(acc => ({ + ...acc, + isRateLimited: acc.isRateLimited || false, + rateLimitResetTime: acc.rateLimitResetTime || null, + lastUsed: acc.lastUsed || null + })); + + this.#settings = config.settings || {}; + this.#currentIndex = config.activeIndex || 0; + + // Clamp currentIndex to valid range + if (this.#currentIndex >= this.#accounts.length) { + this.#currentIndex = 0; + } + + console.log(`[AccountManager] Loaded ${this.#accounts.length} account(s) from config`); + } else { + // No config file - use single account from Antigravity database + console.log('[AccountManager] No config file found. Using Antigravity database (single account mode)'); + await this.#loadDefaultAccount(); + } + } catch (error) { + console.error('[AccountManager] Failed to load config:', error.message); + // Fall back to default account + await this.#loadDefaultAccount(); + } + + // Clear any expired rate limits + this.clearExpiredLimits(); + + this.#initialized = true; + } + + /** + * Load the default account from Antigravity's database + */ + async #loadDefaultAccount() { + try { + const authData = this.#extractTokenFromDB(); + if (authData?.apiKey) { + this.#accounts = [{ + email: authData.email || 'default@antigravity', + source: 'database', + isRateLimited: false, + rateLimitResetTime: null, + lastUsed: null + }]; + // Pre-cache the token + this.#tokenCache.set(this.#accounts[0].email, { + token: authData.apiKey, + extractedAt: Date.now() + }); + console.log(`[AccountManager] Loaded default account: ${this.#accounts[0].email}`); + } + } catch (error) { + console.error('[AccountManager] Failed to load default account:', error.message); + // Create empty account list - will fail on first request + this.#accounts = []; + } + } + + /** + * Extract token from Antigravity's SQLite database + */ + #extractTokenFromDB(dbPath = ANTIGRAVITY_DB_PATH) { + const result = execSync( + `sqlite3 "${dbPath}" "SELECT value FROM ItemTable WHERE key = 'antigravityAuthStatus';"`, + { encoding: 'utf-8', timeout: 5000 } + ); + + if (!result || !result.trim()) { + throw new Error('No auth status found in database'); + } + + return JSON.parse(result.trim()); + } + + /** + * Get the number of accounts + */ + getAccountCount() { + return this.#accounts.length; + } + + /** + * Check if all accounts are rate-limited + */ + isAllRateLimited() { + if (this.#accounts.length === 0) return true; + return this.#accounts.every(acc => acc.isRateLimited); + } + + /** + * Get list of available (non-rate-limited, non-invalid) accounts + */ + getAvailableAccounts() { + return this.#accounts.filter(acc => !acc.isRateLimited && !acc.isInvalid); + } + + /** + * Get list of invalid accounts + */ + getInvalidAccounts() { + return this.#accounts.filter(acc => acc.isInvalid); + } + + /** + * Clear expired rate limits + */ + clearExpiredLimits() { + const now = Date.now(); + let cleared = 0; + + for (const account of this.#accounts) { + if (account.isRateLimited && account.rateLimitResetTime && account.rateLimitResetTime <= now) { + account.isRateLimited = false; + account.rateLimitResetTime = null; + cleared++; + console.log(`[AccountManager] Rate limit expired for: ${account.email}`); + } + } + + if (cleared > 0) { + this.saveToDisk(); + } + + return cleared; + } + + /** + * Pick the next available account (round-robin) + */ + pickNext() { + this.clearExpiredLimits(); + + const available = this.getAvailableAccounts(); + if (available.length === 0) { + return null; + } + + // Find next available account starting from current index + for (let i = 0; i < this.#accounts.length; i++) { + const idx = (this.#currentIndex + i) % this.#accounts.length; + const account = this.#accounts[idx]; + + if (!account.isRateLimited && !account.isInvalid) { + this.#currentIndex = (idx + 1) % this.#accounts.length; + account.lastUsed = Date.now(); + + const position = this.#accounts.indexOf(account) + 1; + const total = this.#accounts.length; + console.log(`[AccountManager] Using account: ${account.email} (${position}/${total})`); + + return account; + } + } + + return null; + } + + /** + * Mark an account as rate-limited + */ + markRateLimited(email, resetMs = null) { + const account = this.#accounts.find(a => a.email === email); + if (!account) return; + + account.isRateLimited = true; + const cooldownMs = resetMs || this.#settings.cooldownDurationMs || DEFAULT_COOLDOWN_MS; + account.rateLimitResetTime = Date.now() + cooldownMs; + + console.log( + `[AccountManager] Rate limited: ${email}. Available in ${formatDuration(cooldownMs)}` + ); + + this.saveToDisk(); + } + + /** + * Mark an account as invalid (credentials need re-authentication) + */ + markInvalid(email, reason = 'Unknown error') { + const account = this.#accounts.find(a => a.email === email); + if (!account) return; + + account.isInvalid = true; + account.invalidReason = reason; + account.invalidAt = Date.now(); + + console.log( + `[AccountManager] ⚠ Account INVALID: ${email}` + ); + console.log( + `[AccountManager] Reason: ${reason}` + ); + console.log( + `[AccountManager] Run 'npm run accounts' to re-authenticate this account` + ); + + this.saveToDisk(); + } + + /** + * Get the minimum wait time until any account becomes available + */ + getMinWaitTimeMs() { + if (!this.isAllRateLimited()) return 0; + + const now = Date.now(); + let minWait = Infinity; + let soonestAccount = null; + + for (const account of this.#accounts) { + if (account.rateLimitResetTime) { + const wait = account.rateLimitResetTime - now; + if (wait > 0 && wait < minWait) { + minWait = wait; + soonestAccount = account; + } + } + } + + if (soonestAccount) { + console.log(`[AccountManager] Shortest wait: ${formatDuration(minWait)} (account: ${soonestAccount.email})`); + } + + return minWait === Infinity ? DEFAULT_COOLDOWN_MS : minWait; + } + + /** + * Get OAuth token for an account + */ + async getTokenForAccount(account) { + // Check cache first + const cached = this.#tokenCache.get(account.email); + if (cached && (Date.now() - cached.extractedAt) < TOKEN_REFRESH_INTERVAL_MS) { + return cached.token; + } + + // Get fresh token based on source + let token; + + if (account.source === 'oauth' && account.refreshToken) { + // OAuth account - use refresh token to get new access token + try { + const tokens = await refreshAccessToken(account.refreshToken); + token = tokens.accessToken; + // Clear invalid flag on success + if (account.isInvalid) { + account.isInvalid = false; + account.invalidReason = null; + this.saveToDisk(); + } + console.log(`[AccountManager] Refreshed OAuth token for: ${account.email}`); + } catch (error) { + console.error(`[AccountManager] Failed to refresh token for ${account.email}:`, error.message); + // Mark account as invalid (credentials need re-auth) + this.markInvalid(account.email, error.message); + throw new Error(`AUTH_INVALID: ${account.email}: ${error.message}`); + } + } else if (account.source === 'manual' && account.apiKey) { + token = account.apiKey; + } else { + // Extract from database + const dbPath = account.dbPath || ANTIGRAVITY_DB_PATH; + const authData = this.#extractTokenFromDB(dbPath); + token = authData.apiKey; + } + + // Cache the token + this.#tokenCache.set(account.email, { + token, + extractedAt: Date.now() + }); + + return token; + } + + /** + * Get project ID for an account + */ + async getProjectForAccount(account, token) { + // Check cache first + const cached = this.#projectCache.get(account.email); + if (cached) { + return cached; + } + + // OAuth or manual accounts may have projectId specified + if (account.projectId) { + this.#projectCache.set(account.email, account.projectId); + return account.projectId; + } + + // Discover project via loadCodeAssist API + const project = await this.#discoverProject(token); + this.#projectCache.set(account.email, project); + return project; + } + + /** + * Discover project ID via Cloud Code API + */ + async #discoverProject(token) { + for (const endpoint of ANTIGRAVITY_ENDPOINT_FALLBACKS) { + try { + const response = await fetch(`${endpoint}/v1internal:loadCodeAssist`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + ...ANTIGRAVITY_HEADERS + }, + body: JSON.stringify({ + metadata: { + ideType: 'IDE_UNSPECIFIED', + platform: 'PLATFORM_UNSPECIFIED', + pluginType: 'GEMINI' + } + }) + }); + + if (!response.ok) continue; + + const data = await response.json(); + + if (typeof data.cloudaicompanionProject === 'string') { + return data.cloudaicompanionProject; + } + if (data.cloudaicompanionProject?.id) { + return data.cloudaicompanionProject.id; + } + } catch (error) { + console.log(`[AccountManager] Project discovery failed at ${endpoint}:`, error.message); + } + } + + console.log(`[AccountManager] Using default project: ${DEFAULT_PROJECT_ID}`); + return DEFAULT_PROJECT_ID; + } + + /** + * Clear project cache for an account (useful on auth errors) + */ + clearProjectCache(email = null) { + if (email) { + this.#projectCache.delete(email); + } else { + this.#projectCache.clear(); + } + } + + /** + * Clear token cache for an account (useful on auth errors) + */ + clearTokenCache(email = null) { + if (email) { + this.#tokenCache.delete(email); + } else { + this.#tokenCache.clear(); + } + } + + /** + * Save current state to disk + */ + saveToDisk() { + try { + // Ensure directory exists + const dir = dirname(this.#configPath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + const config = { + accounts: this.#accounts.map(acc => ({ + email: acc.email, + source: acc.source, + dbPath: acc.dbPath || null, + refreshToken: acc.source === 'oauth' ? acc.refreshToken : undefined, + apiKey: acc.source === 'manual' ? acc.apiKey : undefined, + projectId: acc.projectId || undefined, + addedAt: acc.addedAt || undefined, + isRateLimited: acc.isRateLimited, + rateLimitResetTime: acc.rateLimitResetTime, + isInvalid: acc.isInvalid || false, + invalidReason: acc.invalidReason || null, + lastUsed: acc.lastUsed + })), + settings: this.#settings, + activeIndex: this.#currentIndex + }; + + writeFileSync(this.#configPath, JSON.stringify(config, null, 2)); + } catch (error) { + console.error('[AccountManager] Failed to save config:', error.message); + } + } + + /** + * Get status object for logging/API + */ + getStatus() { + const available = this.getAvailableAccounts(); + const rateLimited = this.#accounts.filter(a => a.isRateLimited); + const invalid = this.getInvalidAccounts(); + + return { + total: this.#accounts.length, + available: available.length, + rateLimited: rateLimited.length, + invalid: invalid.length, + summary: `${this.#accounts.length} total, ${available.length} available, ${rateLimited.length} rate-limited, ${invalid.length} invalid`, + accounts: this.#accounts.map(a => ({ + email: a.email, + source: a.source, + isRateLimited: a.isRateLimited, + rateLimitResetTime: a.rateLimitResetTime, + isInvalid: a.isInvalid || false, + invalidReason: a.invalidReason || null, + lastUsed: a.lastUsed + })) + }; + } + + /** + * Get settings + */ + getSettings() { + return { ...this.#settings }; + } +} + +// Export helper functions +export { formatDuration, sleep }; + +export default AccountManager; diff --git a/src/accounts-cli.js b/src/accounts-cli.js new file mode 100644 index 0000000..551f151 --- /dev/null +++ b/src/accounts-cli.js @@ -0,0 +1,336 @@ +#!/usr/bin/env node + +/** + * Account Management CLI + * + * Interactive CLI for adding and managing Google accounts + * for the Antigravity Claude Proxy. + * + * Usage: + * node src/accounts-cli.js # Interactive mode + * node src/accounts-cli.js add # Add new account(s) + * node src/accounts-cli.js list # List all accounts + * node src/accounts-cli.js clear # Remove all accounts + */ + +import { createInterface } from 'readline/promises'; +import { stdin, stdout } from 'process'; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; +import { dirname } from 'path'; +import { exec } from 'child_process'; +import { ACCOUNT_CONFIG_PATH } from './constants.js'; +import { + getAuthorizationUrl, + startCallbackServer, + completeOAuthFlow, + refreshAccessToken, + getUserEmail +} from './oauth.js'; + +const MAX_ACCOUNTS = 10; + +/** + * Create readline interface + */ +function createRL() { + return createInterface({ input: stdin, output: stdout }); +} + +/** + * Open URL in default browser + */ +function openBrowser(url) { + const platform = process.platform; + let command; + + if (platform === 'darwin') { + command = `open "${url}"`; + } else if (platform === 'win32') { + command = `start "" "${url}"`; + } else { + command = `xdg-open "${url}"`; + } + + exec(command, (error) => { + if (error) { + console.log('\n⚠ Could not open browser automatically.'); + console.log('Please open this URL manually:', url); + } + }); +} + +/** + * Load existing accounts from config + */ +function loadAccounts() { + try { + if (existsSync(ACCOUNT_CONFIG_PATH)) { + const data = readFileSync(ACCOUNT_CONFIG_PATH, 'utf-8'); + const config = JSON.parse(data); + return config.accounts || []; + } + } catch (error) { + console.error('Error loading accounts:', error.message); + } + return []; +} + +/** + * Save accounts to config + */ +function saveAccounts(accounts, settings = {}) { + try { + const dir = dirname(ACCOUNT_CONFIG_PATH); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + const config = { + accounts: accounts.map(acc => ({ + email: acc.email, + source: 'oauth', + refreshToken: acc.refreshToken, + projectId: acc.projectId, + addedAt: acc.addedAt || new Date().toISOString(), + lastUsed: acc.lastUsed || null, + isRateLimited: acc.isRateLimited || false, + rateLimitResetTime: acc.rateLimitResetTime || null + })), + settings: { + cooldownDurationMs: 60000, + maxRetries: 5, + ...settings + }, + activeIndex: 0 + }; + + writeFileSync(ACCOUNT_CONFIG_PATH, JSON.stringify(config, null, 2)); + console.log(`\n✓ Saved ${accounts.length} account(s) to ${ACCOUNT_CONFIG_PATH}`); + } catch (error) { + console.error('Error saving accounts:', error.message); + throw error; + } +} + +/** + * Display current accounts + */ +function displayAccounts(accounts) { + if (accounts.length === 0) { + console.log('\nNo accounts configured.'); + return; + } + + console.log(`\n${accounts.length} account(s) saved:`); + accounts.forEach((acc, i) => { + const status = acc.isRateLimited ? ' (rate-limited)' : ''; + console.log(` ${i + 1}. ${acc.email}${status}`); + }); +} + +/** + * Add a new account via OAuth with automatic callback + */ +async function addAccount(existingAccounts) { + console.log('\n=== Add Google Account ===\n'); + + // Generate authorization URL + const { url, verifier, state } = getAuthorizationUrl(); + + console.log('Opening browser for Google sign-in...'); + console.log('(If browser does not open, copy this URL manually)\n'); + console.log(` ${url}\n`); + + // Open browser + openBrowser(url); + + // Start callback server and wait for code + console.log('Waiting for authentication (timeout: 2 minutes)...\n'); + + try { + const code = await startCallbackServer(state); + + console.log('Received authorization code. Exchanging 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(), + isRateLimited: false, + rateLimitResetTime: null + }; + } catch (error) { + console.error(`\n✗ Authentication failed: ${error.message}`); + return null; + } +} + +/** + * Interactive add accounts flow + */ +async function interactiveAdd(rl) { + const accounts = loadAccounts(); + + if (accounts.length > 0) { + displayAccounts(accounts); + + const choice = await rl.question('\n(a)dd new account(s) or (f)resh start? [a/f]: '); + + if (choice.toLowerCase() === 'f') { + console.log('\nStarting fresh - existing accounts will be replaced.'); + accounts.length = 0; + } else { + console.log('\nAdding to existing accounts.'); + } + } + + // Add accounts loop + while (accounts.length < MAX_ACCOUNTS) { + const newAccount = await addAccount(accounts); + if (newAccount) { + accounts.push(newAccount); + // Auto-save after each successful add to prevent data loss + saveAccounts(accounts); + } else if (accounts.length > 0) { + // Even if newAccount is null (duplicate update), save the updated accounts + saveAccounts(accounts); + } + + if (accounts.length >= MAX_ACCOUNTS) { + console.log(`\nMaximum of ${MAX_ACCOUNTS} accounts reached.`); + break; + } + + const addMore = await rl.question('\nAdd another account? [y/N]: '); + if (addMore.toLowerCase() !== 'y') { + break; + } + } + + if (accounts.length > 0) { + displayAccounts(accounts); + } else { + console.log('\nNo accounts to save.'); + } +} + +/** + * List accounts + */ +async function listAccounts() { + const accounts = loadAccounts(); + displayAccounts(accounts); + + if (accounts.length > 0) { + console.log(`\nConfig file: ${ACCOUNT_CONFIG_PATH}`); + } +} + +/** + * Clear all accounts + */ +async function clearAccounts(rl) { + const accounts = loadAccounts(); + + if (accounts.length === 0) { + console.log('No accounts to clear.'); + return; + } + + displayAccounts(accounts); + + const confirm = await rl.question('\nAre you sure you want to remove all accounts? [y/N]: '); + if (confirm.toLowerCase() === 'y') { + saveAccounts([]); + console.log('All accounts removed.'); + } else { + console.log('Cancelled.'); + } +} + +/** + * Verify accounts (test refresh tokens) + */ +async function verifyAccounts() { + const accounts = loadAccounts(); + + if (accounts.length === 0) { + console.log('No accounts to verify.'); + return; + } + + console.log('\nVerifying accounts...\n'); + + for (const account of accounts) { + try { + const tokens = await refreshAccessToken(account.refreshToken); + const email = await getUserEmail(tokens.accessToken); + console.log(` ✓ ${email} - OK`); + } catch (error) { + console.log(` ✗ ${account.email} - ${error.message}`); + } + } +} + +/** + * Main CLI + */ +async function main() { + const args = process.argv.slice(2); + const command = args[0] || 'add'; + + console.log('╔════════════════════════════════════════╗'); + console.log('║ Antigravity Proxy Account Manager ║'); + console.log('╚════════════════════════════════════════╝'); + + const rl = createRL(); + + try { + switch (command) { + case 'add': + await interactiveAdd(rl); + break; + case 'list': + await listAccounts(); + break; + case 'clear': + await clearAccounts(rl); + break; + case 'verify': + await verifyAccounts(); + break; + case 'help': + console.log('\nUsage:'); + console.log(' node src/accounts-cli.js add Add new account(s)'); + console.log(' node src/accounts-cli.js list List all accounts'); + console.log(' node src/accounts-cli.js verify Verify account tokens'); + console.log(' node src/accounts-cli.js clear Remove all accounts'); + console.log(' node src/accounts-cli.js help Show this help'); + break; + default: + console.log(`Unknown command: ${command}`); + console.log('Run with "help" for usage information.'); + } + } finally { + rl.close(); + } +} + +main().catch(console.error); diff --git a/src/cloudcode-client.js b/src/cloudcode-client.js index 55e4c3e..a3c826e 100644 --- a/src/cloudcode-client.js +++ b/src/cloudcode-client.js @@ -1,108 +1,173 @@ /** * Cloud Code Client for Antigravity - * + * * Communicates with Google's Cloud Code internal API using the * v1internal:streamGenerateContent endpoint with proper request wrapping. - * + * + * Supports multi-account load balancing with automatic failover. + * * Based on: https://github.com/NoeFabris/opencode-antigravity-auth */ import crypto from 'crypto'; -import { getToken, refreshToken } from './token-extractor.js'; import { ANTIGRAVITY_ENDPOINT_FALLBACKS, ANTIGRAVITY_HEADERS, AVAILABLE_MODELS, - DEFAULT_PROJECT_ID, - STREAMING_CHUNK_SIZE + MAX_RETRIES } from './constants.js'; import { mapModelName, convertAnthropicToGoogle, - convertGoogleToAnthropic, - convertStreamingChunk + convertGoogleToAnthropic } from './format-converter.js'; - -// Cache the project ID -let cachedProject = null; +import { formatDuration, sleep } from './account-manager.js'; /** - * Get the user's cloudaicompanion project from the API + * Check if an error is a rate limit error (429 or RESOURCE_EXHAUSTED) */ -export async function getProject(token) { - if (cachedProject) { - return cachedProject; - } +function is429Error(error) { + const msg = (error.message || '').toLowerCase(); + return msg.includes('429') || + msg.includes('resource_exhausted') || + msg.includes('quota_exhausted') || + msg.includes('rate limit'); +} - console.log('[CloudCode] Getting project from loadCodeAssist...'); +/** + * Check if an error is an auth-invalid error (credentials need re-authentication) + */ +function isAuthInvalidError(error) { + const msg = (error.message || '').toUpperCase(); + return msg.includes('AUTH_INVALID') || + msg.includes('INVALID_GRANT') || + msg.includes('TOKEN REFRESH FAILED'); +} - // Try each endpoint - for (const endpoint of ANTIGRAVITY_ENDPOINT_FALLBACKS) { - try { - const response = await fetch(`${endpoint}/v1internal:loadCodeAssist`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json', - ...ANTIGRAVITY_HEADERS - }, - body: JSON.stringify({ - metadata: { - ideType: 'IDE_UNSPECIFIED', - platform: 'PLATFORM_UNSPECIFIED', - pluginType: 'GEMINI' +/** + * Parse reset time from HTTP response or error + * Checks headers first, then error message body + * Returns milliseconds or null if not found + * + * @param {Response|Error} responseOrError - HTTP Response object or Error + * @param {string} errorText - Optional error body text + */ +function parseResetTime(responseOrError, errorText = '') { + let resetMs = null; + + // If it's a Response object, check headers first + if (responseOrError && typeof responseOrError.headers?.get === 'function') { + const headers = responseOrError.headers; + + // Standard Retry-After header (seconds or HTTP date) + const retryAfter = headers.get('retry-after'); + if (retryAfter) { + const seconds = parseInt(retryAfter, 10); + if (!isNaN(seconds)) { + resetMs = seconds * 1000; + console.log(`[CloudCode] Retry-After header: ${seconds}s`); + } else { + // Try parsing as HTTP date + const date = new Date(retryAfter); + if (!isNaN(date.getTime())) { + resetMs = date.getTime() - Date.now(); + if (resetMs > 0) { + console.log(`[CloudCode] Retry-After date: ${retryAfter}`); + } else { + resetMs = null; } - }) - }); - - if (!response.ok) { - console.log(`[CloudCode] loadCodeAssist failed at ${endpoint}: ${response.status}`); - continue; + } } + } - const data = await response.json(); - - // Extract project ID from response - if (typeof data.cloudaicompanionProject === 'string' && data.cloudaicompanionProject) { - cachedProject = data.cloudaicompanionProject; - console.log(`[CloudCode] Got project: ${cachedProject}`); - return cachedProject; + // x-ratelimit-reset (Unix timestamp in seconds) + if (!resetMs) { + const ratelimitReset = headers.get('x-ratelimit-reset'); + if (ratelimitReset) { + const resetTimestamp = parseInt(ratelimitReset, 10) * 1000; + resetMs = resetTimestamp - Date.now(); + if (resetMs > 0) { + console.log(`[CloudCode] x-ratelimit-reset: ${new Date(resetTimestamp).toISOString()}`); + } else { + resetMs = null; + } } + } - if (data.cloudaicompanionProject?.id) { - cachedProject = data.cloudaicompanionProject.id; - console.log(`[CloudCode] Got project: ${cachedProject}`); - return cachedProject; + // x-ratelimit-reset-after (seconds) + if (!resetMs) { + const resetAfter = headers.get('x-ratelimit-reset-after'); + if (resetAfter) { + const seconds = parseInt(resetAfter, 10); + if (!isNaN(seconds) && seconds > 0) { + resetMs = seconds * 1000; + console.log(`[CloudCode] x-ratelimit-reset-after: ${seconds}s`); + } } - - console.log(`[CloudCode] No project in response from ${endpoint}`); - } catch (error) { - console.log(`[CloudCode] Error at ${endpoint}:`, error.message); } } - // Use default project if discovery fails - console.log(`[CloudCode] Using default project: ${DEFAULT_PROJECT_ID}`); - cachedProject = DEFAULT_PROJECT_ID; - return cachedProject; -} + // If no header found, try parsing from error message/body + if (!resetMs) { + const msg = (responseOrError instanceof Error ? responseOrError.message : errorText) || ''; -/** - * Clear the cached project - */ -export function clearProjectCache() { - cachedProject = null; -} + // Try to extract "retry-after-ms" or "retryDelay" in ms + const msMatch = msg.match(/retry[-_]?after[-_]?ms[:\s"]+(\d+)/i) || + msg.match(/retryDelay[:\s"]+(\d+)/i); + if (msMatch) { + resetMs = parseInt(msMatch[1], 10); + console.log(`[CloudCode] Parsed retry-after-ms from body: ${resetMs}ms`); + } -/** - * Refresh token and get project - helper to avoid duplicate logic - */ -async function refreshAndGetProject() { - await refreshToken(); - const token = await getToken(); - clearProjectCache(); - const project = await getProject(token); - return { token, project }; + // Try to extract seconds value like "retry after 60 seconds" + if (!resetMs) { + const secMatch = msg.match(/retry\s+(?:after\s+)?(\d+)\s*(?:sec|s\b)/i); + if (secMatch) { + resetMs = parseInt(secMatch[1], 10) * 1000; + console.log(`[CloudCode] Parsed retry seconds from body: ${secMatch[1]}s`); + } + } + + // Try to extract duration like "1h23m45s" or "23m45s" or "45s" + if (!resetMs) { + const durationMatch = msg.match(/(\d+)h(\d+)m(\d+)s|(\d+)m(\d+)s|(\d+)s/i); + if (durationMatch) { + if (durationMatch[1]) { + const hours = parseInt(durationMatch[1], 10); + const minutes = parseInt(durationMatch[2], 10); + const seconds = parseInt(durationMatch[3], 10); + resetMs = (hours * 3600 + minutes * 60 + seconds) * 1000; + } else if (durationMatch[4]) { + const minutes = parseInt(durationMatch[4], 10); + const seconds = parseInt(durationMatch[5], 10); + resetMs = (minutes * 60 + seconds) * 1000; + } else if (durationMatch[6]) { + resetMs = parseInt(durationMatch[6], 10) * 1000; + } + if (resetMs) { + console.log(`[CloudCode] Parsed duration from body: ${formatDuration(resetMs)}`); + } + } + } + + // Try to extract ISO timestamp or Unix timestamp + if (!resetMs) { + const isoMatch = msg.match(/reset[:\s"]+(\d{4}-\d{2}-\d{2}T[\d:.]+Z?)/i); + if (isoMatch) { + const resetTime = new Date(isoMatch[1]).getTime(); + if (!isNaN(resetTime)) { + resetMs = resetTime - Date.now(); + if (resetMs > 0) { + console.log(`[CloudCode] Parsed ISO reset time: ${isoMatch[1]}`); + } else { + resetMs = null; + } + } + } + } + } + + return resetMs; } /** @@ -112,8 +177,8 @@ function buildCloudCodeRequest(anthropicRequest, projectId) { const model = mapModelName(anthropicRequest.model); const googleRequest = convertAnthropicToGoogle(anthropicRequest); - // Add session ID - googleRequest.sessionId = '-' + Math.floor(Math.random() * 9000000000000000000).toString(); + // Use random session ID for API tracking + googleRequest.sessionId = crypto.randomUUID(); const payload = { project: projectId, @@ -132,184 +197,642 @@ function buildCloudCodeRequest(anthropicRequest, projectId) { } /** - * Send a non-streaming request to Cloud Code + * Build headers for Cloud Code API requests */ -export async function sendMessage(anthropicRequest) { - let token = await getToken(); - let project; +function buildHeaders(token, model, accept = 'application/json') { + const headers = { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + ...ANTIGRAVITY_HEADERS + }; - try { - project = await getProject(token); - } catch (err) { - console.log('[CloudCode] Project fetch failed, refreshing token...'); - ({ token, project } = await refreshAndGetProject()); + // Add interleaved thinking header for Claude thinking models + const isThinkingModel = model.toLowerCase().includes('thinking'); + if (isThinkingModel) { + headers['anthropic-beta'] = 'interleaved-thinking-2025-05-14'; } - const model = mapModelName(anthropicRequest.model); - const payload = buildCloudCodeRequest(anthropicRequest, project); - - console.log(`[CloudCode] Sending request for model: ${model}`); - - // Try each endpoint - let lastError = null; - for (const endpoint of ANTIGRAVITY_ENDPOINT_FALLBACKS) { - try { - const url = `${endpoint}/v1internal:generateContent`; - - const response = await fetch(url, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json', - ...ANTIGRAVITY_HEADERS - }, - body: JSON.stringify(payload) - }); - - if (!response.ok) { - const errorText = await response.text(); - console.log(`[CloudCode] Error at ${endpoint}: ${response.status} - ${errorText}`); - - // Handle auth errors - if (response.status === 401) { - console.log('[CloudCode] Auth error, refreshing token...'); - ({ token, project } = await refreshAndGetProject()); - // Retry with new token - payload.project = project; - continue; - } - - // Handle rate limiting - if (response.status === 429) { - lastError = new Error(`Rate limited: ${errorText}`); - continue; - } - - // Try next endpoint for 4xx/5xx errors - if (response.status >= 400) { - lastError = new Error(`API error ${response.status}: ${errorText}`); - continue; - } - } - - const data = await response.json(); - console.log('[CloudCode] Response received'); - - return convertGoogleToAnthropic(data, anthropicRequest.model); - - } catch (error) { - console.log(`[CloudCode] Error at ${endpoint}:`, error.message); - lastError = error; - } + if (accept !== 'application/json') { + headers['Accept'] = accept; } - throw lastError || new Error('All endpoints failed'); + return headers; } /** - * Send a streaming request to Cloud Code - * Note: Antigravity's streaming API doesn't actually stream text incrementally, - * so we use the non-streaming API and simulate SSE events for client compatibility. + * Send a non-streaming request to Cloud Code with multi-account support + * Uses SSE endpoint for thinking models (non-streaming doesn't return thinking blocks) + * + * @param {Object} anthropicRequest - The Anthropic-format request + * @param {AccountManager} accountManager - The account manager instance */ -export async function* sendMessageStream(anthropicRequest) { - // Get the full response first - const fullResponse = await sendMessage(anthropicRequest); +export async function sendMessage(anthropicRequest, accountManager) { + const model = mapModelName(anthropicRequest.model); + const isThinkingModel = model.toLowerCase().includes('thinking'); - console.log('[CloudCode] Simulating stream from full response'); + // Retry loop with account failover + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + // Get next available account + let account = accountManager.pickNext(); - // Emit message_start - yield { - type: 'message_start', - message: { - id: fullResponse.id, - type: 'message', - role: 'assistant', - content: [], - model: fullResponse.model, - stop_reason: null, - stop_sequence: null, - usage: { input_tokens: fullResponse.usage?.input_tokens || 0, output_tokens: 0 } - } - }; + // Handle all accounts rate-limited + if (!account) { + if (accountManager.isAllRateLimited()) { + const waitMs = accountManager.getMinWaitTimeMs(); - // Process each content block - let blockIndex = 0; - for (const block of fullResponse.content) { - if (block.type === 'text') { - // content_block_start - yield { - type: 'content_block_start', - index: blockIndex, - content_block: { type: 'text', text: '' } - }; - - // Stream text in chunks for a more realistic streaming experience - const text = block.text; - - for (let i = 0; i < text.length; i += STREAMING_CHUNK_SIZE) { - const chunk = text.slice(i, i + STREAMING_CHUNK_SIZE); - yield { - type: 'content_block_delta', - index: blockIndex, - delta: { type: 'text_delta', text: chunk } - }; + if (accountManager.getAccountCount() === 1) { + // Single account mode: wait for reset + console.log(`[CloudCode] Single account rate-limited. Waiting ${formatDuration(waitMs)}...`); + await sleep(waitMs); + accountManager.clearExpiredLimits(); + account = accountManager.pickNext(); + } else { + // Multi-account: all exhausted - throw proper error + const resetTime = new Date(Date.now() + waitMs).toISOString(); + throw new Error( + `RESOURCE_EXHAUSTED: All ${accountManager.getAccountCount()} accounts rate-limited. ` + + `quota will reset after ${formatDuration(waitMs)}. Next available: ${resetTime}` + ); + } } - // content_block_stop - yield { - type: 'content_block_stop', - index: blockIndex - }; + if (!account) { + throw new Error('No accounts available'); + } + } - blockIndex++; + try { + // Get token and project for this account + const token = await accountManager.getTokenForAccount(account); + const project = await accountManager.getProjectForAccount(account, token); + const payload = buildCloudCodeRequest(anthropicRequest, project); - } else if (block.type === 'tool_use') { - // content_block_start for tool_use - yield { - type: 'content_block_start', - index: blockIndex, - content_block: { - type: 'tool_use', - id: block.id, - name: block.name, - input: {} + console.log(`[CloudCode] Sending request for model: ${model}`); + + // Try each endpoint + let lastError = null; + for (const endpoint of ANTIGRAVITY_ENDPOINT_FALLBACKS) { + try { + const url = isThinkingModel + ? `${endpoint}/v1internal:streamGenerateContent?alt=sse` + : `${endpoint}/v1internal:generateContent`; + + const response = await fetch(url, { + method: 'POST', + headers: buildHeaders(token, model, isThinkingModel ? 'text/event-stream' : 'application/json'), + body: JSON.stringify(payload) + }); + + if (!response.ok) { + const errorText = await response.text(); + console.log(`[CloudCode] Error at ${endpoint}: ${response.status} - ${errorText}`); + + if (response.status === 401) { + // Auth error - clear caches and retry with fresh token + console.log('[CloudCode] Auth error, refreshing token...'); + accountManager.clearTokenCache(account.email); + accountManager.clearProjectCache(account.email); + continue; + } + + if (response.status === 429) { + // Rate limited on this endpoint - try next endpoint first (DAILY → PROD) + console.log(`[CloudCode] Rate limited at ${endpoint}, trying next endpoint...`); + const resetMs = parseResetTime(response, errorText); + // Keep minimum reset time across all 429 responses + if (!lastError?.is429 || (resetMs && (!lastError.resetMs || resetMs < lastError.resetMs))) { + lastError = { is429: true, response, errorText, resetMs }; + } + continue; + } + + if (response.status >= 400) { + lastError = new Error(`API error ${response.status}: ${errorText}`); + continue; + } + } + + // For thinking models, parse SSE and accumulate all parts + if (isThinkingModel) { + return await parseThinkingSSEResponse(response, anthropicRequest.model); + } + + // Non-thinking models use regular JSON + const data = await response.json(); + console.log('[CloudCode] Response received'); + return convertGoogleToAnthropic(data, anthropicRequest.model); + + } catch (endpointError) { + if (is429Error(endpointError)) { + throw endpointError; // Re-throw to trigger account switch + } + console.log(`[CloudCode] Error at ${endpoint}:`, endpointError.message); + lastError = endpointError; } - }; + } - // Send input as delta - yield { - type: 'content_block_delta', - index: blockIndex, - delta: { - type: 'input_json_delta', - partial_json: JSON.stringify(block.input) + // If all endpoints failed for this account + if (lastError) { + // If all endpoints returned 429, mark account as rate-limited + if (lastError.is429) { + console.log(`[CloudCode] All endpoints rate-limited for ${account.email}`); + accountManager.markRateLimited(account.email, lastError.resetMs); + throw new Error(`Rate limited: ${lastError.errorText}`); } - }; + throw lastError; + } - // content_block_stop - yield { - type: 'content_block_stop', - index: blockIndex - }; - - blockIndex++; + } catch (error) { + if (is429Error(error)) { + // Rate limited - already marked, continue to next account + console.log(`[CloudCode] Account ${account.email} rate-limited, trying next...`); + continue; + } + if (isAuthInvalidError(error)) { + // Auth invalid - already marked, continue to next account + console.log(`[CloudCode] Account ${account.email} has invalid credentials, trying next...`); + continue; + } + // Non-rate-limit error: throw immediately + throw error; } } - // message_delta - yield { - type: 'message_delta', - delta: { - stop_reason: fullResponse.stop_reason, - stop_sequence: fullResponse.stop_sequence - }, - usage: { output_tokens: fullResponse.usage?.output_tokens || 0 } + throw new Error('Max retries exceeded'); +} + +/** + * Parse SSE response for thinking models and accumulate all parts + */ +async function parseThinkingSSEResponse(response, originalModel) { + let accumulatedThinkingText = ''; + let accumulatedThinkingSignature = ''; + let accumulatedText = ''; + const finalParts = []; + let usageMetadata = {}; + let finishReason = 'STOP'; + + const flushThinking = () => { + if (accumulatedThinkingText) { + finalParts.push({ + thought: true, + text: accumulatedThinkingText, + thoughtSignature: accumulatedThinkingSignature + }); + accumulatedThinkingText = ''; + accumulatedThinkingSignature = ''; + } + }; + + const flushText = () => { + if (accumulatedText) { + finalParts.push({ text: accumulatedText }); + accumulatedText = ''; + } + }; + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (!line.startsWith('data:')) continue; + const jsonText = line.slice(5).trim(); + if (!jsonText) continue; + + try { + const data = JSON.parse(jsonText); + const innerResponse = data.response || data; + + if (innerResponse.usageMetadata) { + usageMetadata = innerResponse.usageMetadata; + } + + const candidates = innerResponse.candidates || []; + const firstCandidate = candidates[0] || {}; + if (firstCandidate.finishReason) { + finishReason = firstCandidate.finishReason; + } + + const parts = firstCandidate.content?.parts || []; + for (const part of parts) { + if (part.thought === true) { + flushText(); + accumulatedThinkingText += (part.text || ''); + if (part.thoughtSignature) { + accumulatedThinkingSignature = part.thoughtSignature; + } + } else if (part.functionCall) { + flushThinking(); + flushText(); + finalParts.push(part); + } else if (part.text !== undefined) { + if (!part.text) continue; + flushThinking(); + accumulatedText += part.text; + } + } + } catch (e) { /* skip parse errors */ } + } + } + + flushThinking(); + flushText(); + + const accumulatedResponse = { + candidates: [{ content: { parts: finalParts }, finishReason }], + usageMetadata + }; + + const partTypes = finalParts.map(p => p.thought ? 'thought' : (p.functionCall ? 'functionCall' : 'text')); + console.log('[CloudCode] Response received (SSE), part types:', partTypes); + if (finalParts.some(p => p.thought)) { + const thinkingPart = finalParts.find(p => p.thought); + console.log('[CloudCode] Thinking signature length:', thinkingPart?.thoughtSignature?.length || 0); + } + + return convertGoogleToAnthropic(accumulatedResponse, originalModel); +} + +/** + * Send a streaming request to Cloud Code with multi-account support + * Streams events in real-time as they arrive from the server + * + * @param {Object} anthropicRequest - The Anthropic-format request + * @param {AccountManager} accountManager - The account manager instance + */ +export async function* sendMessageStream(anthropicRequest, accountManager) { + const model = mapModelName(anthropicRequest.model); + + // Retry loop with account failover + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + // Get next available account + let account = accountManager.pickNext(); + + // Handle all accounts rate-limited + if (!account) { + if (accountManager.isAllRateLimited()) { + const waitMs = accountManager.getMinWaitTimeMs(); + + if (accountManager.getAccountCount() === 1) { + // Single account mode: wait for reset + console.log(`[CloudCode] Single account rate-limited. Waiting ${formatDuration(waitMs)}...`); + await sleep(waitMs); + accountManager.clearExpiredLimits(); + account = accountManager.pickNext(); + } else { + // Multi-account: all exhausted - throw proper error + const resetTime = new Date(Date.now() + waitMs).toISOString(); + throw new Error( + `RESOURCE_EXHAUSTED: All ${accountManager.getAccountCount()} accounts rate-limited. ` + + `quota will reset after ${formatDuration(waitMs)}. Next available: ${resetTime}` + ); + } + } + + if (!account) { + throw new Error('No accounts available'); + } + } + + try { + // Get token and project for this account + const token = await accountManager.getTokenForAccount(account); + const project = await accountManager.getProjectForAccount(account, token); + const payload = buildCloudCodeRequest(anthropicRequest, project); + + console.log(`[CloudCode] Starting stream for model: ${model}`); + + // Try each endpoint for streaming + let lastError = null; + for (const endpoint of ANTIGRAVITY_ENDPOINT_FALLBACKS) { + try { + const url = `${endpoint}/v1internal:streamGenerateContent?alt=sse`; + + const response = await fetch(url, { + method: 'POST', + headers: buildHeaders(token, model, 'text/event-stream'), + body: JSON.stringify(payload) + }); + + if (!response.ok) { + const errorText = await response.text(); + console.log(`[CloudCode] Stream error at ${endpoint}: ${response.status} - ${errorText}`); + + if (response.status === 401) { + // Auth error - clear caches and retry + accountManager.clearTokenCache(account.email); + accountManager.clearProjectCache(account.email); + continue; + } + + if (response.status === 429) { + // Rate limited on this endpoint - try next endpoint first (DAILY → PROD) + console.log(`[CloudCode] Stream rate limited at ${endpoint}, trying next endpoint...`); + const resetMs = parseResetTime(response, errorText); + // Keep minimum reset time across all 429 responses + if (!lastError?.is429 || (resetMs && (!lastError.resetMs || resetMs < lastError.resetMs))) { + lastError = { is429: true, response, errorText, resetMs }; + } + continue; + } + + lastError = new Error(`API error ${response.status}: ${errorText}`); + continue; + } + + // Stream the response - yield events as they arrive + yield* streamSSEResponse(response, anthropicRequest.model); + + console.log('[CloudCode] Stream completed'); + return; + + } catch (endpointError) { + if (is429Error(endpointError)) { + throw endpointError; // Re-throw to trigger account switch + } + console.log(`[CloudCode] Stream error at ${endpoint}:`, endpointError.message); + lastError = endpointError; + } + } + + // If all endpoints failed for this account + if (lastError) { + // If all endpoints returned 429, mark account as rate-limited + if (lastError.is429) { + console.log(`[CloudCode] All endpoints rate-limited for ${account.email}`); + accountManager.markRateLimited(account.email, lastError.resetMs); + throw new Error(`Rate limited: ${lastError.errorText}`); + } + throw lastError; + } + + } catch (error) { + if (is429Error(error)) { + // Rate limited - already marked, continue to next account + console.log(`[CloudCode] Account ${account.email} rate-limited, trying next...`); + continue; + } + if (isAuthInvalidError(error)) { + // Auth invalid - already marked, continue to next account + console.log(`[CloudCode] Account ${account.email} has invalid credentials, trying next...`); + continue; + } + // Non-rate-limit error: throw immediately + throw error; + } + } + + throw new Error('Max retries exceeded'); +} + +/** + * Stream SSE response and yield Anthropic-format events + */ +async function* streamSSEResponse(response, originalModel) { + const messageId = `msg_${crypto.randomBytes(16).toString('hex')}`; + let hasEmittedStart = false; + let blockIndex = 0; + let currentBlockType = null; + let currentThinkingSignature = ''; + let inputTokens = 0; + let outputTokens = 0; + let stopReason = 'end_turn'; + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (!line.startsWith('data:')) continue; + + const jsonText = line.slice(5).trim(); + if (!jsonText) continue; + + try { + const data = JSON.parse(jsonText); + const innerResponse = data.response || data; + + // Extract usage metadata + const usage = innerResponse.usageMetadata; + if (usage) { + inputTokens = usage.promptTokenCount || inputTokens; + outputTokens = usage.candidatesTokenCount || outputTokens; + } + + const candidates = innerResponse.candidates || []; + const firstCandidate = candidates[0] || {}; + const content = firstCandidate.content || {}; + const parts = content.parts || []; + + // Emit message_start on first data + if (!hasEmittedStart && parts.length > 0) { + hasEmittedStart = true; + yield { + type: 'message_start', + message: { + id: messageId, + type: 'message', + role: 'assistant', + content: [], + model: originalModel, + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: inputTokens, output_tokens: 0 } + } + }; + } + + // Process each part + for (const part of parts) { + if (part.thought === true) { + // Handle thinking block + const text = part.text || ''; + const signature = part.thoughtSignature || ''; + + if (currentBlockType !== 'thinking') { + if (currentBlockType !== null) { + yield { type: 'content_block_stop', index: blockIndex }; + blockIndex++; + } + currentBlockType = 'thinking'; + currentThinkingSignature = ''; + yield { + type: 'content_block_start', + index: blockIndex, + content_block: { type: 'thinking', thinking: '' } + }; + } + + if (signature && signature.length >= 50) { + currentThinkingSignature = signature; + } + + yield { + type: 'content_block_delta', + index: blockIndex, + delta: { type: 'thinking_delta', thinking: text } + }; + + } else if (part.text !== undefined) { + // Skip empty text parts + if (!part.text || part.text.trim().length === 0) { + continue; + } + + // Handle regular text + if (currentBlockType !== 'text') { + if (currentBlockType === 'thinking' && currentThinkingSignature) { + yield { + type: 'content_block_delta', + index: blockIndex, + delta: { type: 'signature_delta', signature: currentThinkingSignature } + }; + currentThinkingSignature = ''; + } + if (currentBlockType !== null) { + yield { type: 'content_block_stop', index: blockIndex }; + blockIndex++; + } + currentBlockType = 'text'; + yield { + type: 'content_block_start', + index: blockIndex, + content_block: { type: 'text', text: '' } + }; + } + + yield { + type: 'content_block_delta', + index: blockIndex, + delta: { type: 'text_delta', text: part.text } + }; + + } else if (part.functionCall) { + // Handle tool use + if (currentBlockType === 'thinking' && currentThinkingSignature) { + yield { + type: 'content_block_delta', + index: blockIndex, + delta: { type: 'signature_delta', signature: currentThinkingSignature } + }; + currentThinkingSignature = ''; + } + if (currentBlockType !== null) { + yield { type: 'content_block_stop', index: blockIndex }; + blockIndex++; + } + currentBlockType = 'tool_use'; + stopReason = 'tool_use'; + + const toolId = part.functionCall.id || `toolu_${crypto.randomBytes(12).toString('hex')}`; + + yield { + type: 'content_block_start', + index: blockIndex, + content_block: { + type: 'tool_use', + id: toolId, + name: part.functionCall.name, + input: {} + } + }; + + yield { + type: 'content_block_delta', + index: blockIndex, + delta: { + type: 'input_json_delta', + partial_json: JSON.stringify(part.functionCall.args || {}) + } + }; + } + } + + // Check finish reason + if (firstCandidate.finishReason) { + if (firstCandidate.finishReason === 'MAX_TOKENS') { + stopReason = 'max_tokens'; + } else if (firstCandidate.finishReason === 'STOP') { + stopReason = 'end_turn'; + } + } + + } catch (parseError) { + console.log('[CloudCode] SSE parse error:', parseError.message); + } + } + } + + // Handle no content received + if (!hasEmittedStart) { + console.log('[CloudCode] WARNING: 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, 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 received from API]' } + }; + yield { type: 'content_block_stop', index: 0 }; + } else { + // Close any open block + if (currentBlockType !== null) { + if (currentBlockType === 'thinking' && currentThinkingSignature) { + yield { + type: 'content_block_delta', + index: blockIndex, + delta: { type: 'signature_delta', signature: currentThinkingSignature } + }; + } + yield { type: 'content_block_stop', index: blockIndex }; + } + } + + // Emit message_delta and message_stop + yield { + type: 'message_delta', + delta: { stop_reason: stopReason, stop_sequence: null }, + usage: { output_tokens: outputTokens } }; - // message_stop yield { type: 'message_stop' }; } + /** * List available models */ @@ -329,7 +852,5 @@ export function listModels() { export default { sendMessage, sendMessageStream, - listModels, - clearProjectCache, - getProject + listModels }; diff --git a/src/constants.js b/src/constants.js index 19c5e23..57e320f 100644 --- a/src/constants.js +++ b/src/constants.js @@ -3,21 +3,19 @@ * Based on: https://github.com/NoeFabris/opencode-antigravity-auth */ -// Cloud Code API endpoints (in fallback order) -export const ANTIGRAVITY_ENDPOINT_DAILY = 'https://daily-cloudcode-pa.sandbox.googleapis.com'; -export const ANTIGRAVITY_ENDPOINT_AUTOPUSH = 'https://autopush-cloudcode-pa.sandbox.googleapis.com'; -export const ANTIGRAVITY_ENDPOINT_PROD = 'https://cloudcode-pa.googleapis.com'; +import { homedir } from 'os'; +import { join } from 'path'; -// Endpoint fallback order (daily → autopush → prod) +// Cloud Code API endpoints (in fallback order) +const ANTIGRAVITY_ENDPOINT_DAILY = 'https://daily-cloudcode-pa.sandbox.googleapis.com'; +const ANTIGRAVITY_ENDPOINT_PROD = 'https://cloudcode-pa.googleapis.com'; + +// Endpoint fallback order (daily → prod) export const ANTIGRAVITY_ENDPOINT_FALLBACKS = [ ANTIGRAVITY_ENDPOINT_DAILY, - ANTIGRAVITY_ENDPOINT_AUTOPUSH, ANTIGRAVITY_ENDPOINT_PROD ]; -// Primary endpoint -export const ANTIGRAVITY_ENDPOINT = ANTIGRAVITY_ENDPOINT_DAILY; - // Required headers for Antigravity API requests export const ANTIGRAVITY_HEADERS = { 'User-Agent': 'antigravity/1.11.5 darwin/arm64', @@ -70,23 +68,30 @@ export const AVAILABLE_MODELS = [ // Default project ID if none can be discovered export const DEFAULT_PROJECT_ID = 'rising-fact-p41fc'; -// Centralized configuration constants -export const STREAMING_CHUNK_SIZE = 20; export const TOKEN_REFRESH_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes export const REQUEST_BODY_LIMIT = '50mb'; export const ANTIGRAVITY_AUTH_PORT = 9092; export const DEFAULT_PORT = 8080; +// Multi-account configuration +export const ACCOUNT_CONFIG_PATH = join( + homedir(), + '.config/antigravity-proxy/accounts.json' +); +export const DEFAULT_COOLDOWN_MS = 60 * 1000; // 1 minute default cooldown +export const MAX_RETRIES = 5; // Max retry attempts across accounts + export default { - ANTIGRAVITY_ENDPOINT, ANTIGRAVITY_ENDPOINT_FALLBACKS, ANTIGRAVITY_HEADERS, MODEL_MAPPINGS, AVAILABLE_MODELS, DEFAULT_PROJECT_ID, - STREAMING_CHUNK_SIZE, TOKEN_REFRESH_INTERVAL_MS, REQUEST_BODY_LIMIT, ANTIGRAVITY_AUTH_PORT, - DEFAULT_PORT + DEFAULT_PORT, + ACCOUNT_CONFIG_PATH, + DEFAULT_COOLDOWN_MS, + MAX_RETRIES }; diff --git a/src/format-converter.js b/src/format-converter.js index 01691a4..a6c503c 100644 --- a/src/format-converter.js +++ b/src/format-converter.js @@ -10,6 +10,11 @@ import crypto from 'crypto'; import { MODEL_MAPPINGS } from './constants.js'; +// Default thinking budget (16K tokens) +const DEFAULT_THINKING_BUDGET = 16000; +// Claude thinking models need larger max output tokens +const CLAUDE_THINKING_MAX_OUTPUT_TOKENS = 64000; + /** * Map Anthropic model name to Antigravity model name */ @@ -17,6 +22,208 @@ export function mapModelName(anthropicModel) { return MODEL_MAPPINGS[anthropicModel] || anthropicModel; } +/** + * Check if a part is a thinking block + */ +function isThinkingPart(part) { + return part.type === 'thinking' || + part.type === 'redacted_thinking' || + part.thinking !== undefined || + part.thought === true; +} + +/** + * Check if a thinking part has a valid signature (>= 50 chars) + */ +function hasValidSignature(part) { + const signature = part.thought === true ? part.thoughtSignature : part.signature; + return typeof signature === 'string' && signature.length >= 50; +} + +/** + * Sanitize a thinking part by keeping only allowed fields + */ +function sanitizeThinkingPart(part) { + // Gemini-style thought blocks: { thought: true, text, thoughtSignature } + if (part.thought === true) { + const sanitized = { thought: true }; + if (part.text !== undefined) sanitized.text = part.text; + if (part.thoughtSignature !== undefined) sanitized.thoughtSignature = part.thoughtSignature; + return sanitized; + } + + // Anthropic-style thinking blocks: { type: "thinking", thinking, signature } + if (part.type === 'thinking' || part.thinking !== undefined) { + const sanitized = { type: 'thinking' }; + if (part.thinking !== undefined) sanitized.thinking = part.thinking; + if (part.signature !== undefined) sanitized.signature = part.signature; + return sanitized; + } + + return part; +} + +/** + * Filter content array, keeping only thinking blocks with valid signatures. + * Since signature_delta transmits signatures properly, cache is no longer needed. + */ +function filterContentArray(contentArray) { + const filtered = []; + + for (const item of contentArray) { + if (!item || typeof item !== 'object') { + filtered.push(item); + continue; + } + + if (!isThinkingPart(item)) { + filtered.push(item); + continue; + } + + // Keep items with valid signatures + if (hasValidSignature(item)) { + filtered.push(sanitizeThinkingPart(item)); + continue; + } + + // Drop unsigned thinking blocks + console.log('[FormatConverter] Dropping unsigned thinking block'); + } + + return filtered; +} + +/** + * Filter unsigned thinking blocks from contents (Gemini format) + */ +export function filterUnsignedThinkingBlocks(contents) { + return contents.map(content => { + if (!content || typeof content !== 'object') return content; + + if (Array.isArray(content.parts)) { + return { ...content, parts: filterContentArray(content.parts) }; + } + + return content; + }); +} + +/** + * Remove trailing unsigned thinking blocks from assistant messages. + * Claude/Gemini APIs require that assistant messages don't end with unsigned thinking blocks. + * This function removes thinking blocks from the end of content arrays. + */ +export function removeTrailingThinkingBlocks(content) { + if (!Array.isArray(content)) return content; + if (content.length === 0) return content; + + // Work backwards from the end, removing thinking blocks + let endIndex = content.length; + for (let i = content.length - 1; i >= 0; i--) { + const block = content[i]; + if (!block || typeof block !== 'object') break; + + // Check if it's a thinking block (any format) + const isThinking = isThinkingPart(block); + + if (isThinking) { + // Check if it has a valid signature + if (!hasValidSignature(block)) { + endIndex = i; + } else { + break; // Stop at signed thinking block + } + } else { + break; // Stop at first non-thinking block + } + } + + if (endIndex < content.length) { + console.log('[FormatConverter] Removed', content.length - endIndex, 'trailing unsigned thinking blocks'); + return content.slice(0, endIndex); + } + + return content; +} + +/** + * Filter thinking blocks: keep only those with valid signatures. + * Blocks without signatures are dropped (API requires signatures). + */ +export function restoreThinkingSignatures(content) { + if (!Array.isArray(content)) return content; + + const originalLength = content.length; + const filtered = content.filter(block => { + if (!block || block.type !== 'thinking') return true; + + // Keep blocks with valid signatures (>= 50 chars) + return block.signature && block.signature.length >= 50; + }); + + if (filtered.length < originalLength) { + console.log(`[FormatConverter] Dropped ${originalLength - filtered.length} unsigned thinking block(s)`); + } + + return filtered; +} + +/** + * Reorder content so that: + * 1. Thinking blocks come first (required when thinking is enabled) + * 2. Text blocks come in the middle (filtering out empty/useless ones) + * 3. Tool_use blocks come at the end (required before tool_result) + * + * Claude API requires that when thinking is enabled, assistant messages must start with thinking. + */ +export function reorderAssistantContent(content) { + if (!Array.isArray(content)) return content; + if (content.length <= 1) return content; + + const thinkingBlocks = []; + const textBlocks = []; + const toolUseBlocks = []; + let droppedEmptyBlocks = 0; + + for (const block of content) { + if (!block) continue; + + if (block.type === 'thinking') { + thinkingBlocks.push(block); + } else if (block.type === 'tool_use') { + toolUseBlocks.push(block); + } else if (block.type === 'text') { + // Only keep text blocks with meaningful content + if (block.text && block.text.trim().length > 0) { + textBlocks.push(block); + } else { + droppedEmptyBlocks++; + } + } else { + // Other block types go in the text position + textBlocks.push(block); + } + } + + if (droppedEmptyBlocks > 0) { + console.log(`[FormatConverter] Dropped ${droppedEmptyBlocks} empty text block(s)`); + } + + const reordered = [...thinkingBlocks, ...textBlocks, ...toolUseBlocks]; + + // Log only if actual reordering happened (not just filtering) + if (reordered.length === content.length) { + const originalOrder = content.map(b => b?.type || 'unknown').join(','); + const newOrder = reordered.map(b => b?.type || 'unknown').join(','); + if (originalOrder !== newOrder) { + console.log('[FormatConverter] Reordered assistant content'); + } + } + + return reordered; +} + /** * Convert Anthropic message content to Google Generative AI parts */ @@ -33,7 +240,10 @@ function convertContentToParts(content, isClaudeModel = false) { for (const block of content) { if (block.type === 'text') { - parts.push({ text: block.text }); + // Skip empty text blocks - they cause API errors + if (block.text && block.text.trim()) { + parts.push({ text: block.text }); + } } else if (block.type === 'image') { // Handle image content if (block.source?.type === 'base64') { @@ -107,19 +317,21 @@ function convertContentToParts(content, isClaudeModel = false) { } parts.push({ functionResponse }); - } else if (block.type === 'thinking' || block.type === 'redacted_thinking') { - // Skip thinking blocks for Claude models - thinking is handled by the model itself - // For non-Claude models, convert to Google's thought format - if (!isClaudeModel && block.type === 'thinking') { + } else if (block.type === 'thinking') { + // Handle thinking blocks - only those with valid signatures + if (block.signature && block.signature.length >= 50) { + // Convert to Gemini format with signature parts.push({ text: block.thinking, - thought: true + thought: true, + thoughtSignature: block.signature }); } + // Unsigned thinking blocks are dropped upstream } } - return parts.length > 0 ? parts : [{ text: '' }]; + return parts; } /** @@ -142,7 +354,10 @@ function convertRole(role) { */ export function convertAnthropicToGoogle(anthropicRequest) { const { messages, system, max_tokens, temperature, top_p, top_k, stop_sequences, tools, tool_choice, thinking } = anthropicRequest; - const isClaudeModel = (anthropicRequest.model || '').toLowerCase().includes('claude'); + const modelName = anthropicRequest.model || ''; + const isClaudeModel = modelName.toLowerCase().includes('claude'); + const isClaudeThinkingModel = isClaudeModel && modelName.toLowerCase().includes('thinking'); + const googleRequest = { contents: [], @@ -169,9 +384,36 @@ export function convertAnthropicToGoogle(anthropicRequest) { } } - // Convert messages to contents + // Add interleaved thinking hint for Claude thinking models with tools + if (isClaudeThinkingModel && tools && tools.length > 0) { + const hint = 'Interleaved thinking is enabled. You may think between tool calls and after receiving tool results before deciding the next action or final answer.'; + if (!googleRequest.systemInstruction) { + googleRequest.systemInstruction = { parts: [{ text: hint }] }; + } else { + const lastPart = googleRequest.systemInstruction.parts[googleRequest.systemInstruction.parts.length - 1]; + if (lastPart && lastPart.text) { + lastPart.text = `${lastPart.text}\n\n${hint}`; + } else { + googleRequest.systemInstruction.parts.push({ text: hint }); + } + } + } + + // Convert messages to contents, then filter unsigned thinking blocks for (const msg of messages) { - const parts = convertContentToParts(msg.content, isClaudeModel); + let msgContent = msg.content; + + // For assistant messages, process thinking blocks and reorder content + if ((msg.role === 'assistant' || msg.role === 'model') && Array.isArray(msgContent)) { + // First, try to restore signatures for unsigned thinking blocks from cache + msgContent = restoreThinkingSignatures(msgContent); + // Remove trailing unsigned thinking blocks + msgContent = removeTrailingThinkingBlocks(msgContent); + // Reorder: thinking first, then text, then tool_use + msgContent = reorderAssistantContent(msgContent); + } + + const parts = convertContentToParts(msgContent, isClaudeModel); const content = { role: convertRole(msg.role), parts: parts @@ -179,6 +421,11 @@ export function convertAnthropicToGoogle(anthropicRequest) { googleRequest.contents.push(content); } + // Filter unsigned thinking blocks for Claude models + if (isClaudeModel) { + googleRequest.contents = filterUnsignedThinkingBlocks(googleRequest.contents); + } + // Generation config if (max_tokens) { googleRequest.generationConfig.maxOutputTokens = max_tokens; @@ -196,9 +443,24 @@ export function convertAnthropicToGoogle(anthropicRequest) { googleRequest.generationConfig.stopSequences = stop_sequences; } - // Extended thinking is disabled for Claude models - // The model itself (e.g., claude-opus-4-5-thinking) handles thinking internally - // Enabling thinkingConfig causes signature issues in multi-turn conversations + // Enable thinking for Claude thinking models + if (isClaudeThinkingModel) { + // Get budget from request or use default + const thinkingBudget = thinking?.budget_tokens || DEFAULT_THINKING_BUDGET; + + googleRequest.generationConfig.thinkingConfig = { + include_thoughts: true, + thinking_budget: thinkingBudget + }; + + // Ensure maxOutputTokens is large enough for thinking models + if (!googleRequest.generationConfig.maxOutputTokens || + googleRequest.generationConfig.maxOutputTokens <= thinkingBudget) { + googleRequest.generationConfig.maxOutputTokens = CLAUDE_THINKING_MAX_OUTPUT_TOKENS; + } + + console.log('[FormatConverter] Thinking enabled with budget:', thinkingBudget); + } // Convert tools to Google format if (tools && tools.length > 0) { @@ -232,54 +494,48 @@ export function convertAnthropicToGoogle(anthropicRequest) { } /** - * Sanitize JSON schema for Google API compatibility - * Removes unsupported fields like additionalProperties + * Sanitize JSON Schema for Antigravity API compatibility. + * Uses allowlist approach - only permit known-safe JSON Schema features. + * Converts "const" to equivalent "enum" for compatibility. + * Generates placeholder schema for empty tool schemas. */ function sanitizeSchema(schema) { if (!schema || typeof schema !== 'object') { - return schema; + // Empty/missing schema - generate placeholder with reason property + return { + type: 'object', + properties: { + reason: { + type: 'string', + description: 'Reason for calling this tool' + } + }, + required: ['reason'] + }; } - // Fields to skip entirely - not compatible with Claude's JSON Schema 2020-12 - const UNSUPPORTED_FIELDS = new Set([ - '$schema', - 'additionalProperties', - 'default', - 'anyOf', - 'allOf', - 'oneOf', - 'minLength', - 'maxLength', - 'pattern', - 'format', - 'minimum', - 'maximum', - 'exclusiveMinimum', - 'exclusiveMaximum', - 'minItems', - 'maxItems', - 'uniqueItems', - 'minProperties', - 'maxProperties', - '$id', - '$ref', - '$defs', - 'definitions', - 'patternProperties', - 'unevaluatedProperties', - 'unevaluatedItems', - 'if', - 'then', - 'else', - 'not', - 'contentEncoding', - 'contentMediaType' + // Allowlist of permitted JSON Schema fields + const ALLOWED_FIELDS = new Set([ + 'type', + 'description', + 'properties', + 'required', + 'items', + 'enum', + 'title' ]); const sanitized = {}; + for (const [key, value] of Object.entries(schema)) { - // Skip unsupported fields - if (UNSUPPORTED_FIELDS.has(key)) { + // Convert "const" to "enum" for compatibility + if (key === 'const') { + sanitized.enum = [value]; + continue; + } + + // Skip fields not in allowlist + if (!ALLOWED_FIELDS.has(key)) { continue; } @@ -289,38 +545,49 @@ function sanitizeSchema(schema) { sanitized.properties[propKey] = sanitizeSchema(propValue); } } else if (key === 'items' && value && typeof value === 'object') { - // Handle items - could be object or array if (Array.isArray(value)) { sanitized.items = value.map(item => sanitizeSchema(item)); - } else if (value.anyOf || value.allOf || value.oneOf) { - // Replace complex items with permissive type - sanitized.items = {}; } else { sanitized.items = sanitizeSchema(value); } } else if (typeof value === 'object' && value !== null && !Array.isArray(value)) { - // Recursively sanitize nested objects that aren't properties/items sanitized[key] = sanitizeSchema(value); } else { sanitized[key] = value; } } + // Ensure we have at least a type + if (!sanitized.type) { + sanitized.type = 'object'; + } + + // If object type with no properties, add placeholder + if (sanitized.type === 'object' && (!sanitized.properties || Object.keys(sanitized.properties).length === 0)) { + sanitized.properties = { + reason: { + type: 'string', + description: 'Reason for calling this tool' + } + }; + sanitized.required = ['reason']; + } + return sanitized; } /** * Convert Google Generative AI response to Anthropic Messages API format - * + * * @param {Object} googleResponse - Google format response (the inner response object) * @param {string} model - The model name used - * @param {boolean} isStreaming - Whether this is a streaming response * @returns {Object} Anthropic format response */ -export function convertGoogleToAnthropic(googleResponse, model, isStreaming = false) { +export function convertGoogleToAnthropic(googleResponse, model) { // Handle the response wrapper const response = googleResponse.response || googleResponse; + const candidates = response.candidates || []; const firstCandidate = candidates[0] || {}; const content = firstCandidate.content || {}; @@ -328,18 +595,26 @@ export function convertGoogleToAnthropic(googleResponse, model, isStreaming = fa // Convert parts to Anthropic content blocks const anthropicContent = []; - let toolCallCounter = 0; + let hasToolCalls = false; for (const part of parts) { if (part.text !== undefined) { - // Skip thinking blocks (thought: true) - the model handles thinking internally + // Handle thinking blocks if (part.thought === true) { - continue; + const signature = part.thoughtSignature || ''; + + // Include thinking blocks in the response for Claude Code + anthropicContent.push({ + type: 'thinking', + thinking: part.text, + signature: signature + }); + } else { + anthropicContent.push({ + type: 'text', + text: part.text + }); } - anthropicContent.push({ - type: 'text', - text: part.text - }); } else if (part.functionCall) { // Convert functionCall to tool_use // Use the id from the response if available, otherwise generate one @@ -349,7 +624,7 @@ export function convertGoogleToAnthropic(googleResponse, model, isStreaming = fa name: part.functionCall.name, input: part.functionCall.args || {} }); - toolCallCounter++; + hasToolCalls = true; } } @@ -360,7 +635,7 @@ export function convertGoogleToAnthropic(googleResponse, model, isStreaming = fa stopReason = 'end_turn'; } else if (finishReason === 'MAX_TOKENS') { stopReason = 'max_tokens'; - } else if (finishReason === 'TOOL_USE' || toolCallCounter > 0) { + } else if (finishReason === 'TOOL_USE' || hasToolCalls) { stopReason = 'tool_use'; } @@ -382,108 +657,8 @@ export function convertGoogleToAnthropic(googleResponse, model, isStreaming = fa }; } -/** - * Parse SSE data and extract the response object - */ -export function parseSSEResponse(data) { - if (!data || !data.startsWith('data:')) { - return null; - } - - const jsonStr = data.slice(5).trim(); - if (!jsonStr) { - return null; - } - - try { - return JSON.parse(jsonStr); - } catch (e) { - console.error('[FormatConverter] Failed to parse SSE data:', e.message); - return null; - } -} - -/** - * Convert a streaming chunk to Anthropic SSE format - */ -export function convertStreamingChunk(googleChunk, model, index, isFirst, isLast) { - const events = []; - const response = googleChunk.response || googleChunk; - const candidates = response.candidates || []; - const firstCandidate = candidates[0] || {}; - const content = firstCandidate.content || {}; - const parts = content.parts || []; - - if (isFirst) { - // message_start event - events.push({ - type: 'message_start', - message: { - id: `msg_${crypto.randomBytes(16).toString('hex')}`, - type: 'message', - role: 'assistant', - content: [], - model: model, - stop_reason: null, - stop_sequence: null, - usage: { input_tokens: 0, output_tokens: 0 } - } - }); - - // content_block_start event - events.push({ - type: 'content_block_start', - index: 0, - content_block: { type: 'text', text: '' } - }); - } - - // Extract text from parts and emit as delta - for (const part of parts) { - if (part.text !== undefined) { - events.push({ - type: 'content_block_delta', - index: 0, - delta: { type: 'text_delta', text: part.text } - }); - } - } - - if (isLast) { - // content_block_stop event - events.push({ - type: 'content_block_stop', - index: 0 - }); - - // Determine stop reason - const finishReason = firstCandidate.finishReason; - let stopReason = 'end_turn'; - if (finishReason === 'MAX_TOKENS') { - stopReason = 'max_tokens'; - } - - // Extract usage - const usageMetadata = response.usageMetadata || {}; - - // message_delta event - events.push({ - type: 'message_delta', - delta: { stop_reason: stopReason, stop_sequence: null }, - usage: { output_tokens: usageMetadata.candidatesTokenCount || 0 } - }); - - // message_stop event - events.push({ type: 'message_stop' }); - } - - return events; -} - export default { mapModelName, convertAnthropicToGoogle, - convertGoogleToAnthropic, - parseSSEResponse, - convertStreamingChunk + convertGoogleToAnthropic }; diff --git a/src/index.js b/src/index.js index a6269c4..1571bc8 100644 --- a/src/index.js +++ b/src/index.js @@ -20,6 +20,7 @@ app.listen(PORT, () => { ║ POST /v1/messages - Anthropic Messages API ║ ║ GET /v1/models - List available models ║ ║ GET /health - Health check ║ +║ GET /accounts - Account pool status ║ ║ POST /refresh-token - Force token refresh ║ ║ ║ ║ Usage with Claude Code: ║ @@ -27,7 +28,10 @@ app.listen(PORT, () => { ║ export ANTHROPIC_API_KEY=dummy ║ ║ claude ║ ║ ║ -║ Prerequisites: ║ +║ Add Google accounts: ║ +║ npm run accounts ║ +║ ║ +║ Prerequisites (if no accounts configured): ║ ║ - Antigravity must be running ║ ║ - Have a chat panel open in Antigravity ║ ║ ║ diff --git a/src/oauth.js b/src/oauth.js new file mode 100644 index 0000000..fde6bc8 --- /dev/null +++ b/src/oauth.js @@ -0,0 +1,338 @@ +/** + * Google OAuth with PKCE for Antigravity + * + * Implements the same OAuth flow as opencode-antigravity-auth + * to obtain refresh tokens for multiple Google accounts. + * Uses a local callback server to automatically capture the auth code. + */ + +import crypto from 'crypto'; +import http from 'http'; +import { ANTIGRAVITY_ENDPOINT_FALLBACKS, ANTIGRAVITY_HEADERS } from './constants.js'; + +// Google OAuth configuration (from opencode-antigravity-auth) +const GOOGLE_CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com'; +const GOOGLE_CLIENT_SECRET = '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'; +const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'; +const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'; +const GOOGLE_USERINFO_URL = 'https://www.googleapis.com/oauth2/v1/userinfo'; + +// Local callback server configuration +const CALLBACK_PORT = 51121; +const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}/oauth-callback`; + +// Scopes needed for Cloud Code access (matching Antigravity) +const SCOPES = [ + 'https://www.googleapis.com/auth/cloud-platform', + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', + 'https://www.googleapis.com/auth/cclog', + 'https://www.googleapis.com/auth/experimentsandconfigs' +].join(' '); + +/** + * Generate PKCE code verifier and challenge + */ +function generatePKCE() { + const verifier = crypto.randomBytes(32).toString('base64url'); + const challenge = crypto + .createHash('sha256') + .update(verifier) + .digest('base64url'); + return { verifier, challenge }; +} + +/** + * Generate authorization URL for Google OAuth + * Returns the URL and the PKCE verifier (needed for token exchange) + */ +export function getAuthorizationUrl() { + const { verifier, challenge } = generatePKCE(); + const state = crypto.randomBytes(16).toString('hex'); + + const params = new URLSearchParams({ + client_id: GOOGLE_CLIENT_ID, + redirect_uri: REDIRECT_URI, + response_type: 'code', + scope: SCOPES, + access_type: 'offline', + prompt: 'consent', + code_challenge: challenge, + code_challenge_method: 'S256', + state: state + }); + + return { + url: `${GOOGLE_AUTH_URL}?${params.toString()}`, + verifier, + state + }; +} + +/** + * Start a local server to receive the OAuth callback + * Returns a promise that resolves with the authorization code + */ +export function startCallbackServer(expectedState, timeoutMs = 120000) { + return new Promise((resolve, reject) => { + const server = http.createServer((req, res) => { + const url = new URL(req.url, `http://localhost:${CALLBACK_PORT}`); + + if (url.pathname !== '/oauth-callback') { + res.writeHead(404); + res.end('Not found'); + return; + } + + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + const error = url.searchParams.get('error'); + + if (error) { + res.writeHead(400, { 'Content-Type': 'text/html' }); + res.end(` + + Authentication Failed + +

❌ Authentication Failed

+

Error: ${error}

+

You can close this window.

+ + + `); + server.close(); + reject(new Error(`OAuth error: ${error}`)); + return; + } + + if (state !== expectedState) { + res.writeHead(400, { 'Content-Type': 'text/html' }); + res.end(` + + Authentication Failed + +

❌ Authentication Failed

+

State mismatch - possible CSRF attack.

+

You can close this window.

+ + + `); + server.close(); + reject(new Error('State mismatch')); + return; + } + + if (!code) { + res.writeHead(400, { 'Content-Type': 'text/html' }); + res.end(` + + Authentication Failed + +

❌ Authentication Failed

+

No authorization code received.

+

You can close this window.

+ + + `); + server.close(); + reject(new Error('No authorization code')); + return; + } + + // Success! + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(` + + Authentication Successful + +

✅ Authentication Successful!

+

You can close this window and return to the terminal.

+ + + + `); + + server.close(); + resolve(code); + }); + + server.on('error', (err) => { + if (err.code === 'EADDRINUSE') { + reject(new Error(`Port ${CALLBACK_PORT} is already in use. Close any other OAuth flows and try again.`)); + } else { + reject(err); + } + }); + + server.listen(CALLBACK_PORT, () => { + console.log(`[OAuth] Callback server listening on port ${CALLBACK_PORT}`); + }); + + // Timeout after specified duration + setTimeout(() => { + server.close(); + reject(new Error('OAuth callback timeout - no response received')); + }, timeoutMs); + }); +} + +/** + * Exchange authorization code for tokens + */ +export async function exchangeCode(code, verifier) { + const response = await fetch(GOOGLE_TOKEN_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + client_id: GOOGLE_CLIENT_ID, + client_secret: GOOGLE_CLIENT_SECRET, + code: code, + code_verifier: verifier, + grant_type: 'authorization_code', + redirect_uri: REDIRECT_URI + }) + }); + + if (!response.ok) { + const error = await response.text(); + console.error('[OAuth] Token exchange failed:', response.status, error); + throw new Error(`Token exchange failed: ${error}`); + } + + const tokens = await response.json(); + + if (!tokens.access_token) { + console.error('[OAuth] No access token in response:', tokens); + throw new Error('No access token received'); + } + + console.log('[OAuth] Token exchange successful, access_token length:', tokens.access_token?.length); + + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in + }; +} + +/** + * Refresh access token using refresh token + */ +export async function refreshAccessToken(refreshToken) { + const response = await fetch(GOOGLE_TOKEN_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + client_id: GOOGLE_CLIENT_ID, + client_secret: GOOGLE_CLIENT_SECRET, + refresh_token: refreshToken, + grant_type: 'refresh_token' + }) + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token refresh failed: ${error}`); + } + + const tokens = await response.json(); + return { + accessToken: tokens.access_token, + expiresIn: tokens.expires_in + }; +} + +/** + * Get user email from access token + */ +export async function getUserEmail(accessToken) { + const response = await fetch(GOOGLE_USERINFO_URL, { + headers: { + 'Authorization': `Bearer ${accessToken}` + } + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('[OAuth] getUserEmail failed:', response.status, errorText); + throw new Error(`Failed to get user info: ${response.status}`); + } + + const userInfo = await response.json(); + return userInfo.email; +} + +/** + * Discover project ID for the authenticated user + */ +export async function discoverProjectId(accessToken) { + for (const endpoint of ANTIGRAVITY_ENDPOINT_FALLBACKS) { + try { + const response = await fetch(`${endpoint}/v1internal:loadCodeAssist`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + ...ANTIGRAVITY_HEADERS + }, + body: JSON.stringify({ + metadata: { + ideType: 'IDE_UNSPECIFIED', + platform: 'PLATFORM_UNSPECIFIED', + pluginType: 'GEMINI' + } + }) + }); + + if (!response.ok) continue; + + const data = await response.json(); + + if (typeof data.cloudaicompanionProject === 'string') { + return data.cloudaicompanionProject; + } + if (data.cloudaicompanionProject?.id) { + return data.cloudaicompanionProject.id; + } + } catch (error) { + console.log(`[OAuth] Project discovery failed at ${endpoint}:`, error.message); + } + } + + return null; +} + +/** + * Complete OAuth flow: exchange code and get all account info + */ +export async function completeOAuthFlow(code, verifier) { + // Exchange code for tokens + const tokens = await exchangeCode(code, verifier); + + // Get user email + const email = await getUserEmail(tokens.accessToken); + + // Discover project ID + const projectId = await discoverProjectId(tokens.accessToken); + + return { + email, + refreshToken: tokens.refreshToken, + accessToken: tokens.accessToken, + projectId + }; +} + +export default { + getAuthorizationUrl, + startCallbackServer, + exchangeCode, + refreshAccessToken, + getUserEmail, + discoverProjectId, + completeOAuthFlow +}; diff --git a/src/server.js b/src/server.js index 9fe5b33..4c25b80 100644 --- a/src/server.js +++ b/src/server.js @@ -1,16 +1,43 @@ /** * Express Server - Anthropic-compatible API * Proxies to Google Cloud Code via Antigravity + * Supports multi-account load balancing */ import express from 'express'; import cors from 'cors'; -import { sendMessage, sendMessageStream, listModels, clearProjectCache, getProject } from './cloudcode-client.js'; -import { getToken, forceRefresh } from './token-extractor.js'; -import { AVAILABLE_MODELS, REQUEST_BODY_LIMIT } from './constants.js'; +import { sendMessage, sendMessageStream, listModels } from './cloudcode-client.js'; +import { forceRefresh } from './token-extractor.js'; +import { REQUEST_BODY_LIMIT } from './constants.js'; +import { AccountManager } from './account-manager.js'; const app = express(); +// Initialize account manager (will be fully initialized on first request or startup) +const accountManager = new AccountManager(); + +// Track initialization status +let isInitialized = false; +let initError = null; + +/** + * Ensure account manager is initialized + */ +async function ensureInitialized() { + if (isInitialized) return; + + try { + await accountManager.initialize(); + isInitialized = true; + const status = accountManager.getStatus(); + console.log(`[Server] Account pool initialized: ${status.summary}`); + } catch (error) { + initError = error; + console.error('[Server] Failed to initialize account manager:', error.message); + throw error; + } +} + // Middleware app.use(cors()); app.use(express.json({ limit: REQUEST_BODY_LIMIT })); @@ -27,13 +54,20 @@ function parseError(error) { errorType = 'authentication_error'; statusCode = 401; errorMessage = 'Authentication failed. Make sure Antigravity is running with a valid token.'; - } else if (error.message.includes('429') || error.message.includes('RESOURCE_EXHAUSTED')) { - errorType = 'rate_limit_error'; - statusCode = 429; - const resetMatch = error.message.match(/quota will reset after (\d+h\d+m\d+s|\d+s)/i); - errorMessage = resetMatch - ? `Rate limited. Quota will reset after ${resetMatch[1]}.` - : 'Rate limited. Please wait and try again.'; + } else if (error.message.includes('429') || error.message.includes('RESOURCE_EXHAUSTED') || error.message.includes('QUOTA_EXHAUSTED')) { + errorType = 'overloaded_error'; // Claude Code recognizes this type + statusCode = 529; // Use 529 for overloaded (Claude API convention) + + // Try to extract the quota reset time from the error + const resetMatch = error.message.match(/quota will reset after (\d+h\d+m\d+s|\d+m\d+s|\d+s)/i); + const modelMatch = error.message.match(/"model":\s*"([^"]+)"/); + const model = modelMatch ? modelMatch[1] : 'the model'; + + if (resetMatch) { + errorMessage = `You have exhausted your capacity on ${model}. Quota will reset after ${resetMatch[1]}.`; + } else { + errorMessage = `You have exhausted your capacity on ${model}. Please wait for your quota to reset.`; + } } else if (error.message.includes('invalid_request_error') || error.message.includes('INVALID_ARGUMENT')) { errorType = 'invalid_request_error'; statusCode = 400; @@ -63,19 +97,15 @@ app.use((req, res, next) => { */ app.get('/health', async (req, res) => { try { - const token = await getToken(); - let project = null; - try { - project = await getProject(token); - } catch (e) { - // Project fetch might fail if token just refreshed - } + await ensureInitialized(); + const status = accountManager.getStatus(); res.json({ status: 'ok', - hasToken: !!token, - tokenPrefix: token ? token.substring(0, 10) + '...' : null, - project: project || 'unknown', + accounts: status.summary, + available: status.available, + rateLimited: status.rateLimited, + invalid: status.invalid, timestamp: new Date().toISOString() }); } catch (error) { @@ -87,16 +117,55 @@ app.get('/health', async (req, res) => { } }); +/** + * Account pool status endpoint + */ +app.get('/accounts', async (req, res) => { + try { + await ensureInitialized(); + const status = accountManager.getStatus(); + + res.json({ + total: status.total, + available: status.available, + rateLimited: status.rateLimited, + invalid: status.invalid, + accounts: status.accounts.map(a => ({ + email: a.email, + source: a.source, + isRateLimited: a.isRateLimited, + rateLimitResetTime: a.rateLimitResetTime + ? new Date(a.rateLimitResetTime).toISOString() + : null, + isInvalid: a.isInvalid, + invalidReason: a.invalidReason, + lastUsed: a.lastUsed + ? new Date(a.lastUsed).toISOString() + : null + })) + }); + } catch (error) { + res.status(500).json({ + status: 'error', + error: error.message + }); + } +}); + /** * Force token refresh endpoint */ app.post('/refresh-token', async (req, res) => { try { - clearProjectCache(); + await ensureInitialized(); + // Clear all caches + accountManager.clearTokenCache(); + accountManager.clearProjectCache(); + // Force refresh default token const token = await forceRefresh(); res.json({ status: 'ok', - message: 'Token refreshed successfully', + message: 'Token caches cleared and refreshed', tokenPrefix: token.substring(0, 10) + '...' }); } catch (error) { @@ -119,6 +188,9 @@ app.get('/v1/models', (req, res) => { */ app.post('/v1/messages', async (req, res) => { try { + // Ensure account manager is initialized + await ensureInitialized(); + const { model, messages, @@ -161,6 +233,15 @@ app.post('/v1/messages', async (req, res) => { console.log(`[API] Request for model: ${request.model}, stream: ${!!stream}`); + // Debug: Log message structure to diagnose tool_use/tool_result ordering + console.log('[API] Message structure:'); + messages.forEach((msg, i) => { + const contentTypes = Array.isArray(msg.content) + ? msg.content.map(c => c.type || 'text').join(', ') + : (typeof msg.content === 'string' ? 'text' : 'unknown'); + console.log(` [${i}] ${msg.role}: ${contentTypes}`); + }); + if (stream) { // Handle streaming response res.setHeader('Content-Type', 'text/event-stream'); @@ -168,10 +249,15 @@ app.post('/v1/messages', async (req, res) => { res.setHeader('Connection', 'keep-alive'); res.setHeader('X-Accel-Buffering', 'no'); + // Flush headers immediately to start the stream + res.flushHeaders(); + try { - // Use the streaming generator - for await (const event of sendMessageStream(request)) { + // Use the streaming generator with account manager + for await (const event of sendMessageStream(request, accountManager)) { res.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`); + // Flush after each event for real-time streaming + if (res.flush) res.flush(); } res.end(); @@ -189,7 +275,7 @@ app.post('/v1/messages', async (req, res) => { } else { // Handle non-streaming response - const response = await sendMessage(request); + const response = await sendMessage(request, accountManager); res.json(response); } @@ -202,7 +288,8 @@ app.post('/v1/messages', async (req, res) => { if (errorType === 'authentication_error') { console.log('[API] Token might be expired, attempting refresh...'); try { - clearProjectCache(); + accountManager.clearProjectCache(); + accountManager.clearTokenCache(); await forceRefresh(); errorMessage = 'Token was expired and has been refreshed. Please retry your request.'; } catch (refreshError) { @@ -210,13 +297,25 @@ app.post('/v1/messages', async (req, res) => { } } - res.status(statusCode).json({ - type: 'error', - error: { - type: errorType, - message: errorMessage - } - }); + console.log(`[API] Returning error response: ${statusCode} ${errorType} - ${errorMessage}`); + + // Check if headers have already been sent (for streaming that failed mid-way) + if (res.headersSent) { + console.log('[API] Headers already sent, writing error as SSE event'); + res.write(`event: error\ndata: ${JSON.stringify({ + type: 'error', + error: { type: errorType, message: errorMessage } + })}\n\n`); + res.end(); + } else { + res.status(statusCode).json({ + type: 'error', + error: { + type: errorType, + message: errorMessage + } + }); + } } }); diff --git a/src/token-extractor.js b/src/token-extractor.js index ad7206c..d5f78d0 100644 --- a/src/token-extractor.js +++ b/src/token-extractor.js @@ -9,7 +9,6 @@ import { execSync } from 'child_process'; import { homedir } from 'os'; import { join } from 'path'; -import fetch from 'node-fetch'; import { TOKEN_REFRESH_INTERVAL_MS, ANTIGRAVITY_AUTH_PORT } from './constants.js'; // Cache for the extracted token @@ -138,27 +137,6 @@ export async function getToken() { return cachedToken; } -/** - * Get the full configuration from Antigravity - */ -export async function getConfig() { - if (needsRefresh()) { - await getToken(); // This will refresh the cache - } - return cachedConfig; -} - -/** - * Get available models from the cached config - */ -export async function getAvailableModels() { - const config = await getConfig(); - if (!config?.initialUserStatus?.cascadeModelConfigData?.clientModelConfigs) { - return []; - } - return config.initialUserStatus.cascadeModelConfigData.clientModelConfigs; -} - /** * Force refresh the token (useful if requests start failing) */ @@ -169,13 +147,7 @@ export async function forceRefresh() { return getToken(); } -// Alias for forceRefresh -export const refreshToken = forceRefresh; - export default { getToken, - getConfig, - getAvailableModels, - forceRefresh, - refreshToken + forceRefresh }; diff --git a/test_pdf_payload.json b/test_pdf_payload.json deleted file mode 100644 index 6022e14..0000000 --- a/test_pdf_payload.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "model": "claude-sonnet-4-5", - "messages": [{ - "role": "user", - "content": [ - {"type": "text", "text": "What does this document contain?"}, - { - "type": "document", - "source": { - "type": "base64", - "media_type": "application/pdf", - "data": "JVBERi0xLgoxIDAgb2JqPDwvUGFnZXMgMiAwIFI+PmVuZG9iagoyIDAgb2JqPDwvS2lkc1szIDAgUl0vQ291bnQgMT4+ZW5kb2JqCjMgMCBvYmo8PC9QYXJlbnQgMiAwIFI+PmVuZG9iagp0cmFpbGVyPDwvUm9vdCAxIDAgUj4+Cg==" - } - } - ] - }], - "max_tokens": 100 -} diff --git a/tests/run-all.cjs b/tests/run-all.cjs new file mode 100644 index 0000000..3556b75 --- /dev/null +++ b/tests/run-all.cjs @@ -0,0 +1,100 @@ +#!/usr/bin/env node +/** + * Test Runner + * + * Runs all tests in sequence and reports results. + * Usage: node tests/run-all.cjs + */ +const { spawn } = require('child_process'); +const path = require('path'); + +const tests = [ + { name: 'Thinking Signatures', file: 'test-thinking-signatures.cjs' }, + { name: 'Multi-turn Tools (Non-Streaming)', file: 'test-multiturn-thinking-tools.cjs' }, + { name: 'Multi-turn Tools (Streaming)', file: 'test-multiturn-thinking-tools-streaming.cjs' }, + { name: 'Interleaved Thinking', file: 'test-interleaved-thinking.cjs' }, + { name: 'Image Support', file: 'test-images.cjs' } +]; + +async function runTest(test) { + return new Promise((resolve) => { + const testPath = path.join(__dirname, test.file); + const child = spawn('node', [testPath], { + stdio: 'inherit' + }); + + child.on('close', (code) => { + resolve({ ...test, passed: code === 0 }); + }); + + child.on('error', (err) => { + console.error(`Error running ${test.name}:`, err); + resolve({ ...test, passed: false }); + }); + }); +} + +async function main() { + console.log('╔══════════════════════════════════════════════════════════════╗'); + console.log('║ ANTIGRAVITY PROXY TEST SUITE ║'); + console.log('╚══════════════════════════════════════════════════════════════╝'); + console.log(''); + console.log('Make sure the server is running on port 8080 before running tests.'); + console.log(''); + + // Check if running specific test + const specificTest = process.argv[2]; + let testsToRun = tests; + + if (specificTest) { + testsToRun = tests.filter(t => + t.file.includes(specificTest) || t.name.toLowerCase().includes(specificTest.toLowerCase()) + ); + if (testsToRun.length === 0) { + console.log(`No test found matching: ${specificTest}`); + console.log('\nAvailable tests:'); + tests.forEach(t => console.log(` - ${t.name} (${t.file})`)); + process.exit(1); + } + } + + const results = []; + + for (const test of testsToRun) { + console.log('\n'); + console.log('╔' + '═'.repeat(60) + '╗'); + console.log('║ Running: ' + test.name.padEnd(50) + '║'); + console.log('╚' + '═'.repeat(60) + '╝'); + console.log(''); + + const result = await runTest(test); + results.push(result); + + console.log('\n'); + } + + // Summary + console.log('╔══════════════════════════════════════════════════════════════╗'); + console.log('║ FINAL RESULTS ║'); + console.log('╠══════════════════════════════════════════════════════════════╣'); + + let allPassed = true; + for (const result of results) { + const status = result.passed ? '✓ PASS' : '✗ FAIL'; + const statusColor = result.passed ? '' : ''; + console.log(`║ ${status.padEnd(8)} ${result.name.padEnd(50)} ║`); + if (!result.passed) allPassed = false; + } + + console.log('╠══════════════════════════════════════════════════════════════╣'); + const overallStatus = allPassed ? '✓ ALL TESTS PASSED' : '✗ SOME TESTS FAILED'; + console.log(`║ ${overallStatus.padEnd(60)} ║`); + console.log('╚══════════════════════════════════════════════════════════════╝'); + + process.exit(allPassed ? 0 : 1); +} + +main().catch(err => { + console.error('Test runner failed:', err); + process.exit(1); +}); diff --git a/tests/test-images.cjs b/tests/test-images.cjs new file mode 100644 index 0000000..8a7dbdb --- /dev/null +++ b/tests/test-images.cjs @@ -0,0 +1,240 @@ +/** + * Image Support Test + * + * Tests that images can be sent to the API with thinking models. + * Simulates Claude Code sending screenshots or images for analysis. + */ +const http = require('http'); +const fs = require('fs'); +const path = require('path'); + +const BASE_URL = 'localhost'; +const PORT = 8080; + +// Load test image from disk +const TEST_IMAGE_PATH = path.join(__dirname, 'utils', 'test_image.jpeg'); +const TEST_IMAGE_BASE64 = fs.readFileSync(TEST_IMAGE_PATH).toString('base64'); + +function streamRequest(body) { + return new Promise((resolve, reject) => { + const data = JSON.stringify(body); + const req = http.request({ + host: BASE_URL, + port: PORT, + path: '/v1/messages', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': 'test', + 'anthropic-version': '2023-06-01', + 'anthropic-beta': 'interleaved-thinking-2025-05-14', + 'Content-Length': Buffer.byteLength(data) + } + }, res => { + const events = []; + let fullData = ''; + + res.on('data', chunk => { + fullData += chunk.toString(); + }); + + res.on('end', () => { + const parts = fullData.split('\n\n').filter(e => e.trim()); + for (const part of parts) { + const lines = part.split('\n'); + const eventLine = lines.find(l => l.startsWith('event:')); + const dataLine = lines.find(l => l.startsWith('data:')); + if (eventLine && dataLine) { + try { + const eventType = eventLine.replace('event:', '').trim(); + const eventData = JSON.parse(dataLine.replace('data:', '').trim()); + events.push({ type: eventType, data: eventData }); + } catch (e) { } + } + } + + const content = []; + let currentBlock = null; + + for (const event of events) { + if (event.type === 'content_block_start') { + currentBlock = { ...event.data.content_block }; + if (currentBlock.type === 'thinking') { + currentBlock.thinking = ''; + currentBlock.signature = ''; + } + if (currentBlock.type === 'text') currentBlock.text = ''; + } else if (event.type === 'content_block_delta') { + const delta = event.data.delta; + if (delta.type === 'thinking_delta' && currentBlock) { + currentBlock.thinking += delta.thinking || ''; + } + if (delta.type === 'signature_delta' && currentBlock) { + currentBlock.signature += delta.signature || ''; + } + if (delta.type === 'text_delta' && currentBlock) { + currentBlock.text += delta.text || ''; + } + } else if (event.type === 'content_block_stop') { + if (currentBlock) content.push(currentBlock); + currentBlock = null; + } + } + + const errorEvent = events.find(e => e.type === 'error'); + if (errorEvent) { + resolve({ content, events, error: errorEvent.data.error, statusCode: res.statusCode }); + } else { + resolve({ content, events, statusCode: res.statusCode }); + } + }); + }); + req.on('error', reject); + req.write(data); + req.end(); + }); +} + +async function runTests() { + console.log('='.repeat(60)); + console.log('IMAGE SUPPORT TEST'); + console.log('Tests image processing with thinking models'); + console.log('='.repeat(60)); + console.log(''); + + let allPassed = true; + const results = []; + + // ===== TEST 1: Single image with question ===== + console.log('TEST 1: Single image with question'); + console.log('-'.repeat(40)); + + const result1 = await streamRequest({ + model: 'claude-sonnet-4-5-thinking', + max_tokens: 2048, + stream: true, + thinking: { type: 'enabled', budget_tokens: 8000 }, + messages: [{ + role: 'user', + content: [ + { + type: 'image', + source: { + type: 'base64', + media_type: 'image/jpeg', + data: TEST_IMAGE_BASE64 + } + }, + { + type: 'text', + text: 'What do you see in this image? Describe it briefly.' + } + ] + }] + }); + + if (result1.error) { + console.log(` ERROR: ${result1.error.message}`); + allPassed = false; + results.push({ name: 'Single image processing', passed: false }); + } else { + const thinking = result1.content.filter(b => b.type === 'thinking'); + const text = result1.content.filter(b => b.type === 'text'); + + console.log(` Thinking: ${thinking.length > 0 ? 'YES' : 'NO'}`); + console.log(` Text response: ${text.length > 0 ? 'YES' : 'NO'}`); + + if (thinking.length > 0) { + console.log(` Thinking: "${thinking[0].thinking?.substring(0, 60)}..."`); + } + if (text.length > 0) { + console.log(` Response: "${text[0].text?.substring(0, 100)}..."`); + } + + const passed = thinking.length > 0 && text.length > 0; + results.push({ name: 'Single image processing', passed }); + if (!passed) allPassed = false; + } + + // ===== TEST 2: Image + text in multi-turn ===== + console.log('\nTEST 2: Image in multi-turn conversation'); + console.log('-'.repeat(40)); + + const result2 = await streamRequest({ + model: 'claude-sonnet-4-5-thinking', + max_tokens: 2048, + stream: true, + thinking: { type: 'enabled', budget_tokens: 8000 }, + messages: [ + { + role: 'user', + content: 'I will show you an image.' + }, + { + role: 'assistant', + content: [{ + type: 'text', + text: 'Sure, please share the image and I\'ll help analyze it.' + }] + }, + { + role: 'user', + content: [ + { + type: 'image', + source: { + type: 'base64', + media_type: 'image/jpeg', + data: TEST_IMAGE_BASE64 + } + }, + { + type: 'text', + text: 'Here is the image. What do you see?' + } + ] + } + ] + }); + + if (result2.error) { + console.log(` ERROR: ${result2.error.message}`); + allPassed = false; + results.push({ name: 'Image in multi-turn', passed: false }); + } else { + const thinking = result2.content.filter(b => b.type === 'thinking'); + const text = result2.content.filter(b => b.type === 'text'); + + console.log(` Thinking: ${thinking.length > 0 ? 'YES' : 'NO'}`); + console.log(` Text response: ${text.length > 0 ? 'YES' : 'NO'}`); + + if (text.length > 0) { + console.log(` Response: "${text[0].text?.substring(0, 80)}..."`); + } + + const passed = text.length > 0; + results.push({ name: 'Image in multi-turn', passed }); + if (!passed) allPassed = false; + } + + // ===== Summary ===== + console.log('\n' + '='.repeat(60)); + console.log('SUMMARY'); + console.log('='.repeat(60)); + + for (const result of results) { + const status = result.passed ? 'PASS' : 'FAIL'; + console.log(` [${status}] ${result.name}`); + } + + console.log('\n' + '='.repeat(60)); + console.log(`OVERALL: ${allPassed ? 'ALL TESTS PASSED' : 'SOME TESTS FAILED'}`); + console.log('='.repeat(60)); + + process.exit(allPassed ? 0 : 1); +} + +runTests().catch(err => { + console.error('Test failed with error:', err); + process.exit(1); +}); diff --git a/tests/test-interleaved-thinking.cjs b/tests/test-interleaved-thinking.cjs new file mode 100644 index 0000000..209d4f8 --- /dev/null +++ b/tests/test-interleaved-thinking.cjs @@ -0,0 +1,285 @@ +/** + * Interleaved Thinking Test + * + * Tests that interleaved thinking works correctly: + * - Multiple thinking blocks can appear in a single response + * - Thinking blocks between tool calls + * - Thinking after tool results + * + * This simulates complex Claude Code scenarios where the model + * thinks multiple times during a single turn. + */ +const http = require('http'); + +const BASE_URL = 'localhost'; +const PORT = 8080; + +function streamRequest(body) { + return new Promise((resolve, reject) => { + const data = JSON.stringify(body); + const req = http.request({ + host: BASE_URL, + port: PORT, + path: '/v1/messages', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': 'test', + 'anthropic-version': '2023-06-01', + 'anthropic-beta': 'interleaved-thinking-2025-05-14', + 'Content-Length': Buffer.byteLength(data) + } + }, res => { + const events = []; + let fullData = ''; + + res.on('data', chunk => { + fullData += chunk.toString(); + }); + + res.on('end', () => { + const parts = fullData.split('\n\n').filter(e => e.trim()); + for (const part of parts) { + const lines = part.split('\n'); + const eventLine = lines.find(l => l.startsWith('event:')); + const dataLine = lines.find(l => l.startsWith('data:')); + if (eventLine && dataLine) { + try { + const eventType = eventLine.replace('event:', '').trim(); + const eventData = JSON.parse(dataLine.replace('data:', '').trim()); + events.push({ type: eventType, data: eventData }); + } catch (e) { } + } + } + + const content = []; + let currentBlock = null; + + for (const event of events) { + if (event.type === 'content_block_start') { + currentBlock = { ...event.data.content_block }; + if (currentBlock.type === 'thinking') { + currentBlock.thinking = ''; + currentBlock.signature = ''; + } + if (currentBlock.type === 'text') currentBlock.text = ''; + } else if (event.type === 'content_block_delta') { + const delta = event.data.delta; + if (delta.type === 'thinking_delta' && currentBlock) { + currentBlock.thinking += delta.thinking || ''; + } + if (delta.type === 'signature_delta' && currentBlock) { + currentBlock.signature += delta.signature || ''; + } + if (delta.type === 'text_delta' && currentBlock) { + currentBlock.text += delta.text || ''; + } + if (delta.type === 'input_json_delta' && currentBlock) { + currentBlock.partial_json = (currentBlock.partial_json || '') + delta.partial_json; + } + } else if (event.type === 'content_block_stop') { + if (currentBlock?.type === 'tool_use' && currentBlock.partial_json) { + try { currentBlock.input = JSON.parse(currentBlock.partial_json); } catch (e) { } + delete currentBlock.partial_json; + } + if (currentBlock) content.push(currentBlock); + currentBlock = null; + } + } + + const errorEvent = events.find(e => e.type === 'error'); + if (errorEvent) { + resolve({ content, events, error: errorEvent.data.error, statusCode: res.statusCode }); + } else { + resolve({ content, events, statusCode: res.statusCode }); + } + }); + }); + req.on('error', reject); + req.write(data); + req.end(); + }); +} + +// Multiple tools to encourage interleaved thinking +const tools = [{ + name: 'read_file', + description: 'Read a file', + input_schema: { + type: 'object', + properties: { path: { type: 'string' } }, + required: ['path'] + } +}, { + name: 'write_file', + description: 'Write to a file', + input_schema: { + type: 'object', + properties: { + path: { type: 'string' }, + content: { type: 'string' } + }, + required: ['path', 'content'] + } +}, { + name: 'run_tests', + description: 'Run test suite', + input_schema: { + type: 'object', + properties: { pattern: { type: 'string' } }, + required: ['pattern'] + } +}]; + +async function runTests() { + console.log('='.repeat(60)); + console.log('INTERLEAVED THINKING TEST'); + console.log('Tests complex multi-step reasoning with tools'); + console.log('='.repeat(60)); + console.log(''); + + let allPassed = true; + const results = []; + + // ===== TEST 1: Complex task requiring multiple steps ===== + console.log('TEST 1: Complex task - read, modify, write, test'); + console.log('-'.repeat(40)); + + const result = await streamRequest({ + model: 'claude-opus-4-5-thinking', + max_tokens: 8192, + stream: true, + tools, + thinking: { type: 'enabled', budget_tokens: 16000 }, + messages: [{ + role: 'user', + content: `I need you to: +1. Read the file src/config.js +2. Add a new config option "debug: true" +3. Write the updated file +4. Run the tests to make sure nothing broke + +Please do this step by step, reading each file before modifying.` + }] + }); + + if (result.error) { + console.log(` ERROR: ${result.error.message}`); + allPassed = false; + results.push({ name: 'Complex multi-step task', passed: false }); + } else { + const thinking = result.content.filter(b => b.type === 'thinking'); + const toolUse = result.content.filter(b => b.type === 'tool_use'); + const text = result.content.filter(b => b.type === 'text'); + + console.log(` Thinking blocks: ${thinking.length}`); + console.log(` Tool use blocks: ${toolUse.length}`); + console.log(` Text blocks: ${text.length}`); + + // Check signatures + const signedThinking = thinking.filter(t => t.signature && t.signature.length >= 50); + console.log(` Signed thinking blocks: ${signedThinking.length}`); + + // Analyze block order + const blockOrder = result.content.map(b => b.type).join(' -> '); + console.log(` Block order: ${blockOrder}`); + + // Show thinking previews + thinking.forEach((t, i) => { + console.log(` Thinking ${i + 1}: "${(t.thinking || '').substring(0, 50)}..."`); + }); + + // Show tool calls + toolUse.forEach((t, i) => { + console.log(` Tool ${i + 1}: ${t.name}(${JSON.stringify(t.input).substring(0, 50)}...)`); + }); + + // Expect at least one thinking block (ideally multiple for complex task) + const passed = thinking.length >= 1 && signedThinking.length >= 1 && toolUse.length >= 1; + results.push({ name: 'Thinking + Tools in complex task', passed }); + if (!passed) allPassed = false; + } + + // ===== TEST 2: Multiple tool calls in sequence ===== + console.log('\nTEST 2: Tool result followed by more thinking'); + console.log('-'.repeat(40)); + + // Start with previous result and add tool result + if (result.content && result.content.some(b => b.type === 'tool_use')) { + const toolUseBlock = result.content.find(b => b.type === 'tool_use'); + + const result2 = await streamRequest({ + model: 'claude-opus-4-5-thinking', + max_tokens: 8192, + stream: true, + tools, + thinking: { type: 'enabled', budget_tokens: 16000 }, + messages: [ + { + role: 'user', + content: `Read src/config.js and tell me if debug mode is enabled.` + }, + { role: 'assistant', content: result.content }, + { + role: 'user', + content: [{ + type: 'tool_result', + tool_use_id: toolUseBlock.id, + content: `module.exports = { + port: 3000, + host: 'localhost', + debug: false +};` + }] + } + ] + }); + + if (result2.error) { + console.log(` ERROR: ${result2.error.message}`); + allPassed = false; + results.push({ name: 'Thinking after tool result', passed: false }); + } else { + const thinking2 = result2.content.filter(b => b.type === 'thinking'); + const text2 = result2.content.filter(b => b.type === 'text'); + const toolUse2 = result2.content.filter(b => b.type === 'tool_use'); + + console.log(` Thinking blocks: ${thinking2.length}`); + console.log(` Text blocks: ${text2.length}`); + console.log(` Tool use blocks: ${toolUse2.length}`); + + if (text2.length > 0) { + console.log(` Response: "${text2[0].text?.substring(0, 80)}..."`); + } + + // Should have thinking after receiving tool result + const passed = thinking2.length >= 1 && (text2.length > 0 || toolUse2.length > 0); + results.push({ name: 'Thinking after tool result', passed }); + if (!passed) allPassed = false; + } + } else { + console.log(' SKIPPED - No tool use in previous test'); + results.push({ name: 'Thinking after tool result', passed: false, skipped: true }); + } + + // ===== Summary ===== + console.log('\n' + '='.repeat(60)); + console.log('SUMMARY'); + console.log('='.repeat(60)); + + for (const result of results) { + const status = result.skipped ? 'SKIP' : (result.passed ? 'PASS' : 'FAIL'); + console.log(` [${status}] ${result.name}`); + } + + console.log('\n' + '='.repeat(60)); + console.log(`OVERALL: ${allPassed ? 'ALL TESTS PASSED' : 'SOME TESTS FAILED'}`); + console.log('='.repeat(60)); + + process.exit(allPassed ? 0 : 1); +} + +runTests().catch(err => { + console.error('Test failed with error:', err); + process.exit(1); +}); diff --git a/tests/test-multiturn-thinking-tools-streaming.cjs b/tests/test-multiturn-thinking-tools-streaming.cjs new file mode 100644 index 0000000..0035ccc --- /dev/null +++ b/tests/test-multiturn-thinking-tools-streaming.cjs @@ -0,0 +1,301 @@ +/** + * Multi-Turn Tool Call Test (Streaming) + * + * Simulates Claude Code's streaming multi-turn conversation pattern. + * Same flow as non-streaming but verifies: + * - SSE events are properly formatted + * - signature_delta events are present + * - Thinking blocks accumulate correctly across deltas + */ +const http = require('http'); + +const BASE_URL = 'localhost'; +const PORT = 8080; + +function streamRequest(body) { + return new Promise((resolve, reject) => { + const data = JSON.stringify(body); + const req = http.request({ + host: BASE_URL, + port: PORT, + path: '/v1/messages', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': 'test', + 'anthropic-version': '2023-06-01', + 'anthropic-beta': 'interleaved-thinking-2025-05-14', + 'Content-Length': Buffer.byteLength(data) + } + }, res => { + const events = []; + let fullData = ''; + + res.on('data', chunk => { + fullData += chunk.toString(); + }); + + res.on('end', () => { + // Parse SSE events + const parts = fullData.split('\n\n').filter(e => e.trim()); + for (const part of parts) { + const lines = part.split('\n'); + const eventLine = lines.find(l => l.startsWith('event:')); + const dataLine = lines.find(l => l.startsWith('data:')); + if (eventLine && dataLine) { + try { + const eventType = eventLine.replace('event:', '').trim(); + const eventData = JSON.parse(dataLine.replace('data:', '').trim()); + events.push({ type: eventType, data: eventData }); + } catch (e) { } + } + } + + // Build content from events + const content = []; + let currentBlock = null; + + for (const event of events) { + if (event.type === 'content_block_start') { + currentBlock = { ...event.data.content_block }; + if (currentBlock.type === 'thinking') { + currentBlock.thinking = ''; + currentBlock.signature = ''; + } + if (currentBlock.type === 'text') currentBlock.text = ''; + } else if (event.type === 'content_block_delta') { + const delta = event.data.delta; + if (delta.type === 'thinking_delta' && currentBlock) { + currentBlock.thinking += delta.thinking || ''; + } + if (delta.type === 'signature_delta' && currentBlock) { + currentBlock.signature += delta.signature || ''; + } + if (delta.type === 'text_delta' && currentBlock) { + currentBlock.text += delta.text || ''; + } + if (delta.type === 'input_json_delta' && currentBlock) { + currentBlock.partial_json = (currentBlock.partial_json || '') + delta.partial_json; + } + } else if (event.type === 'content_block_stop') { + if (currentBlock?.type === 'tool_use' && currentBlock.partial_json) { + try { currentBlock.input = JSON.parse(currentBlock.partial_json); } catch (e) { } + delete currentBlock.partial_json; + } + if (currentBlock) content.push(currentBlock); + currentBlock = null; + } + } + + // Check for errors + const errorEvent = events.find(e => e.type === 'error'); + if (errorEvent) { + resolve({ + content, + events, + error: errorEvent.data.error, + statusCode: res.statusCode, + raw: fullData + }); + } else { + resolve({ content, events, statusCode: res.statusCode, raw: fullData }); + } + }); + }); + req.on('error', reject); + req.write(data); + req.end(); + }); +} + +const tools = [{ + name: 'execute_command', + description: 'Execute a shell command', + input_schema: { + type: 'object', + properties: { + command: { type: 'string', description: 'Command to execute' }, + cwd: { type: 'string', description: 'Working directory' } + }, + required: ['command'] + } +}]; + +function analyzeContent(content) { + const thinking = content.filter(b => b.type === 'thinking'); + const toolUse = content.filter(b => b.type === 'tool_use'); + const text = content.filter(b => b.type === 'text'); + + return { + thinking, + toolUse, + text, + hasThinking: thinking.length > 0, + hasToolUse: toolUse.length > 0, + hasText: text.length > 0, + thinkingHasSignature: thinking.some(t => t.signature && t.signature.length >= 50) + }; +} + +function analyzeEvents(events) { + return { + messageStart: events.filter(e => e.type === 'message_start').length, + blockStart: events.filter(e => e.type === 'content_block_start').length, + blockDelta: events.filter(e => e.type === 'content_block_delta').length, + blockStop: events.filter(e => e.type === 'content_block_stop').length, + messageDelta: events.filter(e => e.type === 'message_delta').length, + messageStop: events.filter(e => e.type === 'message_stop').length, + thinkingDeltas: events.filter(e => e.data?.delta?.type === 'thinking_delta').length, + signatureDeltas: events.filter(e => e.data?.delta?.type === 'signature_delta').length, + textDeltas: events.filter(e => e.data?.delta?.type === 'text_delta').length, + inputJsonDeltas: events.filter(e => e.data?.delta?.type === 'input_json_delta').length + }; +} + +async function runTests() { + console.log('='.repeat(60)); + console.log('MULTI-TURN TOOL CALL TEST (STREAMING)'); + console.log('Simulates Claude Code streaming conversation'); + console.log('='.repeat(60)); + console.log(''); + + let messages = []; + let allPassed = true; + const results = []; + + // ===== TURN 1: Initial request ===== + console.log('TURN 1: User asks to run a command'); + console.log('-'.repeat(40)); + + messages.push({ + role: 'user', + content: 'Run "ls -la" in the current directory and tell me what files exist.' + }); + + const turn1 = await streamRequest({ + model: 'claude-sonnet-4-5-thinking', + max_tokens: 4096, + stream: true, + tools, + thinking: { type: 'enabled', budget_tokens: 10000 }, + messages + }); + + if (turn1.error) { + console.log(` ERROR: ${turn1.error.message}`); + allPassed = false; + results.push({ name: 'Turn 1: Streaming request', passed: false }); + } else { + const content = analyzeContent(turn1.content); + const events = analyzeEvents(turn1.events); + + console.log(' Content:'); + console.log(` Thinking: ${content.hasThinking ? 'YES' : 'NO'} (${content.thinking.length} blocks)`); + console.log(` Signature: ${content.thinkingHasSignature ? 'YES' : 'NO'}`); + console.log(` Tool Use: ${content.hasToolUse ? 'YES' : 'NO'} (${content.toolUse.length} calls)`); + + console.log(' Events:'); + console.log(` message_start: ${events.messageStart}`); + console.log(` content_block_start/stop: ${events.blockStart}/${events.blockStop}`); + console.log(` thinking_delta: ${events.thinkingDeltas}`); + console.log(` signature_delta: ${events.signatureDeltas}`); + console.log(` input_json_delta: ${events.inputJsonDeltas}`); + + if (content.hasThinking && content.thinking[0].thinking) { + console.log(` Thinking: "${content.thinking[0].thinking.substring(0, 60)}..."`); + } + if (content.hasToolUse) { + console.log(` Tool: ${content.toolUse[0].name}(${JSON.stringify(content.toolUse[0].input)})`); + } + + const passed = content.hasThinking && content.thinkingHasSignature && + events.signatureDeltas > 0 && content.hasToolUse; + results.push({ name: 'Turn 1: Thinking + Signature + Tool Use + Events', passed }); + if (!passed) allPassed = false; + + if (content.hasToolUse) { + messages.push({ role: 'assistant', content: turn1.content }); + } + } + + // ===== TURN 2: Provide tool result ===== + if (messages.length >= 2) { + console.log('\nTURN 2: Provide command output, expect summary'); + console.log('-'.repeat(40)); + + const lastAssistant = messages[messages.length - 1]; + const toolUseBlock = lastAssistant.content.find(b => b.type === 'tool_use'); + + messages.push({ + role: 'user', + content: [{ + type: 'tool_result', + tool_use_id: toolUseBlock.id, + content: `total 32 +drwxr-xr-x 10 user staff 320 Dec 19 10:00 . +drwxr-xr-x 5 user staff 160 Dec 19 09:00 .. +-rw-r--r-- 1 user staff 1024 Dec 19 10:00 package.json +-rw-r--r-- 1 user staff 2048 Dec 19 10:00 README.md +drwxr-xr-x 8 user staff 256 Dec 19 10:00 src +drwxr-xr-x 4 user staff 128 Dec 19 10:00 tests` + }] + }); + + const turn2 = await streamRequest({ + model: 'claude-sonnet-4-5-thinking', + max_tokens: 4096, + stream: true, + tools, + thinking: { type: 'enabled', budget_tokens: 10000 }, + messages + }); + + if (turn2.error) { + console.log(` ERROR: ${turn2.error.message}`); + allPassed = false; + results.push({ name: 'Turn 2: After tool result', passed: false }); + } else { + const content = analyzeContent(turn2.content); + const events = analyzeEvents(turn2.events); + + console.log(' Content:'); + console.log(` Thinking: ${content.hasThinking ? 'YES' : 'NO'} (${content.thinking.length} blocks)`); + console.log(` Signature: ${content.thinkingHasSignature ? 'YES' : 'NO'}`); + console.log(` Text: ${content.hasText ? 'YES' : 'NO'}`); + + console.log(' Events:'); + console.log(` thinking_delta: ${events.thinkingDeltas}`); + console.log(` signature_delta: ${events.signatureDeltas}`); + console.log(` text_delta: ${events.textDeltas}`); + + if (content.hasText && content.text[0].text) { + console.log(` Response: "${content.text[0].text.substring(0, 100)}..."`); + } + + const passed = content.hasThinking && content.hasText && events.textDeltas > 0; + results.push({ name: 'Turn 2: Thinking + Text response', passed }); + if (!passed) allPassed = false; + } + } + + // ===== Summary ===== + console.log('\n' + '='.repeat(60)); + console.log('SUMMARY'); + console.log('='.repeat(60)); + + for (const result of results) { + const status = result.passed ? 'PASS' : 'FAIL'; + console.log(` [${status}] ${result.name}`); + } + + console.log('\n' + '='.repeat(60)); + console.log(`OVERALL: ${allPassed ? 'ALL TESTS PASSED' : 'SOME TESTS FAILED'}`); + console.log('='.repeat(60)); + + process.exit(allPassed ? 0 : 1); +} + +runTests().catch(err => { + console.error('Test failed with error:', err); + process.exit(1); +}); diff --git a/tests/test-multiturn-thinking-tools.cjs b/tests/test-multiturn-thinking-tools.cjs new file mode 100644 index 0000000..1b52164 --- /dev/null +++ b/tests/test-multiturn-thinking-tools.cjs @@ -0,0 +1,278 @@ +/** + * Multi-Turn Tool Call Test (Non-Streaming) + * + * Simulates Claude Code's actual multi-turn conversation pattern: + * 1. User asks question requiring tool + * 2. Assistant responds with thinking + tool_use + * 3. User provides tool_result + * 4. Assistant responds with thinking + final answer + * + * Key aspects tested: + * - Thinking blocks with signatures are preserved across turns + * - Tool use/result flow works correctly + * - Interleaved thinking with tools + */ +const http = require('http'); + +const BASE_URL = 'localhost'; +const PORT = 8080; + +function makeRequest(body) { + return new Promise((resolve, reject) => { + const data = JSON.stringify(body); + const req = http.request({ + host: BASE_URL, + port: PORT, + path: '/v1/messages', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': 'test', + 'anthropic-version': '2023-06-01', + 'anthropic-beta': 'interleaved-thinking-2025-05-14', + 'Content-Length': Buffer.byteLength(data) + } + }, res => { + let fullData = ''; + res.on('data', chunk => fullData += chunk.toString()); + res.on('end', () => { + try { + const parsed = JSON.parse(fullData); + resolve({ ...parsed, statusCode: res.statusCode }); + } catch (e) { + reject(new Error(`Parse error: ${e.message}\nRaw: ${fullData.substring(0, 500)}`)); + } + }); + }); + req.on('error', reject); + req.write(data); + req.end(); + }); +} + +const tools = [{ + name: 'search_files', + description: 'Search for files matching a pattern', + input_schema: { + type: 'object', + properties: { + pattern: { type: 'string', description: 'Glob pattern to search' }, + path: { type: 'string', description: 'Directory to search in' } + }, + required: ['pattern'] + } +}, { + name: 'read_file', + description: 'Read contents of a file', + input_schema: { + type: 'object', + properties: { + path: { type: 'string', description: 'Path to file' } + }, + required: ['path'] + } +}]; + +function analyzeContent(content) { + const thinking = content.filter(b => b.type === 'thinking'); + const toolUse = content.filter(b => b.type === 'tool_use'); + const text = content.filter(b => b.type === 'text'); + + return { + thinking, + toolUse, + text, + hasThinking: thinking.length > 0, + hasToolUse: toolUse.length > 0, + hasText: text.length > 0, + thinkingHasSignature: thinking.some(t => t.signature && t.signature.length >= 50) + }; +} + +async function runTests() { + console.log('='.repeat(60)); + console.log('MULTI-TURN TOOL CALL TEST (NON-STREAMING)'); + console.log('Simulates Claude Code conversation pattern'); + console.log('='.repeat(60)); + console.log(''); + + let messages = []; + let allPassed = true; + const results = []; + + // ===== TURN 1: Initial request ===== + console.log('TURN 1: User asks to find and read a config file'); + console.log('-'.repeat(40)); + + messages.push({ + role: 'user', + content: 'Find the package.json file and tell me what dependencies it has. Use search_files first.' + }); + + const turn1 = await makeRequest({ + model: 'claude-sonnet-4-5-thinking', + max_tokens: 4096, + stream: false, + tools, + thinking: { type: 'enabled', budget_tokens: 10000 }, + messages + }); + + if (turn1.statusCode !== 200 || turn1.error) { + console.log(` ERROR: ${turn1.error?.message || `Status ${turn1.statusCode}`}`); + allPassed = false; + results.push({ name: 'Turn 1: Initial request', passed: false }); + } else { + const analysis = analyzeContent(turn1.content || []); + console.log(` Thinking: ${analysis.hasThinking ? 'YES' : 'NO'} (${analysis.thinking.length} blocks)`); + console.log(` Signature: ${analysis.thinkingHasSignature ? 'YES' : 'NO'}`); + console.log(` Tool Use: ${analysis.hasToolUse ? 'YES' : 'NO'} (${analysis.toolUse.length} calls)`); + console.log(` Text: ${analysis.hasText ? 'YES' : 'NO'}`); + + if (analysis.hasThinking && analysis.thinking[0].thinking) { + console.log(` Thinking: "${analysis.thinking[0].thinking.substring(0, 60)}..."`); + } + if (analysis.hasToolUse) { + console.log(` Tool: ${analysis.toolUse[0].name}(${JSON.stringify(analysis.toolUse[0].input)})`); + } + + const passed = analysis.hasThinking && analysis.thinkingHasSignature && analysis.hasToolUse; + results.push({ name: 'Turn 1: Thinking + Signature + Tool Use', passed }); + if (!passed) allPassed = false; + + // Prepare for turn 2 + if (analysis.hasToolUse) { + messages.push({ role: 'assistant', content: turn1.content }); + } + } + + // ===== TURN 2: Provide tool result ===== + if (messages.length >= 2) { + console.log('\nTURN 2: Provide tool result, expect another tool call'); + console.log('-'.repeat(40)); + + const lastAssistant = messages[messages.length - 1]; + const toolUseBlock = lastAssistant.content.find(b => b.type === 'tool_use'); + + messages.push({ + role: 'user', + content: [{ + type: 'tool_result', + tool_use_id: toolUseBlock.id, + content: 'Found files:\n- /project/package.json\n- /project/packages/core/package.json' + }] + }); + + const turn2 = await makeRequest({ + model: 'claude-sonnet-4-5-thinking', + max_tokens: 4096, + stream: false, + tools, + thinking: { type: 'enabled', budget_tokens: 10000 }, + messages + }); + + if (turn2.statusCode !== 200 || turn2.error) { + console.log(` ERROR: ${turn2.error?.message || `Status ${turn2.statusCode}`}`); + allPassed = false; + results.push({ name: 'Turn 2: After tool result', passed: false }); + } else { + const analysis = analyzeContent(turn2.content || []); + console.log(` Thinking: ${analysis.hasThinking ? 'YES' : 'NO'} (${analysis.thinking.length} blocks)`); + console.log(` Signature: ${analysis.thinkingHasSignature ? 'YES' : 'NO'}`); + console.log(` Tool Use: ${analysis.hasToolUse ? 'YES' : 'NO'} (${analysis.toolUse.length} calls)`); + console.log(` Text: ${analysis.hasText ? 'YES' : 'NO'}`); + + if (analysis.hasThinking && analysis.thinking[0].thinking) { + console.log(` Thinking: "${analysis.thinking[0].thinking.substring(0, 60)}..."`); + } + if (analysis.hasToolUse) { + console.log(` Tool: ${analysis.toolUse[0].name}(${JSON.stringify(analysis.toolUse[0].input)})`); + } + + // Either tool use (to read file) or text response is acceptable + const passed = analysis.hasThinking && (analysis.hasToolUse || analysis.hasText); + results.push({ name: 'Turn 2: Thinking + (Tool or Text)', passed }); + if (!passed) allPassed = false; + + if (analysis.hasToolUse) { + messages.push({ role: 'assistant', content: turn2.content }); + } + } + } + + // ===== TURN 3: Final tool result and response ===== + if (messages.length >= 4) { + const lastAssistant = messages[messages.length - 1]; + const toolUseBlock = lastAssistant.content?.find(b => b.type === 'tool_use'); + + if (toolUseBlock) { + console.log('\nTURN 3: Provide file content, expect final response'); + console.log('-'.repeat(40)); + + messages.push({ + role: 'user', + content: [{ + type: 'tool_result', + tool_use_id: toolUseBlock.id, + content: JSON.stringify({ + name: 'my-project', + dependencies: { + express: '^4.18.2', + cors: '^2.8.5' + } + }, null, 2) + }] + }); + + const turn3 = await makeRequest({ + model: 'claude-sonnet-4-5-thinking', + max_tokens: 4096, + stream: false, + tools, + thinking: { type: 'enabled', budget_tokens: 10000 }, + messages + }); + + if (turn3.statusCode !== 200 || turn3.error) { + console.log(` ERROR: ${turn3.error?.message || `Status ${turn3.statusCode}`}`); + allPassed = false; + results.push({ name: 'Turn 3: Final response', passed: false }); + } else { + const analysis = analyzeContent(turn3.content || []); + console.log(` Thinking: ${analysis.hasThinking ? 'YES' : 'NO'} (${analysis.thinking.length} blocks)`); + console.log(` Signature: ${analysis.thinkingHasSignature ? 'YES' : 'NO'}`); + console.log(` Text: ${analysis.hasText ? 'YES' : 'NO'}`); + + if (analysis.hasText && analysis.text[0].text) { + console.log(` Response: "${analysis.text[0].text.substring(0, 100)}..."`); + } + + const passed = analysis.hasThinking && analysis.hasText; + results.push({ name: 'Turn 3: Thinking + Text response', passed }); + if (!passed) allPassed = false; + } + } + } + + // ===== Summary ===== + console.log('\n' + '='.repeat(60)); + console.log('SUMMARY'); + console.log('='.repeat(60)); + + for (const result of results) { + const status = result.passed ? 'PASS' : 'FAIL'; + console.log(` [${status}] ${result.name}`); + } + + console.log('\n' + '='.repeat(60)); + console.log(`OVERALL: ${allPassed ? 'ALL TESTS PASSED' : 'SOME TESTS FAILED'}`); + console.log('='.repeat(60)); + + process.exit(allPassed ? 0 : 1); +} + +runTests().catch(err => { + console.error('Test failed with error:', err); + process.exit(1); +}); diff --git a/tests/test-thinking-signatures.cjs b/tests/test-thinking-signatures.cjs new file mode 100644 index 0000000..28e6d02 --- /dev/null +++ b/tests/test-thinking-signatures.cjs @@ -0,0 +1,272 @@ +/** + * Thinking Signature Test + * + * Tests that thinking blocks with signatures are properly handled in multi-turn + * conversations, simulating how Claude Code sends requests. + * + * Claude Code sends assistant messages with thinking blocks that include signatures. + * These signatures must be preserved and sent back to the API. + */ +const http = require('http'); + +const BASE_URL = 'localhost'; +const PORT = 8080; + +function streamRequest(body) { + return new Promise((resolve, reject) => { + const data = JSON.stringify(body); + const req = http.request({ + host: BASE_URL, + port: PORT, + path: '/v1/messages', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': 'test', + 'anthropic-version': '2023-06-01', + 'anthropic-beta': 'interleaved-thinking-2025-05-14', + 'Content-Length': Buffer.byteLength(data) + } + }, res => { + const events = []; + let fullData = ''; + + res.on('data', chunk => { + fullData += chunk.toString(); + }); + + res.on('end', () => { + // Parse SSE events + const parts = fullData.split('\n\n').filter(e => e.trim()); + for (const part of parts) { + const lines = part.split('\n'); + const eventLine = lines.find(l => l.startsWith('event:')); + const dataLine = lines.find(l => l.startsWith('data:')); + if (eventLine && dataLine) { + try { + const eventType = eventLine.replace('event:', '').trim(); + const eventData = JSON.parse(dataLine.replace('data:', '').trim()); + events.push({ type: eventType, data: eventData }); + } catch (e) { } + } + } + + // Build content from events + const content = []; + let currentBlock = null; + + for (const event of events) { + if (event.type === 'content_block_start') { + currentBlock = { ...event.data.content_block }; + if (currentBlock.type === 'thinking') { + currentBlock.thinking = ''; + currentBlock.signature = ''; + } + if (currentBlock.type === 'text') currentBlock.text = ''; + } else if (event.type === 'content_block_delta') { + const delta = event.data.delta; + if (delta.type === 'thinking_delta' && currentBlock) { + currentBlock.thinking += delta.thinking || ''; + } + if (delta.type === 'signature_delta' && currentBlock) { + currentBlock.signature += delta.signature || ''; + } + if (delta.type === 'text_delta' && currentBlock) { + currentBlock.text += delta.text || ''; + } + if (delta.type === 'input_json_delta' && currentBlock) { + currentBlock.partial_json = (currentBlock.partial_json || '') + delta.partial_json; + } + } else if (event.type === 'content_block_stop') { + if (currentBlock?.type === 'tool_use' && currentBlock.partial_json) { + try { currentBlock.input = JSON.parse(currentBlock.partial_json); } catch (e) { } + delete currentBlock.partial_json; + } + if (currentBlock) content.push(currentBlock); + currentBlock = null; + } + } + + resolve({ content, events, statusCode: res.statusCode, raw: fullData }); + }); + }); + req.on('error', reject); + req.write(data); + req.end(); + }); +} + +const tools = [{ + name: 'get_weather', + description: 'Get the current weather for a location', + input_schema: { + type: 'object', + properties: { + location: { type: 'string', description: 'City name' } + }, + required: ['location'] + } +}]; + +async function runTests() { + console.log('='.repeat(60)); + console.log('THINKING SIGNATURE TEST'); + console.log('Simulates Claude Code multi-turn with thinking blocks'); + console.log('='.repeat(60)); + console.log(''); + + let allPassed = true; + const results = []; + + // ===== TEST 1: First turn - get thinking block with signature ===== + console.log('TEST 1: Initial request with thinking model'); + console.log('-'.repeat(40)); + + const turn1Messages = [ + { role: 'user', content: 'What is the weather in Paris? Use the get_weather tool.' } + ]; + + const turn1Result = await streamRequest({ + model: 'claude-sonnet-4-5-thinking', + max_tokens: 4096, + stream: true, + tools, + thinking: { type: 'enabled', budget_tokens: 10000 }, + messages: turn1Messages + }); + + const turn1Thinking = turn1Result.content.filter(b => b.type === 'thinking'); + const turn1ToolUse = turn1Result.content.filter(b => b.type === 'tool_use'); + const turn1Text = turn1Result.content.filter(b => b.type === 'text'); + + console.log(` Thinking blocks: ${turn1Thinking.length}`); + console.log(` Tool use blocks: ${turn1ToolUse.length}`); + console.log(` Text blocks: ${turn1Text.length}`); + + // Check thinking has signature + let turn1HasSignature = false; + if (turn1Thinking.length > 0) { + const sig = turn1Thinking[0].signature || ''; + turn1HasSignature = sig.length >= 50; + console.log(` Signature length: ${sig.length} chars`); + console.log(` Signature present: ${turn1HasSignature ? 'YES' : 'NO'}`); + if (turn1Thinking[0].thinking) { + console.log(` Thinking preview: "${turn1Thinking[0].thinking.substring(0, 80)}..."`); + } + } + + const test1Pass = turn1Thinking.length > 0 && turn1HasSignature && turn1ToolUse.length > 0; + results.push({ name: 'Turn 1: Thinking + Signature + Tool Use', passed: test1Pass }); + console.log(` Result: ${test1Pass ? 'PASS' : 'FAIL'}`); + if (!test1Pass) allPassed = false; + + // ===== TEST 2: Second turn - send back thinking with signature ===== + console.log('\nTEST 2: Multi-turn with thinking signature in assistant message'); + console.log('-'.repeat(40)); + + if (!turn1ToolUse.length) { + console.log(' SKIPPED - No tool use in turn 1'); + results.push({ name: 'Turn 2: Multi-turn with signature', passed: false, skipped: true }); + } else { + // Build assistant message with thinking (including signature) - this is how Claude Code sends it + const assistantContent = turn1Result.content; + + // Verify the thinking block has signature before sending + const thinkingInAssistant = assistantContent.find(b => b.type === 'thinking'); + if (thinkingInAssistant) { + console.log(` Sending thinking with signature: ${(thinkingInAssistant.signature || '').length} chars`); + } + + const turn2Messages = [ + ...turn1Messages, + { role: 'assistant', content: assistantContent }, + { + role: 'user', + content: [{ + type: 'tool_result', + tool_use_id: turn1ToolUse[0].id, + content: 'The weather in Paris is 18°C and sunny.' + }] + } + ]; + + const turn2Result = await streamRequest({ + model: 'claude-sonnet-4-5-thinking', + max_tokens: 4096, + stream: true, + tools, + thinking: { type: 'enabled', budget_tokens: 10000 }, + messages: turn2Messages + }); + + const turn2Thinking = turn2Result.content.filter(b => b.type === 'thinking'); + const turn2Text = turn2Result.content.filter(b => b.type === 'text'); + + console.log(` Thinking blocks: ${turn2Thinking.length}`); + console.log(` Text blocks: ${turn2Text.length}`); + + // Check for errors + const hasError = turn2Result.events.some(e => e.type === 'error'); + if (hasError) { + const errorEvent = turn2Result.events.find(e => e.type === 'error'); + console.log(` ERROR: ${errorEvent?.data?.error?.message || 'Unknown error'}`); + } + + if (turn2Thinking.length > 0) { + const sig = turn2Thinking[0].signature || ''; + console.log(` New signature length: ${sig.length} chars`); + if (turn2Thinking[0].thinking) { + console.log(` Thinking preview: "${turn2Thinking[0].thinking.substring(0, 80)}..."`); + } + } + + if (turn2Text.length > 0 && turn2Text[0].text) { + console.log(` Response: "${turn2Text[0].text.substring(0, 100)}..."`); + } + + const test2Pass = !hasError && (turn2Thinking.length > 0 || turn2Text.length > 0); + results.push({ name: 'Turn 2: Multi-turn with signature', passed: test2Pass }); + console.log(` Result: ${test2Pass ? 'PASS' : 'FAIL'}`); + if (!test2Pass) allPassed = false; + } + + // ===== TEST 3: Verify signature_delta events in stream ===== + console.log('\nTEST 3: Verify signature_delta events in stream'); + console.log('-'.repeat(40)); + + const signatureDeltas = turn1Result.events.filter( + e => e.type === 'content_block_delta' && e.data?.delta?.type === 'signature_delta' + ); + console.log(` signature_delta events: ${signatureDeltas.length}`); + + if (signatureDeltas.length > 0) { + const totalSigLength = signatureDeltas.reduce((sum, e) => sum + (e.data.delta.signature?.length || 0), 0); + console.log(` Total signature length from deltas: ${totalSigLength} chars`); + } + + const test3Pass = signatureDeltas.length > 0; + results.push({ name: 'signature_delta events present', passed: test3Pass }); + console.log(` Result: ${test3Pass ? 'PASS' : 'FAIL'}`); + if (!test3Pass) allPassed = false; + + // ===== Summary ===== + console.log('\n' + '='.repeat(60)); + console.log('SUMMARY'); + console.log('='.repeat(60)); + + for (const result of results) { + const status = result.skipped ? 'SKIP' : (result.passed ? 'PASS' : 'FAIL'); + console.log(` [${status}] ${result.name}`); + } + + console.log('\n' + '='.repeat(60)); + console.log(`OVERALL: ${allPassed ? 'ALL TESTS PASSED' : 'SOME TESTS FAILED'}`); + console.log('='.repeat(60)); + + process.exit(allPassed ? 0 : 1); +} + +runTests().catch(err => { + console.error('Test failed with error:', err); + process.exit(1); +}); diff --git a/tests/utils/test_image.jpeg b/tests/utils/test_image.jpeg new file mode 100644 index 0000000..c3561ec Binary files /dev/null and b/tests/utils/test_image.jpeg differ