refactor: centralize constants, add error classes, and DRY test utilities
- 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 <noreply@anthropic.com>
This commit is contained in:
42
CLAUDE.md
42
CLAUDE.md
@@ -18,7 +18,7 @@ npm install
|
|||||||
npm start
|
npm start
|
||||||
|
|
||||||
# Start with file watching for development
|
# Start with file watching for development
|
||||||
npm dev
|
npm run dev
|
||||||
|
|
||||||
# Account management
|
# Account management
|
||||||
npm run accounts # Interactive account management
|
npm run accounts # Interactive account management
|
||||||
@@ -46,13 +46,15 @@ Claude Code CLI → Express Server (server.js) → CloudCode Client → Antigrav
|
|||||||
|
|
||||||
**Key Modules:**
|
**Key Modules:**
|
||||||
|
|
||||||
- **server.js**: Express server exposing Anthropic-compatible endpoints (`/v1/messages`, `/v1/models`, `/health`, `/accounts`)
|
- **src/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
|
- **src/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
|
- **src/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
|
- **src/account-manager.js**: Multi-account pool with round-robin rotation, rate limit handling, and automatic cooldown
|
||||||
- **oauth.js**: Google OAuth implementation for adding accounts
|
- **src/oauth.js**: Google OAuth implementation for adding accounts
|
||||||
- **token-extractor.js**: Extracts tokens from local Antigravity app installation (legacy single-account mode)
|
- **src/token-extractor.js**: Extracts tokens from local Antigravity app installation (legacy single-account mode)
|
||||||
- **constants.js**: API endpoints, model mappings, configuration values
|
- **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:**
|
**Multi-Account Load Balancing:**
|
||||||
- Round-robin rotation across configured accounts
|
- 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 require the server to be running (`npm start` in separate terminal)
|
||||||
- Tests are CommonJS files (`.cjs`) that make HTTP requests to the local proxy
|
- 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 <filter>` to run matching tests
|
- Test runner supports filtering: `node tests/run-all.cjs <filter>` 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.
|
||||||
|
|||||||
@@ -8,10 +8,9 @@ import { readFile, writeFile, mkdir, access } from 'fs/promises';
|
|||||||
import { constants as fsConstants } from 'fs';
|
import { constants as fsConstants } from 'fs';
|
||||||
import { dirname } from 'path';
|
import { dirname } from 'path';
|
||||||
import { execSync } from 'child_process';
|
import { execSync } from 'child_process';
|
||||||
import { homedir } from 'os';
|
|
||||||
import { join } from 'path';
|
|
||||||
import {
|
import {
|
||||||
ACCOUNT_CONFIG_PATH,
|
ACCOUNT_CONFIG_PATH,
|
||||||
|
ANTIGRAVITY_DB_PATH,
|
||||||
DEFAULT_COOLDOWN_MS,
|
DEFAULT_COOLDOWN_MS,
|
||||||
TOKEN_REFRESH_INTERVAL_MS,
|
TOKEN_REFRESH_INTERVAL_MS,
|
||||||
ANTIGRAVITY_ENDPOINT_FALLBACKS,
|
ANTIGRAVITY_ENDPOINT_FALLBACKS,
|
||||||
@@ -19,36 +18,7 @@ import {
|
|||||||
DEFAULT_PROJECT_ID
|
DEFAULT_PROJECT_ID
|
||||||
} from './constants.js';
|
} from './constants.js';
|
||||||
import { refreshAccessToken } from './oauth.js';
|
import { refreshAccessToken } from './oauth.js';
|
||||||
|
import { formatDuration } from './utils/helpers.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 {
|
export class AccountManager {
|
||||||
#accounts = [];
|
#accounts = [];
|
||||||
@@ -156,6 +126,7 @@ export class AccountManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the number of accounts
|
* Get the number of accounts
|
||||||
|
* @returns {number} Number of configured accounts
|
||||||
*/
|
*/
|
||||||
getAccountCount() {
|
getAccountCount() {
|
||||||
return this.#accounts.length;
|
return this.#accounts.length;
|
||||||
@@ -163,6 +134,7 @@ export class AccountManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if all accounts are rate-limited
|
* Check if all accounts are rate-limited
|
||||||
|
* @returns {boolean} True if all accounts are rate-limited
|
||||||
*/
|
*/
|
||||||
isAllRateLimited() {
|
isAllRateLimited() {
|
||||||
if (this.#accounts.length === 0) return true;
|
if (this.#accounts.length === 0) return true;
|
||||||
@@ -171,6 +143,7 @@ export class AccountManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get list of available (non-rate-limited, non-invalid) accounts
|
* Get list of available (non-rate-limited, non-invalid) accounts
|
||||||
|
* @returns {Array<Object>} Array of available account objects
|
||||||
*/
|
*/
|
||||||
getAvailableAccounts() {
|
getAvailableAccounts() {
|
||||||
return this.#accounts.filter(acc => !acc.isRateLimited && !acc.isInvalid);
|
return this.#accounts.filter(acc => !acc.isRateLimited && !acc.isInvalid);
|
||||||
@@ -178,6 +151,7 @@ export class AccountManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get list of invalid accounts
|
* Get list of invalid accounts
|
||||||
|
* @returns {Array<Object>} Array of invalid account objects
|
||||||
*/
|
*/
|
||||||
getInvalidAccounts() {
|
getInvalidAccounts() {
|
||||||
return this.#accounts.filter(acc => acc.isInvalid);
|
return this.#accounts.filter(acc => acc.isInvalid);
|
||||||
@@ -185,6 +159,7 @@ export class AccountManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear expired rate limits
|
* Clear expired rate limits
|
||||||
|
* @returns {number} Number of rate limits cleared
|
||||||
*/
|
*/
|
||||||
clearExpiredLimits() {
|
clearExpiredLimits() {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -209,6 +184,7 @@ export class AccountManager {
|
|||||||
/**
|
/**
|
||||||
* Clear all rate limits to force a fresh check
|
* Clear all rate limits to force a fresh check
|
||||||
* (Optimistic retry strategy)
|
* (Optimistic retry strategy)
|
||||||
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
resetAllRateLimits() {
|
resetAllRateLimits() {
|
||||||
for (const account of this.#accounts) {
|
for (const account of this.#accounts) {
|
||||||
@@ -223,6 +199,7 @@ export class AccountManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Pick the next available account (round-robin)
|
* Pick the next available account (round-robin)
|
||||||
|
* @returns {Object|null} The next available account or null if none available
|
||||||
*/
|
*/
|
||||||
pickNext() {
|
pickNext() {
|
||||||
this.clearExpiredLimits();
|
this.clearExpiredLimits();
|
||||||
@@ -254,6 +231,8 @@ export class AccountManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark an account as rate-limited
|
* 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) {
|
markRateLimited(email, resetMs = null) {
|
||||||
const account = this.#accounts.find(a => a.email === email);
|
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)
|
* 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') {
|
markInvalid(email, reason = 'Unknown error') {
|
||||||
const account = this.#accounts.find(a => a.email === email);
|
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
|
* Get the minimum wait time until any account becomes available
|
||||||
|
* @returns {number} Wait time in milliseconds
|
||||||
*/
|
*/
|
||||||
getMinWaitTimeMs() {
|
getMinWaitTimeMs() {
|
||||||
if (!this.isAllRateLimited()) return 0;
|
if (!this.isAllRateLimited()) return 0;
|
||||||
@@ -323,6 +305,9 @@ export class AccountManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get OAuth token for an account
|
* Get OAuth token for an account
|
||||||
|
* @param {Object} account - Account object with email and credentials
|
||||||
|
* @returns {Promise<string>} OAuth access token
|
||||||
|
* @throws {Error} If token refresh fails
|
||||||
*/
|
*/
|
||||||
async getTokenForAccount(account) {
|
async getTokenForAccount(account) {
|
||||||
// Check cache first
|
// Check cache first
|
||||||
@@ -372,6 +357,9 @@ export class AccountManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get project ID for an account
|
* Get project ID for an account
|
||||||
|
* @param {Object} account - Account object
|
||||||
|
* @param {string} token - OAuth access token
|
||||||
|
* @returns {Promise<string>} Project ID
|
||||||
*/
|
*/
|
||||||
async getProjectForAccount(account, token) {
|
async getProjectForAccount(account, token) {
|
||||||
// Check cache first
|
// Check cache first
|
||||||
@@ -435,6 +423,7 @@ export class AccountManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear project cache for an account (useful on auth errors)
|
* 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) {
|
clearProjectCache(email = null) {
|
||||||
if (email) {
|
if (email) {
|
||||||
@@ -446,6 +435,7 @@ export class AccountManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear token cache for an account (useful on auth errors)
|
* 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) {
|
clearTokenCache(email = null) {
|
||||||
if (email) {
|
if (email) {
|
||||||
@@ -457,6 +447,7 @@ export class AccountManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Save current state to disk (async)
|
* Save current state to disk (async)
|
||||||
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async saveToDisk() {
|
async saveToDisk() {
|
||||||
try {
|
try {
|
||||||
@@ -491,6 +482,7 @@ export class AccountManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get status object for logging/API
|
* Get status object for logging/API
|
||||||
|
* @returns {{accounts: Array, settings: Object}} Status object with accounts and settings
|
||||||
*/
|
*/
|
||||||
getStatus() {
|
getStatus() {
|
||||||
const available = this.getAvailableAccounts();
|
const available = this.getAvailableAccounts();
|
||||||
@@ -517,13 +509,11 @@ export class AccountManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get settings
|
* Get settings
|
||||||
|
* @returns {Object} Current settings object
|
||||||
*/
|
*/
|
||||||
getSettings() {
|
getSettings() {
|
||||||
return { ...this.#settings };
|
return { ...this.#settings };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export helper functions
|
|
||||||
export { formatDuration, sleep };
|
|
||||||
|
|
||||||
export default AccountManager;
|
export default AccountManager;
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|||||||
import { dirname } from 'path';
|
import { dirname } from 'path';
|
||||||
import { exec } from 'child_process';
|
import { exec } from 'child_process';
|
||||||
import net from 'net';
|
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 {
|
import {
|
||||||
getAuthorizationUrl,
|
getAuthorizationUrl,
|
||||||
startCallbackServer,
|
startCallbackServer,
|
||||||
@@ -28,7 +28,6 @@ import {
|
|||||||
getUserEmail
|
getUserEmail
|
||||||
} from './oauth.js';
|
} from './oauth.js';
|
||||||
|
|
||||||
const MAX_ACCOUNTS = 10;
|
|
||||||
const SERVER_PORT = process.env.PORT || DEFAULT_PORT;
|
const SERVER_PORT = process.env.PORT || DEFAULT_PORT;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -23,27 +23,23 @@ import {
|
|||||||
convertAnthropicToGoogle,
|
convertAnthropicToGoogle,
|
||||||
convertGoogleToAnthropic
|
convertGoogleToAnthropic
|
||||||
} from './format-converter.js';
|
} 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)
|
* Check if an error is a rate limit error (429 or RESOURCE_EXHAUSTED)
|
||||||
|
* @deprecated Use isRateLimitError from errors.js instead
|
||||||
*/
|
*/
|
||||||
function is429Error(error) {
|
function is429Error(error) {
|
||||||
const msg = (error.message || '').toLowerCase();
|
return isRateLimitError(error);
|
||||||
return msg.includes('429') ||
|
|
||||||
msg.includes('resource_exhausted') ||
|
|
||||||
msg.includes('quota_exhausted') ||
|
|
||||||
msg.includes('rate limit');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if an error is an auth-invalid error (credentials need re-authentication)
|
* Check if an error is an auth-invalid error (credentials need re-authentication)
|
||||||
|
* @deprecated Use isAuthError from errors.js instead
|
||||||
*/
|
*/
|
||||||
function isAuthInvalidError(error) {
|
function isAuthInvalidError(error) {
|
||||||
const msg = (error.message || '').toUpperCase();
|
return isAuthError(error);
|
||||||
return msg.includes('AUTH_INVALID') ||
|
|
||||||
msg.includes('INVALID_GRANT') ||
|
|
||||||
msg.includes('TOKEN REFRESH FAILED');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -230,7 +226,13 @@ function buildHeaders(token, model, accept = 'application/json') {
|
|||||||
* Uses SSE endpoint for thinking models (non-streaming doesn't return thinking blocks)
|
* Uses SSE endpoint for thinking models (non-streaming doesn't return thinking blocks)
|
||||||
*
|
*
|
||||||
* @param {Object} anthropicRequest - The Anthropic-format request
|
* @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<Object>} Anthropic-format response object
|
||||||
|
* @throws {Error} If max retries exceeded or no accounts available
|
||||||
*/
|
*/
|
||||||
export async function sendMessage(anthropicRequest, accountManager) {
|
export async function sendMessage(anthropicRequest, accountManager) {
|
||||||
const model = mapModelName(anthropicRequest.model);
|
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
|
* Streams events in real-time as they arrive from the server
|
||||||
*
|
*
|
||||||
* @param {Object} anthropicRequest - The Anthropic-format request
|
* @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) {
|
export async function* sendMessageStream(anthropicRequest, accountManager) {
|
||||||
const model = mapModelName(anthropicRequest.model);
|
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() {
|
export function listModels() {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -78,8 +78,16 @@ export const ACCOUNT_CONFIG_PATH = join(
|
|||||||
homedir(),
|
homedir(),
|
||||||
'.config/antigravity-proxy/accounts.json'
|
'.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 DEFAULT_COOLDOWN_MS = 60 * 1000; // 1 minute default cooldown
|
||||||
export const MAX_RETRIES = 5; // Max retry attempts across accounts
|
export const MAX_RETRIES = 5; // Max retry attempts across accounts
|
||||||
|
export const MAX_ACCOUNTS = 10; // Maximum number of accounts allowed
|
||||||
|
|
||||||
// Rate limit wait thresholds
|
// Rate limit wait thresholds
|
||||||
export const MAX_WAIT_BEFORE_ERROR_MS = 120000; // 2 minutes - throw error if wait exceeds this
|
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 CLAUDE_THINKING_MAX_OUTPUT_TOKENS = 64000; // Max output tokens for thinking models
|
||||||
export const MIN_SIGNATURE_LENGTH = 50; // Minimum valid thinking signature length
|
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 {
|
export default {
|
||||||
ANTIGRAVITY_ENDPOINT_FALLBACKS,
|
ANTIGRAVITY_ENDPOINT_FALLBACKS,
|
||||||
ANTIGRAVITY_HEADERS,
|
ANTIGRAVITY_HEADERS,
|
||||||
@@ -100,10 +126,14 @@ export default {
|
|||||||
ANTIGRAVITY_AUTH_PORT,
|
ANTIGRAVITY_AUTH_PORT,
|
||||||
DEFAULT_PORT,
|
DEFAULT_PORT,
|
||||||
ACCOUNT_CONFIG_PATH,
|
ACCOUNT_CONFIG_PATH,
|
||||||
|
ANTIGRAVITY_DB_PATH,
|
||||||
DEFAULT_COOLDOWN_MS,
|
DEFAULT_COOLDOWN_MS,
|
||||||
MAX_RETRIES,
|
MAX_RETRIES,
|
||||||
|
MAX_ACCOUNTS,
|
||||||
MAX_WAIT_BEFORE_ERROR_MS,
|
MAX_WAIT_BEFORE_ERROR_MS,
|
||||||
DEFAULT_THINKING_BUDGET,
|
DEFAULT_THINKING_BUDGET,
|
||||||
CLAUDE_THINKING_MAX_OUTPUT_TOKENS,
|
CLAUDE_THINKING_MAX_OUTPUT_TOKENS,
|
||||||
MIN_SIGNATURE_LENGTH
|
MIN_SIGNATURE_LENGTH,
|
||||||
|
OAUTH_CONFIG,
|
||||||
|
OAUTH_REDIRECT_URI
|
||||||
};
|
};
|
||||||
|
|||||||
159
src/errors.js
Normal file
159
src/errors.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Map Anthropic model name to Antigravity model name
|
* 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) {
|
export function mapModelName(anthropicModel) {
|
||||||
return MODEL_MAPPINGS[anthropicModel] || anthropicModel;
|
return MODEL_MAPPINGS[anthropicModel] || anthropicModel;
|
||||||
@@ -24,6 +26,8 @@ export function mapModelName(anthropicModel) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a part is a thinking block
|
* 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) {
|
function isThinkingPart(part) {
|
||||||
return part.type === 'thinking' ||
|
return part.type === 'thinking' ||
|
||||||
@@ -96,6 +100,9 @@ function filterContentArray(contentArray) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter unsigned thinking blocks from contents (Gemini format)
|
* 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) {
|
export function filterUnsignedThinkingBlocks(contents) {
|
||||||
return contents.map(content => {
|
return contents.map(content => {
|
||||||
@@ -113,6 +120,9 @@ export function filterUnsignedThinkingBlocks(contents) {
|
|||||||
* Remove trailing unsigned thinking blocks from assistant messages.
|
* Remove trailing unsigned thinking blocks from assistant messages.
|
||||||
* Claude/Gemini APIs require that assistant messages don't end with unsigned thinking blocks.
|
* 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.
|
* This function removes thinking blocks from the end of content arrays.
|
||||||
|
*
|
||||||
|
* @param {Array<Object>} content - Array of content blocks
|
||||||
|
* @returns {Array<Object>} Content array with trailing unsigned thinking blocks removed
|
||||||
*/
|
*/
|
||||||
export function removeTrailingThinkingBlocks(content) {
|
export function removeTrailingThinkingBlocks(content) {
|
||||||
if (!Array.isArray(content)) return content;
|
if (!Array.isArray(content)) return content;
|
||||||
@@ -174,6 +184,9 @@ function sanitizeAnthropicThinkingBlock(block) {
|
|||||||
* Filter thinking blocks: keep only those with valid signatures.
|
* Filter thinking blocks: keep only those with valid signatures.
|
||||||
* Blocks without signatures are dropped (API requires signatures).
|
* Blocks without signatures are dropped (API requires signatures).
|
||||||
* Also sanitizes blocks to remove extra fields like cache_control.
|
* Also sanitizes blocks to remove extra fields like cache_control.
|
||||||
|
*
|
||||||
|
* @param {Array<Object>} content - Array of content blocks
|
||||||
|
* @returns {Array<Object>} Filtered content with only valid signed thinking blocks
|
||||||
*/
|
*/
|
||||||
export function restoreThinkingSignatures(content) {
|
export function restoreThinkingSignatures(content) {
|
||||||
if (!Array.isArray(content)) return 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)
|
* 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.
|
* Claude API requires that when thinking is enabled, assistant messages must start with thinking.
|
||||||
|
*
|
||||||
|
* @param {Array<Object>} content - Array of content blocks
|
||||||
|
* @returns {Array<Object>} Reordered content array
|
||||||
*/
|
*/
|
||||||
export function reorderAssistantContent(content) {
|
export function reorderAssistantContent(content) {
|
||||||
if (!Array.isArray(content)) return content;
|
if (!Array.isArray(content)) return content;
|
||||||
|
|||||||
81
src/oauth.js
81
src/oauth.js
@@ -8,27 +8,11 @@
|
|||||||
|
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import http from 'http';
|
import http from 'http';
|
||||||
import { ANTIGRAVITY_ENDPOINT_FALLBACKS, ANTIGRAVITY_HEADERS } from './constants.js';
|
import {
|
||||||
|
ANTIGRAVITY_ENDPOINT_FALLBACKS,
|
||||||
// Google OAuth configuration (from opencode-antigravity-auth)
|
ANTIGRAVITY_HEADERS,
|
||||||
const GOOGLE_CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
|
OAUTH_CONFIG
|
||||||
const GOOGLE_CLIENT_SECRET = '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';
|
} from './constants.js';
|
||||||
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
|
* Generate PKCE code verifier and challenge
|
||||||
@@ -45,16 +29,18 @@ function generatePKCE() {
|
|||||||
/**
|
/**
|
||||||
* Generate authorization URL for Google OAuth
|
* Generate authorization URL for Google OAuth
|
||||||
* Returns the URL and the PKCE verifier (needed for token exchange)
|
* 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() {
|
export function getAuthorizationUrl() {
|
||||||
const { verifier, challenge } = generatePKCE();
|
const { verifier, challenge } = generatePKCE();
|
||||||
const state = crypto.randomBytes(16).toString('hex');
|
const state = crypto.randomBytes(16).toString('hex');
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
client_id: GOOGLE_CLIENT_ID,
|
client_id: OAUTH_CONFIG.clientId,
|
||||||
redirect_uri: REDIRECT_URI,
|
redirect_uri: OAUTH_REDIRECT_URI,
|
||||||
response_type: 'code',
|
response_type: 'code',
|
||||||
scope: SCOPES,
|
scope: OAUTH_CONFIG.scopes.join(' '),
|
||||||
access_type: 'offline',
|
access_type: 'offline',
|
||||||
prompt: 'consent',
|
prompt: 'consent',
|
||||||
code_challenge: challenge,
|
code_challenge: challenge,
|
||||||
@@ -63,7 +49,7 @@ export function getAuthorizationUrl() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
url: `${GOOGLE_AUTH_URL}?${params.toString()}`,
|
url: `${OAUTH_CONFIG.authUrl}?${params.toString()}`,
|
||||||
verifier,
|
verifier,
|
||||||
state
|
state
|
||||||
};
|
};
|
||||||
@@ -72,11 +58,15 @@ export function getAuthorizationUrl() {
|
|||||||
/**
|
/**
|
||||||
* Start a local server to receive the OAuth callback
|
* Start a local server to receive the OAuth callback
|
||||||
* Returns a promise that resolves with the authorization code
|
* 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<string>} Authorization code from OAuth callback
|
||||||
*/
|
*/
|
||||||
export function startCallbackServer(expectedState, timeoutMs = 120000) {
|
export function startCallbackServer(expectedState, timeoutMs = 120000) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const server = http.createServer((req, res) => {
|
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') {
|
if (url.pathname !== '/oauth-callback') {
|
||||||
res.writeHead(404);
|
res.writeHead(404);
|
||||||
@@ -158,14 +148,14 @@ export function startCallbackServer(expectedState, timeoutMs = 120000) {
|
|||||||
|
|
||||||
server.on('error', (err) => {
|
server.on('error', (err) => {
|
||||||
if (err.code === 'EADDRINUSE') {
|
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 {
|
} else {
|
||||||
reject(err);
|
reject(err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
server.listen(CALLBACK_PORT, () => {
|
server.listen(OAUTH_CONFIG.callbackPort, () => {
|
||||||
console.log(`[OAuth] Callback server listening on port ${CALLBACK_PORT}`);
|
console.log(`[OAuth] Callback server listening on port ${OAUTH_CONFIG.callbackPort}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Timeout after specified duration
|
// Timeout after specified duration
|
||||||
@@ -178,20 +168,24 @@ export function startCallbackServer(expectedState, timeoutMs = 120000) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Exchange authorization code for tokens
|
* 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) {
|
export async function exchangeCode(code, verifier) {
|
||||||
const response = await fetch(GOOGLE_TOKEN_URL, {
|
const response = await fetch(OAUTH_CONFIG.tokenUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded'
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
},
|
},
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
client_id: GOOGLE_CLIENT_ID,
|
client_id: OAUTH_CONFIG.clientId,
|
||||||
client_secret: GOOGLE_CLIENT_SECRET,
|
client_secret: OAUTH_CONFIG.clientSecret,
|
||||||
code: code,
|
code: code,
|
||||||
code_verifier: verifier,
|
code_verifier: verifier,
|
||||||
grant_type: 'authorization_code',
|
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
|
* 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) {
|
export async function refreshAccessToken(refreshToken) {
|
||||||
const response = await fetch(GOOGLE_TOKEN_URL, {
|
const response = await fetch(OAUTH_CONFIG.tokenUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded'
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
},
|
},
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
client_id: GOOGLE_CLIENT_ID,
|
client_id: OAUTH_CONFIG.clientId,
|
||||||
client_secret: GOOGLE_CLIENT_SECRET,
|
client_secret: OAUTH_CONFIG.clientSecret,
|
||||||
refresh_token: refreshToken,
|
refresh_token: refreshToken,
|
||||||
grant_type: 'refresh_token'
|
grant_type: 'refresh_token'
|
||||||
})
|
})
|
||||||
@@ -248,9 +245,12 @@ export async function refreshAccessToken(refreshToken) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get user email from access token
|
* Get user email from access token
|
||||||
|
*
|
||||||
|
* @param {string} accessToken - OAuth access token
|
||||||
|
* @returns {Promise<string>} User's email address
|
||||||
*/
|
*/
|
||||||
export async function getUserEmail(accessToken) {
|
export async function getUserEmail(accessToken) {
|
||||||
const response = await fetch(GOOGLE_USERINFO_URL, {
|
const response = await fetch(OAUTH_CONFIG.userInfoUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${accessToken}`
|
'Authorization': `Bearer ${accessToken}`
|
||||||
}
|
}
|
||||||
@@ -268,6 +268,9 @@ export async function getUserEmail(accessToken) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Discover project ID for the authenticated user
|
* Discover project ID for the authenticated user
|
||||||
|
*
|
||||||
|
* @param {string} accessToken - OAuth access token
|
||||||
|
* @returns {Promise<string|null>} Project ID or null if not found
|
||||||
*/
|
*/
|
||||||
export async function discoverProjectId(accessToken) {
|
export async function discoverProjectId(accessToken) {
|
||||||
for (const endpoint of ANTIGRAVITY_ENDPOINT_FALLBACKS) {
|
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
|
* 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) {
|
export async function completeOAuthFlow(code, verifier) {
|
||||||
// Exchange code for tokens
|
// Exchange code for tokens
|
||||||
|
|||||||
@@ -1,26 +1,22 @@
|
|||||||
/**
|
/**
|
||||||
* Token Extractor Module
|
* Token Extractor Module
|
||||||
* Extracts OAuth tokens from Antigravity's SQLite database
|
* Extracts OAuth tokens from Antigravity's SQLite database
|
||||||
*
|
*
|
||||||
* The database is automatically updated by Antigravity when tokens refresh,
|
* The database is automatically updated by Antigravity when tokens refresh,
|
||||||
* so this approach doesn't require any manual intervention.
|
* so this approach doesn't require any manual intervention.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { execSync } from 'child_process';
|
import { execSync } from 'child_process';
|
||||||
import { homedir } from 'os';
|
import {
|
||||||
import { join } from 'path';
|
TOKEN_REFRESH_INTERVAL_MS,
|
||||||
import { TOKEN_REFRESH_INTERVAL_MS, ANTIGRAVITY_AUTH_PORT } from './constants.js';
|
ANTIGRAVITY_AUTH_PORT,
|
||||||
|
ANTIGRAVITY_DB_PATH
|
||||||
|
} from './constants.js';
|
||||||
|
|
||||||
// Cache for the extracted token
|
// Cache for the extracted token
|
||||||
let cachedToken = null;
|
let cachedToken = null;
|
||||||
let tokenExtractedAt = 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
|
* Extract token from Antigravity's SQLite database
|
||||||
* This is the preferred method as the DB is auto-updated
|
* This is the preferred method as the DB is auto-updated
|
||||||
|
|||||||
33
src/utils/helpers.js
Normal file
33
src/utils/helpers.js
Normal file
@@ -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<void>} Resolves after the specified duration
|
||||||
|
*/
|
||||||
|
export function sleep(ms) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
260
tests/helpers/http-client.cjs
Normal file
260
tests/helpers/http-client.cjs
Normal file
@@ -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<Object>} - 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
|
||||||
|
};
|
||||||
@@ -4,97 +4,14 @@
|
|||||||
* Tests that images can be sent to the API with thinking models.
|
* Tests that images can be sent to the API with thinking models.
|
||||||
* Simulates Claude Code sending screenshots or images for analysis.
|
* Simulates Claude Code sending screenshots or images for analysis.
|
||||||
*/
|
*/
|
||||||
const http = require('http');
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const { streamRequest } = require('./helpers/http-client.cjs');
|
||||||
const BASE_URL = 'localhost';
|
|
||||||
const PORT = 8080;
|
|
||||||
|
|
||||||
// Load test image from disk
|
// Load test image from disk
|
||||||
const TEST_IMAGE_PATH = path.join(__dirname, 'utils', 'test_image.jpeg');
|
const TEST_IMAGE_PATH = path.join(__dirname, 'utils', 'test_image.jpeg');
|
||||||
const TEST_IMAGE_BASE64 = fs.readFileSync(TEST_IMAGE_PATH).toString('base64');
|
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() {
|
async function runTests() {
|
||||||
console.log('='.repeat(60));
|
console.log('='.repeat(60));
|
||||||
console.log('IMAGE SUPPORT TEST');
|
console.log('IMAGE SUPPORT TEST');
|
||||||
|
|||||||
@@ -9,127 +9,10 @@
|
|||||||
* This simulates complex Claude Code scenarios where the model
|
* This simulates complex Claude Code scenarios where the model
|
||||||
* thinks multiple times during a single turn.
|
* thinks multiple times during a single turn.
|
||||||
*/
|
*/
|
||||||
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', () => {
|
|
||||||
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
|
// Multiple tools to encourage interleaved thinking
|
||||||
const tools = [{
|
const tools = [commonTools.readFile, commonTools.writeFile, commonTools.runTests];
|
||||||
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() {
|
async function runTests() {
|
||||||
console.log('='.repeat(60));
|
console.log('='.repeat(60));
|
||||||
|
|||||||
@@ -7,150 +7,9 @@
|
|||||||
* - signature_delta events are present
|
* - signature_delta events are present
|
||||||
* - Thinking blocks accumulate correctly across deltas
|
* - 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 tools = [commonTools.executeCommand];
|
||||||
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() {
|
async function runTests() {
|
||||||
console.log('='.repeat(60));
|
console.log('='.repeat(60));
|
||||||
|
|||||||
@@ -12,82 +12,9 @@
|
|||||||
* - Tool use/result flow works correctly
|
* - Tool use/result flow works correctly
|
||||||
* - Interleaved thinking with tools
|
* - Interleaved thinking with tools
|
||||||
*/
|
*/
|
||||||
const http = require('http');
|
const { makeRequest, analyzeContent, commonTools } = require('./helpers/http-client.cjs');
|
||||||
|
|
||||||
const BASE_URL = 'localhost';
|
const tools = [commonTools.searchFiles, commonTools.readFile];
|
||||||
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() {
|
async function runTests() {
|
||||||
console.log('='.repeat(60));
|
console.log('='.repeat(60));
|
||||||
|
|||||||
@@ -7,106 +7,9 @@
|
|||||||
* Claude Code sends assistant messages with thinking blocks that include signatures.
|
* Claude Code sends assistant messages with thinking blocks that include signatures.
|
||||||
* These signatures must be preserved and sent back to the API.
|
* 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 tools = [commonTools.getWeather];
|
||||||
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() {
|
async function runTests() {
|
||||||
console.log('='.repeat(60));
|
console.log('='.repeat(60));
|
||||||
|
|||||||
Reference in New Issue
Block a user