From 0edc71867264aabc515e1653e95b27b23b45b26c Mon Sep 17 00:00:00 2001 From: Badri Narayanan S Date: Wed, 24 Dec 2025 18:11:45 +0530 Subject: [PATCH] refactor: centralize constants, add error classes, and DRY test utilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create src/errors.js with custom error classes (RateLimitError, AuthError, ApiError, etc.) - Create src/utils/helpers.js with shared utilities (formatDuration, sleep) - Create tests/helpers/http-client.cjs with shared test utilities (~250 lines deduped) - Centralize OAuth config and other constants in src/constants.js - Add JSDoc types to all major exported functions - Refactor all test files to use shared http-client utilities - Update CLAUDE.md with new architecture documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CLAUDE.md | 42 ++- src/account-manager.js | 60 ++-- src/accounts-cli.js | 3 +- src/cloudcode-client.js | 36 ++- src/constants.js | 32 ++- src/errors.js | 159 +++++++++++ src/format-converter.js | 16 ++ src/oauth.js | 81 +++--- src/token-extractor.js | 16 +- src/utils/helpers.js | 33 +++ tests/helpers/http-client.cjs | 260 ++++++++++++++++++ tests/test-images.cjs | 85 +----- tests/test-interleaved-thinking.cjs | 121 +------- ...est-multiturn-thinking-tools-streaming.cjs | 145 +--------- tests/test-multiturn-thinking-tools.cjs | 77 +----- tests/test-thinking-signatures.cjs | 101 +------ 16 files changed, 641 insertions(+), 626 deletions(-) create mode 100644 src/errors.js create mode 100644 src/utils/helpers.js create mode 100644 tests/helpers/http-client.cjs diff --git a/CLAUDE.md b/CLAUDE.md index 24092f8..4d49c62 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ npm install npm start # Start with file watching for development -npm dev +npm run dev # Account management npm run accounts # Interactive account management @@ -46,13 +46,15 @@ Claude Code CLI → Express Server (server.js) → CloudCode Client → Antigrav **Key Modules:** -- **server.js**: Express server exposing Anthropic-compatible endpoints (`/v1/messages`, `/v1/models`, `/health`, `/accounts`) -- **cloudcode-client.js**: Makes requests to Antigravity Cloud Code API with retry/failover logic, handles both streaming and non-streaming -- **format-converter.js**: Bidirectional conversion between Anthropic and Google Generative AI formats, including thinking blocks and tool calls -- **account-manager.js**: Multi-account pool with round-robin rotation, rate limit handling, and automatic cooldown -- **oauth.js**: Google OAuth implementation for adding accounts -- **token-extractor.js**: Extracts tokens from local Antigravity app installation (legacy single-account mode) -- **constants.js**: API endpoints, model mappings, configuration values +- **src/server.js**: Express server exposing Anthropic-compatible endpoints (`/v1/messages`, `/v1/models`, `/health`, `/accounts`) +- **src/cloudcode-client.js**: Makes requests to Antigravity Cloud Code API with retry/failover logic, handles both streaming and non-streaming +- **src/format-converter.js**: Bidirectional conversion between Anthropic and Google Generative AI formats, including thinking blocks and tool calls +- **src/account-manager.js**: Multi-account pool with round-robin rotation, rate limit handling, and automatic cooldown +- **src/oauth.js**: Google OAuth implementation for adding accounts +- **src/token-extractor.js**: Extracts tokens from local Antigravity app installation (legacy single-account mode) +- **src/constants.js**: API endpoints, model mappings, OAuth config, and all configuration values +- **src/errors.js**: Custom error classes (`RateLimitError`, `AuthError`, `ApiError`, etc.) for structured error handling +- **src/utils/helpers.js**: Shared utility functions (`formatDuration`, `sleep`) **Multi-Account Load Balancing:** - Round-robin rotation across configured accounts @@ -64,4 +66,28 @@ Claude Code CLI → Express Server (server.js) → CloudCode Client → Antigrav - Tests require the server to be running (`npm start` in separate terminal) - Tests are CommonJS files (`.cjs`) that make HTTP requests to the local proxy +- Shared test utilities are in `tests/helpers/http-client.cjs` - Test runner supports filtering: `node tests/run-all.cjs ` to run matching tests + +## Code Organization + +**Constants:** All configuration values are centralized in `src/constants.js`: +- API endpoints and headers +- Model mappings +- OAuth configuration +- Rate limit thresholds +- Thinking model settings + +**Error Handling:** Use custom error classes from `src/errors.js`: +- `RateLimitError` - 429/RESOURCE_EXHAUSTED errors +- `AuthError` - Authentication failures +- `ApiError` - Upstream API errors +- Helper functions: `isRateLimitError()`, `isAuthError()` + +**Utilities:** Shared helpers in `src/utils/helpers.js`: +- `formatDuration(ms)` - Format milliseconds as "1h23m45s" +- `sleep(ms)` - Promise-based delay + +## Maintenance + +When making significant changes to the codebase (new modules, refactoring, architectural changes), update this CLAUDE.md file to keep documentation in sync. diff --git a/src/account-manager.js b/src/account-manager.js index 27f6bbb..b577b48 100644 --- a/src/account-manager.js +++ b/src/account-manager.js @@ -8,10 +8,9 @@ import { readFile, writeFile, mkdir, access } from 'fs/promises'; import { constants as fsConstants } from 'fs'; import { dirname } from 'path'; import { execSync } from 'child_process'; -import { homedir } from 'os'; -import { join } from 'path'; import { ACCOUNT_CONFIG_PATH, + ANTIGRAVITY_DB_PATH, DEFAULT_COOLDOWN_MS, TOKEN_REFRESH_INTERVAL_MS, ANTIGRAVITY_ENDPOINT_FALLBACKS, @@ -19,36 +18,7 @@ import { 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)); -} +import { formatDuration } from './utils/helpers.js'; export class AccountManager { #accounts = []; @@ -156,6 +126,7 @@ export class AccountManager { /** * Get the number of accounts + * @returns {number} Number of configured accounts */ getAccountCount() { return this.#accounts.length; @@ -163,6 +134,7 @@ export class AccountManager { /** * Check if all accounts are rate-limited + * @returns {boolean} True if all accounts are rate-limited */ isAllRateLimited() { if (this.#accounts.length === 0) return true; @@ -171,6 +143,7 @@ export class AccountManager { /** * Get list of available (non-rate-limited, non-invalid) accounts + * @returns {Array} Array of available account objects */ getAvailableAccounts() { return this.#accounts.filter(acc => !acc.isRateLimited && !acc.isInvalid); @@ -178,6 +151,7 @@ export class AccountManager { /** * Get list of invalid accounts + * @returns {Array} Array of invalid account objects */ getInvalidAccounts() { return this.#accounts.filter(acc => acc.isInvalid); @@ -185,6 +159,7 @@ export class AccountManager { /** * Clear expired rate limits + * @returns {number} Number of rate limits cleared */ clearExpiredLimits() { const now = Date.now(); @@ -209,6 +184,7 @@ export class AccountManager { /** * Clear all rate limits to force a fresh check * (Optimistic retry strategy) + * @returns {void} */ resetAllRateLimits() { for (const account of this.#accounts) { @@ -223,6 +199,7 @@ export class AccountManager { /** * Pick the next available account (round-robin) + * @returns {Object|null} The next available account or null if none available */ pickNext() { this.clearExpiredLimits(); @@ -254,6 +231,8 @@ export class AccountManager { /** * Mark an account as rate-limited + * @param {string} email - Email of the account to mark + * @param {number|null} resetMs - Time in ms until rate limit resets (optional) */ markRateLimited(email, resetMs = null) { const account = this.#accounts.find(a => a.email === email); @@ -272,6 +251,8 @@ export class AccountManager { /** * Mark an account as invalid (credentials need re-authentication) + * @param {string} email - Email of the account to mark + * @param {string} reason - Reason for marking as invalid */ markInvalid(email, reason = 'Unknown error') { const account = this.#accounts.find(a => a.email === email); @@ -296,6 +277,7 @@ export class AccountManager { /** * Get the minimum wait time until any account becomes available + * @returns {number} Wait time in milliseconds */ getMinWaitTimeMs() { if (!this.isAllRateLimited()) return 0; @@ -323,6 +305,9 @@ export class AccountManager { /** * Get OAuth token for an account + * @param {Object} account - Account object with email and credentials + * @returns {Promise} OAuth access token + * @throws {Error} If token refresh fails */ async getTokenForAccount(account) { // Check cache first @@ -372,6 +357,9 @@ export class AccountManager { /** * Get project ID for an account + * @param {Object} account - Account object + * @param {string} token - OAuth access token + * @returns {Promise} Project ID */ async getProjectForAccount(account, token) { // Check cache first @@ -435,6 +423,7 @@ export class AccountManager { /** * Clear project cache for an account (useful on auth errors) + * @param {string|null} email - Email to clear cache for, or null to clear all */ clearProjectCache(email = null) { if (email) { @@ -446,6 +435,7 @@ export class AccountManager { /** * Clear token cache for an account (useful on auth errors) + * @param {string|null} email - Email to clear cache for, or null to clear all */ clearTokenCache(email = null) { if (email) { @@ -457,6 +447,7 @@ export class AccountManager { /** * Save current state to disk (async) + * @returns {Promise} */ async saveToDisk() { try { @@ -491,6 +482,7 @@ export class AccountManager { /** * Get status object for logging/API + * @returns {{accounts: Array, settings: Object}} Status object with accounts and settings */ getStatus() { const available = this.getAvailableAccounts(); @@ -517,13 +509,11 @@ export class AccountManager { /** * Get settings + * @returns {Object} Current settings object */ 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 index 0c094bb..2dab955 100644 --- a/src/accounts-cli.js +++ b/src/accounts-cli.js @@ -19,7 +19,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; import { dirname } from 'path'; import { exec } from 'child_process'; import net from 'net'; -import { ACCOUNT_CONFIG_PATH, DEFAULT_PORT } from './constants.js'; +import { ACCOUNT_CONFIG_PATH, DEFAULT_PORT, MAX_ACCOUNTS } from './constants.js'; import { getAuthorizationUrl, startCallbackServer, @@ -28,7 +28,6 @@ import { getUserEmail } from './oauth.js'; -const MAX_ACCOUNTS = 10; const SERVER_PORT = process.env.PORT || DEFAULT_PORT; /** diff --git a/src/cloudcode-client.js b/src/cloudcode-client.js index dd7e09b..e10cad8 100644 --- a/src/cloudcode-client.js +++ b/src/cloudcode-client.js @@ -23,27 +23,23 @@ import { convertAnthropicToGoogle, convertGoogleToAnthropic } from './format-converter.js'; -import { formatDuration, sleep } from './account-manager.js'; +import { formatDuration, sleep } from './utils/helpers.js'; +import { isRateLimitError, isAuthError } from './errors.js'; /** * Check if an error is a rate limit error (429 or RESOURCE_EXHAUSTED) + * @deprecated Use isRateLimitError from errors.js instead */ function is429Error(error) { - const msg = (error.message || '').toLowerCase(); - return msg.includes('429') || - msg.includes('resource_exhausted') || - msg.includes('quota_exhausted') || - msg.includes('rate limit'); + return isRateLimitError(error); } /** * Check if an error is an auth-invalid error (credentials need re-authentication) + * @deprecated Use isAuthError from errors.js instead */ function isAuthInvalidError(error) { - const msg = (error.message || '').toUpperCase(); - return msg.includes('AUTH_INVALID') || - msg.includes('INVALID_GRANT') || - msg.includes('TOKEN REFRESH FAILED'); + return isAuthError(error); } /** @@ -230,7 +226,13 @@ function buildHeaders(token, model, accept = 'application/json') { * 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 + * @param {Object} anthropicRequest.model - Model name to use + * @param {Array} anthropicRequest.messages - Array of message objects + * @param {number} [anthropicRequest.max_tokens] - Maximum tokens to generate + * @param {Object} [anthropicRequest.thinking] - Thinking configuration + * @param {import('./account-manager.js').default} accountManager - The account manager instance + * @returns {Promise} Anthropic-format response object + * @throws {Error} If max retries exceeded or no accounts available */ export async function sendMessage(anthropicRequest, accountManager) { const model = mapModelName(anthropicRequest.model); @@ -479,7 +481,13 @@ async function parseThinkingSSEResponse(response, originalModel) { * 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 + * @param {string} anthropicRequest.model - Model name to use + * @param {Array} anthropicRequest.messages - Array of message objects + * @param {number} [anthropicRequest.max_tokens] - Maximum tokens to generate + * @param {Object} [anthropicRequest.thinking] - Thinking configuration + * @param {import('./account-manager.js').default} accountManager - The account manager instance + * @yields {Object} Anthropic-format SSE events (message_start, content_block_start, content_block_delta, etc.) + * @throws {Error} If max retries exceeded or no accounts available */ export async function* sendMessageStream(anthropicRequest, accountManager) { const model = mapModelName(anthropicRequest.model); @@ -849,7 +857,9 @@ async function* streamSSEResponse(response, originalModel) { } /** - * List available models + * List available models in Anthropic API format + * + * @returns {{object: string, data: Array<{id: string, object: string, created: number, owned_by: string, description: string}>}} List of available models */ export function listModels() { return { diff --git a/src/constants.js b/src/constants.js index 96a82aa..b5ac394 100644 --- a/src/constants.js +++ b/src/constants.js @@ -78,8 +78,16 @@ export const ACCOUNT_CONFIG_PATH = join( homedir(), '.config/antigravity-proxy/accounts.json' ); + +// Antigravity app database path (for legacy single-account token extraction) +export const ANTIGRAVITY_DB_PATH = join( + homedir(), + 'Library/Application Support/Antigravity/User/globalStorage/state.vscdb' +); + export const DEFAULT_COOLDOWN_MS = 60 * 1000; // 1 minute default cooldown export const MAX_RETRIES = 5; // Max retry attempts across accounts +export const MAX_ACCOUNTS = 10; // Maximum number of accounts allowed // Rate limit wait thresholds export const MAX_WAIT_BEFORE_ERROR_MS = 120000; // 2 minutes - throw error if wait exceeds this @@ -89,6 +97,24 @@ export const DEFAULT_THINKING_BUDGET = 16000; // Default thinking budget tokens export const CLAUDE_THINKING_MAX_OUTPUT_TOKENS = 64000; // Max output tokens for thinking models export const MIN_SIGNATURE_LENGTH = 50; // Minimum valid thinking signature length +// Google OAuth configuration (from opencode-antigravity-auth) +export const OAUTH_CONFIG = { + clientId: '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com', + clientSecret: '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', + authUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + tokenUrl: 'https://oauth2.googleapis.com/token', + userInfoUrl: 'https://www.googleapis.com/oauth2/v1/userinfo', + callbackPort: 51121, + 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' + ] +}; +export const OAUTH_REDIRECT_URI = `http://localhost:${OAUTH_CONFIG.callbackPort}/oauth-callback`; + export default { ANTIGRAVITY_ENDPOINT_FALLBACKS, ANTIGRAVITY_HEADERS, @@ -100,10 +126,14 @@ export default { ANTIGRAVITY_AUTH_PORT, DEFAULT_PORT, ACCOUNT_CONFIG_PATH, + ANTIGRAVITY_DB_PATH, DEFAULT_COOLDOWN_MS, MAX_RETRIES, + MAX_ACCOUNTS, MAX_WAIT_BEFORE_ERROR_MS, DEFAULT_THINKING_BUDGET, CLAUDE_THINKING_MAX_OUTPUT_TOKENS, - MIN_SIGNATURE_LENGTH + MIN_SIGNATURE_LENGTH, + OAUTH_CONFIG, + OAUTH_REDIRECT_URI }; diff --git a/src/errors.js b/src/errors.js new file mode 100644 index 0000000..43f8abc --- /dev/null +++ b/src/errors.js @@ -0,0 +1,159 @@ +/** + * Custom Error Classes + * + * Provides structured error types for better error handling and classification. + * Replaces string-based error detection with proper error class checking. + */ + +/** + * Base error class for Antigravity proxy errors + */ +export class AntigravityError extends Error { + /** + * @param {string} message - Error message + * @param {string} code - Error code for programmatic handling + * @param {boolean} retryable - Whether the error is retryable + * @param {Object} metadata - Additional error metadata + */ + constructor(message, code, retryable = false, metadata = {}) { + super(message); + this.name = 'AntigravityError'; + this.code = code; + this.retryable = retryable; + this.metadata = metadata; + } + + /** + * Convert to JSON for API responses + */ + toJSON() { + return { + name: this.name, + code: this.code, + message: this.message, + retryable: this.retryable, + ...this.metadata + }; + } +} + +/** + * Rate limit error (429 / RESOURCE_EXHAUSTED) + */ +export class RateLimitError extends AntigravityError { + /** + * @param {string} message - Error message + * @param {number|null} resetMs - Time in ms until rate limit resets + * @param {string} accountEmail - Email of the rate-limited account + */ + constructor(message, resetMs = null, accountEmail = null) { + super(message, 'RATE_LIMITED', true, { resetMs, accountEmail }); + this.name = 'RateLimitError'; + this.resetMs = resetMs; + this.accountEmail = accountEmail; + } +} + +/** + * Authentication error (invalid credentials, token expired, etc.) + */ +export class AuthError extends AntigravityError { + /** + * @param {string} message - Error message + * @param {string} accountEmail - Email of the account with auth issues + * @param {string} reason - Specific reason for auth failure + */ + constructor(message, accountEmail = null, reason = null) { + super(message, 'AUTH_INVALID', false, { accountEmail, reason }); + this.name = 'AuthError'; + this.accountEmail = accountEmail; + this.reason = reason; + } +} + +/** + * No accounts available error + */ +export class NoAccountsError extends AntigravityError { + /** + * @param {string} message - Error message + * @param {boolean} allRateLimited - Whether all accounts are rate limited + */ + constructor(message = 'No accounts available', allRateLimited = false) { + super(message, 'NO_ACCOUNTS', allRateLimited, { allRateLimited }); + this.name = 'NoAccountsError'; + this.allRateLimited = allRateLimited; + } +} + +/** + * Max retries exceeded error + */ +export class MaxRetriesError extends AntigravityError { + /** + * @param {string} message - Error message + * @param {number} attempts - Number of attempts made + */ + constructor(message = 'Max retries exceeded', attempts = 0) { + super(message, 'MAX_RETRIES', false, { attempts }); + this.name = 'MaxRetriesError'; + this.attempts = attempts; + } +} + +/** + * API error from upstream service + */ +export class ApiError extends AntigravityError { + /** + * @param {string} message - Error message + * @param {number} statusCode - HTTP status code + * @param {string} errorType - Type of API error + */ + constructor(message, statusCode = 500, errorType = 'api_error') { + super(message, errorType.toUpperCase(), statusCode >= 500, { statusCode, errorType }); + this.name = 'ApiError'; + this.statusCode = statusCode; + this.errorType = errorType; + } +} + +/** + * Check if an error is a rate limit error + * Works with both custom error classes and legacy string-based errors + * @param {Error} error - Error to check + * @returns {boolean} + */ +export function isRateLimitError(error) { + if (error instanceof RateLimitError) return true; + const msg = (error.message || '').toLowerCase(); + return msg.includes('429') || + msg.includes('resource_exhausted') || + msg.includes('quota_exhausted') || + msg.includes('rate limit'); +} + +/** + * Check if an error is an authentication error + * Works with both custom error classes and legacy string-based errors + * @param {Error} error - Error to check + * @returns {boolean} + */ +export function isAuthError(error) { + if (error instanceof AuthError) return true; + const msg = (error.message || '').toUpperCase(); + return msg.includes('AUTH_INVALID') || + msg.includes('INVALID_GRANT') || + msg.includes('TOKEN REFRESH FAILED'); +} + +export default { + AntigravityError, + RateLimitError, + AuthError, + NoAccountsError, + MaxRetriesError, + ApiError, + isRateLimitError, + isAuthError +}; diff --git a/src/format-converter.js b/src/format-converter.js index 2e7283e..f931f3e 100644 --- a/src/format-converter.js +++ b/src/format-converter.js @@ -17,6 +17,8 @@ import { /** * Map Anthropic model name to Antigravity model name + * @param {string} anthropicModel - Anthropic format model name (e.g., 'claude-3-5-sonnet-20241022') + * @returns {string} Antigravity format model name (e.g., 'claude-sonnet-4-5') */ export function mapModelName(anthropicModel) { return MODEL_MAPPINGS[anthropicModel] || anthropicModel; @@ -24,6 +26,8 @@ export function mapModelName(anthropicModel) { /** * Check if a part is a thinking block + * @param {Object} part - Content part to check + * @returns {boolean} True if the part is a thinking block */ function isThinkingPart(part) { return part.type === 'thinking' || @@ -96,6 +100,9 @@ function filterContentArray(contentArray) { /** * Filter unsigned thinking blocks from contents (Gemini format) + * + * @param {Array<{role: string, parts: Array}>} contents - Array of content objects in Gemini format + * @returns {Array<{role: string, parts: Array}>} Filtered contents with unsigned thinking blocks removed */ export function filterUnsignedThinkingBlocks(contents) { return contents.map(content => { @@ -113,6 +120,9 @@ export function filterUnsignedThinkingBlocks(contents) { * 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. + * + * @param {Array} content - Array of content blocks + * @returns {Array} Content array with trailing unsigned thinking blocks removed */ export function removeTrailingThinkingBlocks(content) { if (!Array.isArray(content)) return content; @@ -174,6 +184,9 @@ function sanitizeAnthropicThinkingBlock(block) { * Filter thinking blocks: keep only those with valid signatures. * Blocks without signatures are dropped (API requires signatures). * Also sanitizes blocks to remove extra fields like cache_control. + * + * @param {Array} content - Array of content blocks + * @returns {Array} Filtered content with only valid signed thinking blocks */ export function restoreThinkingSignatures(content) { if (!Array.isArray(content)) return content; @@ -208,6 +221,9 @@ export function restoreThinkingSignatures(content) { * 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. + * + * @param {Array} content - Array of content blocks + * @returns {Array} Reordered content array */ export function reorderAssistantContent(content) { if (!Array.isArray(content)) return content; diff --git a/src/oauth.js b/src/oauth.js index fde6bc8..060ae3a 100644 --- a/src/oauth.js +++ b/src/oauth.js @@ -8,27 +8,11 @@ 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(' '); +import { + ANTIGRAVITY_ENDPOINT_FALLBACKS, + ANTIGRAVITY_HEADERS, + OAUTH_CONFIG +} from './constants.js'; /** * Generate PKCE code verifier and challenge @@ -45,16 +29,18 @@ function generatePKCE() { /** * Generate authorization URL for Google OAuth * Returns the URL and the PKCE verifier (needed for token exchange) + * + * @returns {{url: string, verifier: string, state: string}} Auth URL and PKCE data */ 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, + client_id: OAUTH_CONFIG.clientId, + redirect_uri: OAUTH_REDIRECT_URI, response_type: 'code', - scope: SCOPES, + scope: OAUTH_CONFIG.scopes.join(' '), access_type: 'offline', prompt: 'consent', code_challenge: challenge, @@ -63,7 +49,7 @@ export function getAuthorizationUrl() { }); return { - url: `${GOOGLE_AUTH_URL}?${params.toString()}`, + url: `${OAUTH_CONFIG.authUrl}?${params.toString()}`, verifier, state }; @@ -72,11 +58,15 @@ export function getAuthorizationUrl() { /** * Start a local server to receive the OAuth callback * Returns a promise that resolves with the authorization code + * + * @param {string} expectedState - Expected state parameter for CSRF protection + * @param {number} timeoutMs - Timeout in milliseconds (default 120000) + * @returns {Promise} Authorization code from OAuth callback */ 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}`); + const url = new URL(req.url, `http://localhost:${OAUTH_CONFIG.callbackPort}`); if (url.pathname !== '/oauth-callback') { res.writeHead(404); @@ -158,14 +148,14 @@ export function startCallbackServer(expectedState, timeoutMs = 120000) { 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.`)); + reject(new Error(`Port ${OAUTH_CONFIG.callbackPort} 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}`); + server.listen(OAUTH_CONFIG.callbackPort, () => { + console.log(`[OAuth] Callback server listening on port ${OAUTH_CONFIG.callbackPort}`); }); // Timeout after specified duration @@ -178,20 +168,24 @@ export function startCallbackServer(expectedState, timeoutMs = 120000) { /** * Exchange authorization code for tokens + * + * @param {string} code - Authorization code from OAuth callback + * @param {string} verifier - PKCE code verifier + * @returns {Promise<{accessToken: string, refreshToken: string, expiresIn: number}>} OAuth tokens */ export async function exchangeCode(code, verifier) { - const response = await fetch(GOOGLE_TOKEN_URL, { + const response = await fetch(OAUTH_CONFIG.tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ - client_id: GOOGLE_CLIENT_ID, - client_secret: GOOGLE_CLIENT_SECRET, + client_id: OAUTH_CONFIG.clientId, + client_secret: OAUTH_CONFIG.clientSecret, code: code, code_verifier: verifier, grant_type: 'authorization_code', - redirect_uri: REDIRECT_URI + redirect_uri: OAUTH_REDIRECT_URI }) }); @@ -219,16 +213,19 @@ export async function exchangeCode(code, verifier) { /** * Refresh access token using refresh token + * + * @param {string} refreshToken - OAuth refresh token + * @returns {Promise<{accessToken: string, expiresIn: number}>} New access token */ export async function refreshAccessToken(refreshToken) { - const response = await fetch(GOOGLE_TOKEN_URL, { + const response = await fetch(OAUTH_CONFIG.tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ - client_id: GOOGLE_CLIENT_ID, - client_secret: GOOGLE_CLIENT_SECRET, + client_id: OAUTH_CONFIG.clientId, + client_secret: OAUTH_CONFIG.clientSecret, refresh_token: refreshToken, grant_type: 'refresh_token' }) @@ -248,9 +245,12 @@ export async function refreshAccessToken(refreshToken) { /** * Get user email from access token + * + * @param {string} accessToken - OAuth access token + * @returns {Promise} User's email address */ export async function getUserEmail(accessToken) { - const response = await fetch(GOOGLE_USERINFO_URL, { + const response = await fetch(OAUTH_CONFIG.userInfoUrl, { headers: { 'Authorization': `Bearer ${accessToken}` } @@ -268,6 +268,9 @@ export async function getUserEmail(accessToken) { /** * Discover project ID for the authenticated user + * + * @param {string} accessToken - OAuth access token + * @returns {Promise} Project ID or null if not found */ export async function discoverProjectId(accessToken) { for (const endpoint of ANTIGRAVITY_ENDPOINT_FALLBACKS) { @@ -308,6 +311,10 @@ export async function discoverProjectId(accessToken) { /** * Complete OAuth flow: exchange code and get all account info + * + * @param {string} code - Authorization code from OAuth callback + * @param {string} verifier - PKCE code verifier + * @returns {Promise<{email: string, refreshToken: string, accessToken: string, projectId: string|null}>} Complete account info */ export async function completeOAuthFlow(code, verifier) { // Exchange code for tokens diff --git a/src/token-extractor.js b/src/token-extractor.js index 0405bac..a192edc 100644 --- a/src/token-extractor.js +++ b/src/token-extractor.js @@ -1,26 +1,22 @@ /** * Token Extractor Module * Extracts OAuth tokens from Antigravity's SQLite database - * + * * The database is automatically updated by Antigravity when tokens refresh, * so this approach doesn't require any manual intervention. */ import { execSync } from 'child_process'; -import { homedir } from 'os'; -import { join } from 'path'; -import { TOKEN_REFRESH_INTERVAL_MS, ANTIGRAVITY_AUTH_PORT } from './constants.js'; +import { + TOKEN_REFRESH_INTERVAL_MS, + ANTIGRAVITY_AUTH_PORT, + ANTIGRAVITY_DB_PATH +} from './constants.js'; // Cache for the extracted token let cachedToken = null; let tokenExtractedAt = null; -// Antigravity's SQLite database path -const ANTIGRAVITY_DB_PATH = join( - homedir(), - 'Library/Application Support/Antigravity/User/globalStorage/state.vscdb' -); - /** * Extract token from Antigravity's SQLite database * This is the preferred method as the DB is auto-updated diff --git a/src/utils/helpers.js b/src/utils/helpers.js new file mode 100644 index 0000000..0279253 --- /dev/null +++ b/src/utils/helpers.js @@ -0,0 +1,33 @@ +/** + * Shared Utility Functions + * + * General-purpose helper functions used across multiple modules. + */ + +/** + * Format duration in milliseconds to human-readable string + * @param {number} ms - Duration in milliseconds + * @returns {string} Human-readable duration (e.g., "1h23m45s") + */ +export 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 + * @param {number} ms - Duration to sleep in milliseconds + * @returns {Promise} Resolves after the specified duration + */ +export function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/tests/helpers/http-client.cjs b/tests/helpers/http-client.cjs new file mode 100644 index 0000000..41b9877 --- /dev/null +++ b/tests/helpers/http-client.cjs @@ -0,0 +1,260 @@ +/** + * Shared Test HTTP Client Utilities + * + * Provides common HTTP request functions for integration tests. + * Eliminates code duplication across test files. + */ +const http = require('http'); + +// Server configuration +const BASE_URL = 'localhost'; +const PORT = 8080; + +/** + * Make a streaming SSE request to the API + * @param {Object} body - Request body + * @returns {Promise<{content: Array, events: Array, statusCode: number, raw: string}>} + */ +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(); + }); +} + +/** + * Make a non-streaming JSON request to the API + * @param {Object} body - Request body + * @returns {Promise} - Parsed JSON response with statusCode + */ +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(); + }); +} + +/** + * Analyze content blocks from a response + * @param {Array} content - Array of content blocks + * @returns {Object} - Analysis results + */ +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) + }; +} + +/** + * Analyze SSE events from a streaming response + * @param {Array} events - Array of SSE events + * @returns {Object} - Event counts by type + */ +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 + }; +} + +// Common tool definitions for tests +const commonTools = { + getWeather: { + name: 'get_weather', + description: 'Get the current weather for a location', + input_schema: { + type: 'object', + properties: { + location: { type: 'string', description: 'City name' } + }, + required: ['location'] + } + }, + searchFiles: { + 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'] + } + }, + readFile: { + name: 'read_file', + description: 'Read contents of a file', + input_schema: { + type: 'object', + properties: { + path: { type: 'string', description: 'Path to file' } + }, + required: ['path'] + } + }, + executeCommand: { + 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'] + } + }, + writeFile: { + name: 'write_file', + description: 'Write to a file', + input_schema: { + type: 'object', + properties: { + path: { type: 'string' }, + content: { type: 'string' } + }, + required: ['path', 'content'] + } + }, + runTests: { + name: 'run_tests', + description: 'Run test suite', + input_schema: { + type: 'object', + properties: { pattern: { type: 'string' } }, + required: ['pattern'] + } + } +}; + +module.exports = { + BASE_URL, + PORT, + streamRequest, + makeRequest, + analyzeContent, + analyzeEvents, + commonTools +}; diff --git a/tests/test-images.cjs b/tests/test-images.cjs index 8a7dbdb..8dcb6d2 100644 --- a/tests/test-images.cjs +++ b/tests/test-images.cjs @@ -4,97 +4,14 @@ * 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; +const { streamRequest } = require('./helpers/http-client.cjs'); // 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'); diff --git a/tests/test-interleaved-thinking.cjs b/tests/test-interleaved-thinking.cjs index 209d4f8..66cf92a 100644 --- a/tests/test-interleaved-thinking.cjs +++ b/tests/test-interleaved-thinking.cjs @@ -9,127 +9,10 @@ * 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(); - }); -} +const { streamRequest, commonTools } = require('./helpers/http-client.cjs'); // 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'] - } -}]; +const tools = [commonTools.readFile, commonTools.writeFile, commonTools.runTests]; async function runTests() { console.log('='.repeat(60)); diff --git a/tests/test-multiturn-thinking-tools-streaming.cjs b/tests/test-multiturn-thinking-tools-streaming.cjs index 0035ccc..61a7249 100644 --- a/tests/test-multiturn-thinking-tools-streaming.cjs +++ b/tests/test-multiturn-thinking-tools-streaming.cjs @@ -7,150 +7,9 @@ * - signature_delta events are present * - Thinking blocks accumulate correctly across deltas */ -const http = require('http'); +const { streamRequest, analyzeContent, analyzeEvents, commonTools } = require('./helpers/http-client.cjs'); -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 - }; -} +const tools = [commonTools.executeCommand]; async function runTests() { console.log('='.repeat(60)); diff --git a/tests/test-multiturn-thinking-tools.cjs b/tests/test-multiturn-thinking-tools.cjs index 258fdf0..a2880be 100644 --- a/tests/test-multiturn-thinking-tools.cjs +++ b/tests/test-multiturn-thinking-tools.cjs @@ -12,82 +12,9 @@ * - Tool use/result flow works correctly * - Interleaved thinking with tools */ -const http = require('http'); +const { makeRequest, analyzeContent, commonTools } = require('./helpers/http-client.cjs'); -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) - }; -} +const tools = [commonTools.searchFiles, commonTools.readFile]; async function runTests() { console.log('='.repeat(60)); diff --git a/tests/test-thinking-signatures.cjs b/tests/test-thinking-signatures.cjs index 28e6d02..1adc27d 100644 --- a/tests/test-thinking-signatures.cjs +++ b/tests/test-thinking-signatures.cjs @@ -7,106 +7,9 @@ * 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 { streamRequest, commonTools } = require('./helpers/http-client.cjs'); -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'] - } -}]; +const tools = [commonTools.getWeather]; async function runTests() { console.log('='.repeat(60));