merge: integrate upstream/main (v1.2.15) into feature/webui
- Resolved conflict in src/constants.js: kept config-driven approach - Adopted upstream 10-second cooldown default - Added MAX_EMPTY_RESPONSE_RETRIES constant from upstream - Incorporated new test files and GitHub issue templates
This commit is contained in:
42
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
42
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
name: Bug Report
|
||||||
|
about: Report a bug or issue with the proxy
|
||||||
|
title: '[BUG] '
|
||||||
|
labels: bug
|
||||||
|
assignees: ''
|
||||||
|
---
|
||||||
|
|
||||||
|
**Bug Description**
|
||||||
|
A clear and concise description of the bug.
|
||||||
|
|
||||||
|
**Steps to Reproduce**
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
3.
|
||||||
|
|
||||||
|
**Expected Behavior**
|
||||||
|
What should happen.
|
||||||
|
|
||||||
|
**Actual Behavior**
|
||||||
|
What actually happens.
|
||||||
|
|
||||||
|
**Environment**
|
||||||
|
- Claude Code version:
|
||||||
|
- antigravity-claude-proxy version:
|
||||||
|
- Operating System:
|
||||||
|
- Node.js version:
|
||||||
|
|
||||||
|
**Logs (if applicable)**
|
||||||
|
|
||||||
|
|
||||||
|
**Checklist**
|
||||||
|
Please confirm:
|
||||||
|
- [ ] I've updated antigravity-claude-proxy to the latest version
|
||||||
|
- [ ] The bug exists in the latest version
|
||||||
|
- [ ] I'm using this proxy for personal development only
|
||||||
|
- [ ] I have an active Google Cloud project with Antigravity enabled
|
||||||
|
- [ ] This issue is not related to attempting commercial use or TOS violations
|
||||||
|
- [ ] I've reviewed the README troubleshooting section
|
||||||
|
|
||||||
|
**Additional Context**
|
||||||
|
Add any other relevant information.
|
||||||
11
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
11
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: Google Cloud Support
|
||||||
|
url: https://cloud.google.com/support
|
||||||
|
about: For Google Cloud account or billing issues, contact Google Cloud directly
|
||||||
|
- name: Google Terms of Service
|
||||||
|
url: https://policies.google.com/terms
|
||||||
|
about: Review Google's Terms of Service for compliance questions
|
||||||
|
- name: Discussions
|
||||||
|
url: https://github.com/badrisnarayanan/antigravity-claude-proxy/discussions
|
||||||
|
about: Ask questions or discuss the proxy with the community
|
||||||
28
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
28
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
name: Feature Request
|
||||||
|
about: Suggest a new feature or enhancement
|
||||||
|
title: '[FEATURE] '
|
||||||
|
labels: enhancement
|
||||||
|
assignees: ''
|
||||||
|
---
|
||||||
|
|
||||||
|
**Feature Description**
|
||||||
|
A clear description of the feature you'd like to see.
|
||||||
|
|
||||||
|
**Use Case**
|
||||||
|
Explain how this feature would be used and what problem it solves.
|
||||||
|
|
||||||
|
**Proposed Implementation**
|
||||||
|
If you have ideas about how this could be implemented, share them here.
|
||||||
|
|
||||||
|
**Compliance Consideration**
|
||||||
|
Please confirm:
|
||||||
|
- [ ] This feature is for personal development use
|
||||||
|
- [ ] This feature does not violate or circumvent Google's Terms of Service
|
||||||
|
- [ ] This feature is not intended for commercial resale or multi-user access
|
||||||
|
|
||||||
|
**Alternatives Considered**
|
||||||
|
Have you considered any alternative solutions or workarounds?
|
||||||
|
|
||||||
|
**Additional Context**
|
||||||
|
Add any other context, screenshots, or examples.
|
||||||
14
CLAUDE.md
14
CLAUDE.md
@@ -29,6 +29,7 @@ npm run dev
|
|||||||
# Account management
|
# Account management
|
||||||
npm run accounts # Interactive account management
|
npm run accounts # Interactive account management
|
||||||
npm run accounts:add # Add a new Google account via OAuth
|
npm run accounts:add # Add a new Google account via OAuth
|
||||||
|
npm run accounts:add -- --no-browser # Add account on headless server (manual code input)
|
||||||
npm run accounts:list # List configured accounts
|
npm run accounts:list # List configured accounts
|
||||||
npm run accounts:verify # Verify account tokens are valid
|
npm run accounts:verify # Verify account tokens are valid
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ npm run test:interleaved # Interleaved thinking
|
|||||||
npm run test:images # Image processing
|
npm run test:images # Image processing
|
||||||
npm run test:caching # Prompt caching
|
npm run test:caching # Prompt caching
|
||||||
npm run test:crossmodel # Cross-model thinking signatures
|
npm run test:crossmodel # Cross-model thinking signatures
|
||||||
|
npm run test:oauth # OAuth no-browser mode
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
@@ -99,7 +101,8 @@ src/
|
|||||||
│
|
│
|
||||||
└── utils/ # Utilities
|
└── utils/ # Utilities
|
||||||
├── helpers.js # formatDuration, sleep
|
├── helpers.js # formatDuration, sleep
|
||||||
└── logger.js # Structured logging
|
├── logger.js # Structured logging
|
||||||
|
└── native-module-helper.js # Auto-rebuild for native modules
|
||||||
```
|
```
|
||||||
|
|
||||||
**Key Modules:**
|
**Key Modules:**
|
||||||
@@ -107,7 +110,7 @@ src/
|
|||||||
- **src/server.js**: Express server exposing Anthropic-compatible endpoints (`/v1/messages`, `/v1/models`, `/health`, `/account-limits`)
|
- **src/server.js**: Express server exposing Anthropic-compatible endpoints (`/v1/messages`, `/v1/models`, `/health`, `/account-limits`)
|
||||||
- **src/cloudcode/**: Cloud Code API client with retry/failover logic, streaming and non-streaming support
|
- **src/cloudcode/**: Cloud Code API client with retry/failover logic, streaming and non-streaming support
|
||||||
- **src/account-manager/**: Multi-account pool with sticky selection, rate limit handling, and automatic cooldown
|
- **src/account-manager/**: Multi-account pool with sticky selection, rate limit handling, and automatic cooldown
|
||||||
- **src/auth/**: Authentication including Google OAuth, token extraction, and database access
|
- **src/auth/**: Authentication including Google OAuth, token extraction, database access, and auto-rebuild of native modules
|
||||||
- **src/format/**: Format conversion between Anthropic and Google Generative AI formats
|
- **src/format/**: Format conversion between Anthropic and Google Generative AI formats
|
||||||
- **src/constants.js**: API endpoints, model mappings, fallback config, OAuth config, and all configuration values
|
- **src/constants.js**: API endpoints, model mappings, fallback config, OAuth config, and all configuration values
|
||||||
- **src/fallback-config.js**: Model fallback mappings (`getFallbackModel()`, `hasFallback()`)
|
- **src/fallback-config.js**: Model fallback mappings (`getFallbackModel()`, `hasFallback()`)
|
||||||
@@ -142,6 +145,13 @@ src/
|
|||||||
- For Gemini targets: strict validation - drops unknown or mismatched signatures
|
- For Gemini targets: strict validation - drops unknown or mismatched signatures
|
||||||
- For Claude targets: lenient - lets Claude validate its own signatures
|
- For Claude targets: lenient - lets Claude validate its own signatures
|
||||||
|
|
||||||
|
**Native Module Auto-Rebuild:**
|
||||||
|
- When Node.js is updated, native modules like `better-sqlite3` may become incompatible
|
||||||
|
- The proxy automatically detects `NODE_MODULE_VERSION` mismatch errors
|
||||||
|
- On detection, it attempts to rebuild the module using `npm rebuild`
|
||||||
|
- If rebuild succeeds, the module is reloaded; if reload fails, a server restart is required
|
||||||
|
- Implementation in `src/utils/native-module-helper.js` and lazy loading in `src/auth/database.js`
|
||||||
|
|
||||||
## Testing Notes
|
## Testing Notes
|
||||||
|
|
||||||
- Tests require the server to be running (`npm start` in separate terminal)
|
- Tests require the server to be running (`npm start` in separate terminal)
|
||||||
|
|||||||
55
README.md
55
README.md
@@ -39,10 +39,10 @@ A proxy server that exposes an **Anthropic-compatible API** backed by **Antigrav
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run directly with npx (no install needed)
|
# Run directly with npx (no install needed)
|
||||||
npx antigravity-claude-proxy start
|
npx antigravity-claude-proxy@latest start
|
||||||
|
|
||||||
# Or install globally
|
# Or install globally
|
||||||
npm install -g antigravity-claude-proxy
|
npm install -g antigravity-claude-proxy@latest
|
||||||
antigravity-claude-proxy start
|
antigravity-claude-proxy start
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -69,14 +69,16 @@ If you have Antigravity installed and logged in, the proxy will automatically ex
|
|||||||
|
|
||||||
**Option B: Add Google Accounts via OAuth (Recommended for Multi-Account)**
|
**Option B: Add Google Accounts via OAuth (Recommended for Multi-Account)**
|
||||||
|
|
||||||
Add one or more Google accounts for load balancing:
|
Add one or more Google accounts for load balancing.
|
||||||
|
|
||||||
|
#### Desktop/Laptop (with browser)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# If installed via npm
|
# If installed via npm
|
||||||
antigravity-claude-proxy accounts add
|
antigravity-claude-proxy accounts add
|
||||||
|
|
||||||
# If using npx
|
# If using npx
|
||||||
npx antigravity-claude-proxy accounts add
|
npx antigravity-claude-proxy@latest accounts add
|
||||||
|
|
||||||
# If cloned locally
|
# If cloned locally
|
||||||
npm run accounts:add
|
npm run accounts:add
|
||||||
@@ -84,7 +86,22 @@ npm run accounts:add
|
|||||||
|
|
||||||
This opens your browser for Google OAuth. Sign in and authorize access. Repeat for multiple accounts.
|
This opens your browser for Google OAuth. Sign in and authorize access. Repeat for multiple accounts.
|
||||||
|
|
||||||
Manage accounts:
|
#### Headless Server (Docker, SSH, no desktop)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# If installed via npm
|
||||||
|
antigravity-claude-proxy accounts add --no-browser
|
||||||
|
|
||||||
|
# If using npx
|
||||||
|
npx antigravity-claude-proxy@latest accounts add -- --no-browser
|
||||||
|
|
||||||
|
# If cloned locally
|
||||||
|
npm run accounts:add -- --no-browser
|
||||||
|
```
|
||||||
|
|
||||||
|
This displays an OAuth URL you can open on another device (phone/laptop). After signing in, copy the redirect URL or authorization code and paste it back into the terminal.
|
||||||
|
|
||||||
|
#### Manage accounts
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# List all accounts
|
# List all accounts
|
||||||
@@ -104,7 +121,7 @@ antigravity-claude-proxy accounts
|
|||||||
antigravity-claude-proxy start
|
antigravity-claude-proxy start
|
||||||
|
|
||||||
# If using npx
|
# If using npx
|
||||||
npx antigravity-claude-proxy start
|
npx antigravity-claude-proxy@latest start
|
||||||
|
|
||||||
# If cloned locally
|
# If cloned locally
|
||||||
npm start
|
npm start
|
||||||
@@ -144,12 +161,15 @@ Add this configuration:
|
|||||||
"ANTHROPIC_MODEL": "claude-opus-4-5-thinking",
|
"ANTHROPIC_MODEL": "claude-opus-4-5-thinking",
|
||||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "claude-opus-4-5-thinking",
|
"ANTHROPIC_DEFAULT_OPUS_MODEL": "claude-opus-4-5-thinking",
|
||||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-sonnet-4-5-thinking",
|
"ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-sonnet-4-5-thinking",
|
||||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "claude-sonnet-4-5",
|
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "gemini-2.5-flash-lite[1m]",
|
||||||
"CLAUDE_CODE_SUBAGENT_MODEL": "claude-sonnet-4-5-thinking"
|
"CLAUDE_CODE_SUBAGENT_MODEL": "claude-sonnet-4-5-thinking",
|
||||||
|
"ENABLE_EXPERIMENTAL_MCP_CLI": "true"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
(Please use **gemini-2.5-flash-lite** as the default haiku model, even if others are claude, as claude code makes several calls via the haiku model for background tasks. If you use claude model for it, you may use you claude usage sooner)
|
||||||
|
|
||||||
Or to use Gemini models:
|
Or to use Gemini models:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -157,11 +177,12 @@ Or to use Gemini models:
|
|||||||
"env": {
|
"env": {
|
||||||
"ANTHROPIC_AUTH_TOKEN": "test",
|
"ANTHROPIC_AUTH_TOKEN": "test",
|
||||||
"ANTHROPIC_BASE_URL": "http://localhost:8080",
|
"ANTHROPIC_BASE_URL": "http://localhost:8080",
|
||||||
"ANTHROPIC_MODEL": "gemini-3-pro-high",
|
"ANTHROPIC_MODEL": "gemini-3-pro-high[1m]",
|
||||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "gemini-3-pro-high",
|
"ANTHROPIC_DEFAULT_OPUS_MODEL": "gemini-3-pro-high[1m]",
|
||||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "gemini-3-flash",
|
"ANTHROPIC_DEFAULT_SONNET_MODEL": "gemini-3-flash[1m]",
|
||||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "gemini-2.5-flash-lite",
|
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "gemini-2.5-flash-lite[1m]",
|
||||||
"CLAUDE_CODE_SUBAGENT_MODEL": "gemini-3-flash"
|
"CLAUDE_CODE_SUBAGENT_MODEL": "gemini-3-flash[1m]",
|
||||||
|
"ENABLE_EXPERIMENTAL_MCP_CLI": "true"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -174,7 +195,7 @@ Add the proxy settings to your shell profile:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
echo 'export ANTHROPIC_BASE_URL="http://localhost:8080"' >> ~/.zshrc
|
echo 'export ANTHROPIC_BASE_URL="http://localhost:8080"' >> ~/.zshrc
|
||||||
echo 'export ANTHROPIC_API_KEY="test"' >> ~/.zshrc
|
echo 'export ANTHROPIC_AUTH_TOKEN="test"' >> ~/.zshrc
|
||||||
source ~/.zshrc
|
source ~/.zshrc
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -184,7 +205,7 @@ source ~/.zshrc
|
|||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
Add-Content $PROFILE "`n`$env:ANTHROPIC_BASE_URL = 'http://localhost:8080'"
|
Add-Content $PROFILE "`n`$env:ANTHROPIC_BASE_URL = 'http://localhost:8080'"
|
||||||
Add-Content $PROFILE "`$env:ANTHROPIC_API_KEY = 'test'"
|
Add-Content $PROFILE "`$env:ANTHROPIC_AUTH_TOKEN = 'test'"
|
||||||
. $PROFILE
|
. $PROFILE
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -192,7 +213,7 @@ Add-Content $PROFILE "`$env:ANTHROPIC_API_KEY = 'test'"
|
|||||||
|
|
||||||
```cmd
|
```cmd
|
||||||
setx ANTHROPIC_BASE_URL "http://localhost:8080"
|
setx ANTHROPIC_BASE_URL "http://localhost:8080"
|
||||||
setx ANTHROPIC_API_KEY "test"
|
setx ANTHROPIC_AUTH_TOKEN "test"
|
||||||
```
|
```
|
||||||
|
|
||||||
Restart your terminal for changes to take effect.
|
Restart your terminal for changes to take effect.
|
||||||
@@ -383,4 +404,4 @@ MIT
|
|||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
[](https://www.star-history.com/#badri-s2001/antigravity-claude-proxy&type=date&legend=top-left)
|
[](https://www.star-history.com/#badrisnarayanan/antigravity-claude-proxy&type=date&legend=top-left)
|
||||||
@@ -27,7 +27,9 @@
|
|||||||
"test:interleaved": "node tests/test-interleaved-thinking.cjs",
|
"test:interleaved": "node tests/test-interleaved-thinking.cjs",
|
||||||
"test:images": "node tests/test-images.cjs",
|
"test:images": "node tests/test-images.cjs",
|
||||||
"test:caching": "node tests/test-caching-streaming.cjs",
|
"test:caching": "node tests/test-caching-streaming.cjs",
|
||||||
"test:crossmodel": "node tests/test-cross-model-thinking.cjs"
|
"test:crossmodel": "node tests/test-cross-model-thinking.cjs",
|
||||||
|
"test:oauth": "node tests/test-oauth-no-browser.cjs",
|
||||||
|
"test:emptyretry": "node tests/test-empty-response-retry.cjs"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"claude",
|
"claude",
|
||||||
|
|||||||
@@ -6,10 +6,80 @@
|
|||||||
* - Windows compatibility (no CLI dependency)
|
* - Windows compatibility (no CLI dependency)
|
||||||
* - Native performance
|
* - Native performance
|
||||||
* - Synchronous API (simple error handling)
|
* - Synchronous API (simple error handling)
|
||||||
|
*
|
||||||
|
* Includes auto-rebuild capability for handling Node.js version updates
|
||||||
|
* that cause native module incompatibility.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import Database from 'better-sqlite3';
|
import { createRequire } from 'module';
|
||||||
import { ANTIGRAVITY_DB_PATH } from '../constants.js';
|
import { ANTIGRAVITY_DB_PATH } from '../constants.js';
|
||||||
|
import { isModuleVersionError, attemptAutoRebuild, clearRequireCache } from '../utils/native-module-helper.js';
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
import { NativeModuleError } from '../errors.js';
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
|
// Lazy-loaded Database constructor
|
||||||
|
let Database = null;
|
||||||
|
let moduleLoadError = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the better-sqlite3 module with auto-rebuild on version mismatch
|
||||||
|
* Uses synchronous require to maintain API compatibility
|
||||||
|
* @returns {Function} The Database constructor
|
||||||
|
* @throws {Error} If module cannot be loaded even after rebuild
|
||||||
|
*/
|
||||||
|
function loadDatabaseModule() {
|
||||||
|
// Return cached module if already loaded
|
||||||
|
if (Database) return Database;
|
||||||
|
|
||||||
|
// Re-throw cached error if previous load failed permanently
|
||||||
|
if (moduleLoadError) throw moduleLoadError;
|
||||||
|
|
||||||
|
try {
|
||||||
|
Database = require('better-sqlite3');
|
||||||
|
return Database;
|
||||||
|
} catch (error) {
|
||||||
|
if (isModuleVersionError(error)) {
|
||||||
|
logger.warn('[Database] Native module version mismatch detected');
|
||||||
|
|
||||||
|
if (attemptAutoRebuild(error)) {
|
||||||
|
// Clear require cache and retry
|
||||||
|
try {
|
||||||
|
const resolvedPath = require.resolve('better-sqlite3');
|
||||||
|
// Clear the module and all its dependencies from cache
|
||||||
|
clearRequireCache(resolvedPath, require.cache);
|
||||||
|
|
||||||
|
Database = require('better-sqlite3');
|
||||||
|
logger.success('[Database] Module reloaded successfully after rebuild');
|
||||||
|
return Database;
|
||||||
|
} catch (retryError) {
|
||||||
|
// Rebuild succeeded but reload failed - user needs to restart
|
||||||
|
moduleLoadError = new NativeModuleError(
|
||||||
|
'Native module rebuild completed. Please restart the server to apply the fix.',
|
||||||
|
true, // rebuildSucceeded
|
||||||
|
true // restartRequired
|
||||||
|
);
|
||||||
|
logger.info('[Database] Rebuild succeeded - server restart required');
|
||||||
|
throw moduleLoadError;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
moduleLoadError = new NativeModuleError(
|
||||||
|
'Failed to auto-rebuild native module. Please run manually:\n' +
|
||||||
|
' npm rebuild better-sqlite3\n' +
|
||||||
|
'Or if using npx, find the package location in the error and run:\n' +
|
||||||
|
' cd /path/to/better-sqlite3 && npm rebuild',
|
||||||
|
false, // rebuildSucceeded
|
||||||
|
false // restartRequired
|
||||||
|
);
|
||||||
|
throw moduleLoadError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-version-mismatch error, just throw it
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Query Antigravity database for authentication status
|
* Query Antigravity database for authentication status
|
||||||
@@ -18,10 +88,11 @@ import { ANTIGRAVITY_DB_PATH } from '../constants.js';
|
|||||||
* @throws {Error} If database doesn't exist, query fails, or no auth status found
|
* @throws {Error} If database doesn't exist, query fails, or no auth status found
|
||||||
*/
|
*/
|
||||||
export function getAuthStatus(dbPath = ANTIGRAVITY_DB_PATH) {
|
export function getAuthStatus(dbPath = ANTIGRAVITY_DB_PATH) {
|
||||||
|
const Db = loadDatabaseModule();
|
||||||
let db;
|
let db;
|
||||||
try {
|
try {
|
||||||
// Open database in read-only mode
|
// Open database in read-only mode
|
||||||
db = new Database(dbPath, {
|
db = new Db(dbPath, {
|
||||||
readonly: true,
|
readonly: true,
|
||||||
fileMustExist: true
|
fileMustExist: true
|
||||||
});
|
});
|
||||||
@@ -56,6 +127,10 @@ export function getAuthStatus(dbPath = ANTIGRAVITY_DB_PATH) {
|
|||||||
if (error.message.includes('No auth status') || error.message.includes('missing apiKey')) {
|
if (error.message.includes('No auth status') || error.message.includes('missing apiKey')) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
// Re-throw native module errors from loadDatabaseModule without wrapping
|
||||||
|
if (error instanceof NativeModuleError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
throw new Error(`Failed to read Antigravity database: ${error.message}`);
|
throw new Error(`Failed to read Antigravity database: ${error.message}`);
|
||||||
} finally {
|
} finally {
|
||||||
// Always close database connection
|
// Always close database connection
|
||||||
@@ -73,7 +148,8 @@ export function getAuthStatus(dbPath = ANTIGRAVITY_DB_PATH) {
|
|||||||
export function isDatabaseAccessible(dbPath = ANTIGRAVITY_DB_PATH) {
|
export function isDatabaseAccessible(dbPath = ANTIGRAVITY_DB_PATH) {
|
||||||
let db;
|
let db;
|
||||||
try {
|
try {
|
||||||
db = new Database(dbPath, {
|
const Db = loadDatabaseModule();
|
||||||
|
db = new Db(dbPath, {
|
||||||
readonly: true,
|
readonly: true,
|
||||||
fileMustExist: true
|
fileMustExist: true
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -58,6 +58,56 @@ export function getAuthorizationUrl(customRedirectUri = null) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract authorization code and state from user input.
|
||||||
|
* User can paste either:
|
||||||
|
* - Full callback URL: http://localhost:51121/oauth-callback?code=xxx&state=xxx
|
||||||
|
* - Just the code parameter: 4/0xxx...
|
||||||
|
*
|
||||||
|
* @param {string} input - User input (URL or code)
|
||||||
|
* @returns {{code: string, state: string|null}} Extracted code and optional state
|
||||||
|
*/
|
||||||
|
export function extractCodeFromInput(input) {
|
||||||
|
if (!input || typeof input !== 'string') {
|
||||||
|
throw new Error('No input provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = input.trim();
|
||||||
|
|
||||||
|
// Check if it looks like a URL
|
||||||
|
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
|
||||||
|
try {
|
||||||
|
const url = new URL(trimmed);
|
||||||
|
const code = url.searchParams.get('code');
|
||||||
|
const state = url.searchParams.get('state');
|
||||||
|
const error = url.searchParams.get('error');
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(`OAuth error: ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
throw new Error('No authorization code found in URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { code, state };
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message.includes('OAuth error') || e.message.includes('No authorization code')) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
throw new Error('Invalid URL format');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assume it's a raw code
|
||||||
|
// Google auth codes typically start with "4/" and are long
|
||||||
|
if (trimmed.length < 10) {
|
||||||
|
throw new Error('Input is too short to be a valid authorization code');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { code: trimmed, state: null };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start a local server to receive the OAuth callback
|
* 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
|
||||||
@@ -82,10 +132,10 @@ export function startCallbackServer(expectedState, timeoutMs = 120000) {
|
|||||||
const error = url.searchParams.get('error');
|
const error = url.searchParams.get('error');
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
res.writeHead(400, { 'Content-Type': 'text/html' });
|
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||||
res.end(`
|
res.end(`
|
||||||
<html>
|
<html>
|
||||||
<head><title>Authentication Failed</title></head>
|
<head><meta charset="UTF-8"><title>Authentication Failed</title></head>
|
||||||
<body style="font-family: system-ui; padding: 40px; text-align: center;">
|
<body style="font-family: system-ui; padding: 40px; text-align: center;">
|
||||||
<h1 style="color: #dc3545;">❌ Authentication Failed</h1>
|
<h1 style="color: #dc3545;">❌ Authentication Failed</h1>
|
||||||
<p>Error: ${error}</p>
|
<p>Error: ${error}</p>
|
||||||
@@ -99,10 +149,10 @@ export function startCallbackServer(expectedState, timeoutMs = 120000) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (state !== expectedState) {
|
if (state !== expectedState) {
|
||||||
res.writeHead(400, { 'Content-Type': 'text/html' });
|
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||||
res.end(`
|
res.end(`
|
||||||
<html>
|
<html>
|
||||||
<head><title>Authentication Failed</title></head>
|
<head><meta charset="UTF-8"><title>Authentication Failed</title></head>
|
||||||
<body style="font-family: system-ui; padding: 40px; text-align: center;">
|
<body style="font-family: system-ui; padding: 40px; text-align: center;">
|
||||||
<h1 style="color: #dc3545;">❌ Authentication Failed</h1>
|
<h1 style="color: #dc3545;">❌ Authentication Failed</h1>
|
||||||
<p>State mismatch - possible CSRF attack.</p>
|
<p>State mismatch - possible CSRF attack.</p>
|
||||||
@@ -116,10 +166,10 @@ export function startCallbackServer(expectedState, timeoutMs = 120000) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!code) {
|
if (!code) {
|
||||||
res.writeHead(400, { 'Content-Type': 'text/html' });
|
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||||
res.end(`
|
res.end(`
|
||||||
<html>
|
<html>
|
||||||
<head><title>Authentication Failed</title></head>
|
<head><meta charset="UTF-8"><title>Authentication Failed</title></head>
|
||||||
<body style="font-family: system-ui; padding: 40px; text-align: center;">
|
<body style="font-family: system-ui; padding: 40px; text-align: center;">
|
||||||
<h1 style="color: #dc3545;">❌ Authentication Failed</h1>
|
<h1 style="color: #dc3545;">❌ Authentication Failed</h1>
|
||||||
<p>No authorization code received.</p>
|
<p>No authorization code received.</p>
|
||||||
@@ -133,10 +183,10 @@ export function startCallbackServer(expectedState, timeoutMs = 120000) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Success!
|
// Success!
|
||||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||||
res.end(`
|
res.end(`
|
||||||
<html>
|
<html>
|
||||||
<head><title>Authentication Successful</title></head>
|
<head><meta charset="UTF-8"><title>Authentication Successful</title></head>
|
||||||
<body style="font-family: system-ui; padding: 40px; text-align: center;">
|
<body style="font-family: system-ui; padding: 40px; text-align: center;">
|
||||||
<h1 style="color: #28a745;">✅ Authentication Successful!</h1>
|
<h1 style="color: #28a745;">✅ Authentication Successful!</h1>
|
||||||
<p>You can close this window and return to the terminal.</p>
|
<p>You can close this window and return to the terminal.</p>
|
||||||
@@ -339,6 +389,7 @@ export async function completeOAuthFlow(code, verifier) {
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
getAuthorizationUrl,
|
getAuthorizationUrl,
|
||||||
|
extractCodeFromInput,
|
||||||
startCallbackServer,
|
startCallbackServer,
|
||||||
exchangeCode,
|
exchangeCode,
|
||||||
refreshAccessToken,
|
refreshAccessToken,
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ import {
|
|||||||
startCallbackServer,
|
startCallbackServer,
|
||||||
completeOAuthFlow,
|
completeOAuthFlow,
|
||||||
refreshAccessToken,
|
refreshAccessToken,
|
||||||
getUserEmail
|
getUserEmail,
|
||||||
|
extractCodeFromInput
|
||||||
} from '../auth/oauth.js';
|
} from '../auth/oauth.js';
|
||||||
|
|
||||||
const SERVER_PORT = process.env.PORT || DEFAULT_PORT;
|
const SERVER_PORT = process.env.PORT || DEFAULT_PORT;
|
||||||
@@ -229,6 +230,63 @@ async function addAccount(existingAccounts) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new account via OAuth with manual code input (no-browser mode)
|
||||||
|
* For headless servers without a desktop environment
|
||||||
|
*/
|
||||||
|
async function addAccountNoBrowser(existingAccounts, rl) {
|
||||||
|
console.log('\n=== Add Google Account (No-Browser Mode) ===\n');
|
||||||
|
|
||||||
|
// Generate authorization URL
|
||||||
|
const { url, verifier, state } = getAuthorizationUrl();
|
||||||
|
|
||||||
|
console.log('Copy the following URL and open it in a browser on another device:\n');
|
||||||
|
console.log(` ${url}\n`);
|
||||||
|
console.log('After signing in, you will be redirected to a localhost URL.');
|
||||||
|
console.log('Copy the ENTIRE redirect URL or just the authorization code.\n');
|
||||||
|
|
||||||
|
const input = await rl.question('Paste the callback URL or authorization code: ');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { code, state: extractedState } = extractCodeFromInput(input);
|
||||||
|
|
||||||
|
// Validate state if present
|
||||||
|
if (extractedState && extractedState !== state) {
|
||||||
|
console.log('\n⚠ State mismatch detected. This could indicate a security issue.');
|
||||||
|
console.log('Proceeding anyway as this is manual mode...');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nExchanging authorization code for tokens...');
|
||||||
|
const result = await completeOAuthFlow(code, verifier);
|
||||||
|
|
||||||
|
// Check if account already exists
|
||||||
|
const existing = existingAccounts.find(a => a.email === result.email);
|
||||||
|
if (existing) {
|
||||||
|
console.log(`\n⚠ Account ${result.email} already exists. Updating tokens.`);
|
||||||
|
existing.refreshToken = result.refreshToken;
|
||||||
|
existing.projectId = result.projectId;
|
||||||
|
existing.addedAt = new Date().toISOString();
|
||||||
|
return null; // Don't add duplicate
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n✓ Successfully authenticated: ${result.email}`);
|
||||||
|
if (result.projectId) {
|
||||||
|
console.log(` Project ID: ${result.projectId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
email: result.email,
|
||||||
|
refreshToken: result.refreshToken,
|
||||||
|
projectId: result.projectId,
|
||||||
|
addedAt: new Date().toISOString(),
|
||||||
|
modelRateLimits: {}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`\n✗ Authentication failed: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interactive remove accounts flow
|
* Interactive remove accounts flow
|
||||||
*/
|
*/
|
||||||
@@ -275,8 +333,14 @@ async function interactiveRemove(rl) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Interactive add accounts flow (Main Menu)
|
* Interactive add accounts flow (Main Menu)
|
||||||
|
* @param {Object} rl - readline interface
|
||||||
|
* @param {boolean} noBrowser - if true, use manual code input mode
|
||||||
*/
|
*/
|
||||||
async function interactiveAdd(rl) {
|
async function interactiveAdd(rl, noBrowser = false) {
|
||||||
|
if (noBrowser) {
|
||||||
|
console.log('\n📋 No-browser mode: You will manually paste the authorization code.\n');
|
||||||
|
}
|
||||||
|
|
||||||
const accounts = loadAccounts();
|
const accounts = loadAccounts();
|
||||||
|
|
||||||
if (accounts.length > 0) {
|
if (accounts.length > 0) {
|
||||||
@@ -307,7 +371,11 @@ async function interactiveAdd(rl) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newAccount = await addAccount(accounts);
|
// Use appropriate add function based on mode
|
||||||
|
const newAccount = noBrowser
|
||||||
|
? await addAccountNoBrowser(accounts, rl)
|
||||||
|
: await addAccount(accounts);
|
||||||
|
|
||||||
if (newAccount) {
|
if (newAccount) {
|
||||||
accounts.push(newAccount);
|
accounts.push(newAccount);
|
||||||
saveAccounts(accounts);
|
saveAccounts(accounts);
|
||||||
@@ -388,9 +456,11 @@ async function verifyAccounts() {
|
|||||||
async function main() {
|
async function main() {
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const command = args[0] || 'add';
|
const command = args[0] || 'add';
|
||||||
|
const noBrowser = args.includes('--no-browser');
|
||||||
|
|
||||||
console.log('╔════════════════════════════════════════╗');
|
console.log('╔════════════════════════════════════════╗');
|
||||||
console.log('║ Antigravity Proxy Account Manager ║');
|
console.log('║ Antigravity Proxy Account Manager ║');
|
||||||
|
console.log('║ Use --no-browser for headless mode ║');
|
||||||
console.log('╚════════════════════════════════════════╝');
|
console.log('╚════════════════════════════════════════╝');
|
||||||
|
|
||||||
const rl = createRL();
|
const rl = createRL();
|
||||||
@@ -399,7 +469,7 @@ async function main() {
|
|||||||
switch (command) {
|
switch (command) {
|
||||||
case 'add':
|
case 'add':
|
||||||
await ensureServerStopped();
|
await ensureServerStopped();
|
||||||
await interactiveAdd(rl);
|
await interactiveAdd(rl, noBrowser);
|
||||||
break;
|
break;
|
||||||
case 'list':
|
case 'list':
|
||||||
await listAccounts();
|
await listAccounts();
|
||||||
@@ -418,6 +488,8 @@ async function main() {
|
|||||||
console.log(' node src/cli/accounts.js verify Verify account tokens');
|
console.log(' node src/cli/accounts.js verify Verify account tokens');
|
||||||
console.log(' node src/cli/accounts.js clear Remove all accounts');
|
console.log(' node src/cli/accounts.js clear Remove all accounts');
|
||||||
console.log(' node src/cli/accounts.js help Show this help');
|
console.log(' node src/cli/accounts.js help Show this help');
|
||||||
|
console.log('\nOptions:');
|
||||||
|
console.log(' --no-browser Manual authorization code input (for headless servers)');
|
||||||
break;
|
break;
|
||||||
case 'remove':
|
case 'remove':
|
||||||
await ensureServerStopped();
|
await ensureServerStopped();
|
||||||
|
|||||||
@@ -72,8 +72,19 @@ export async function sendMessage(anthropicRequest, accountManager, fallbackEnab
|
|||||||
const accountCount = accountManager.getAccountCount();
|
const accountCount = accountManager.getAccountCount();
|
||||||
logger.warn(`[CloudCode] All ${accountCount} account(s) rate-limited. Waiting ${formatDuration(allWaitMs)}...`);
|
logger.warn(`[CloudCode] All ${accountCount} account(s) rate-limited. Waiting ${formatDuration(allWaitMs)}...`);
|
||||||
await sleep(allWaitMs);
|
await sleep(allWaitMs);
|
||||||
|
|
||||||
|
// Add small buffer after waiting to ensure rate limits have truly expired
|
||||||
|
await sleep(500);
|
||||||
accountManager.clearExpiredLimits();
|
accountManager.clearExpiredLimits();
|
||||||
account = accountManager.pickNext(model);
|
account = accountManager.pickNext(model);
|
||||||
|
|
||||||
|
// If still no account after waiting, try optimistic reset
|
||||||
|
// This handles cases where the API rate limit is transient
|
||||||
|
if (!account) {
|
||||||
|
logger.warn('[CloudCode] No account available after wait, attempting optimistic reset...');
|
||||||
|
accountManager.resetAllRateLimits();
|
||||||
|
account = accountManager.pickNext(model);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import {
|
import {
|
||||||
ANTIGRAVITY_HEADERS,
|
ANTIGRAVITY_HEADERS,
|
||||||
|
ANTIGRAVITY_SYSTEM_INSTRUCTION,
|
||||||
getModelFamily,
|
getModelFamily,
|
||||||
isThinkingModel
|
isThinkingModel
|
||||||
} from '../constants.js';
|
} from '../constants.js';
|
||||||
@@ -27,14 +28,38 @@ export function buildCloudCodeRequest(anthropicRequest, projectId) {
|
|||||||
// Use stable session ID derived from first user message for cache continuity
|
// Use stable session ID derived from first user message for cache continuity
|
||||||
googleRequest.sessionId = deriveSessionId(anthropicRequest);
|
googleRequest.sessionId = deriveSessionId(anthropicRequest);
|
||||||
|
|
||||||
|
// Build system instruction parts array with [ignore] tags to prevent model from
|
||||||
|
// identifying as "Antigravity" (fixes GitHub issue #76)
|
||||||
|
// Reference: CLIProxyAPI, gcli2api, AIClient-2-API all use this approach
|
||||||
|
const systemParts = [
|
||||||
|
{ text: ANTIGRAVITY_SYSTEM_INSTRUCTION },
|
||||||
|
{ text: `Please ignore the following [ignore]${ANTIGRAVITY_SYSTEM_INSTRUCTION}[/ignore]` }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Append any existing system instructions from the request
|
||||||
|
if (googleRequest.systemInstruction && googleRequest.systemInstruction.parts) {
|
||||||
|
for (const part of googleRequest.systemInstruction.parts) {
|
||||||
|
if (part.text) {
|
||||||
|
systemParts.push({ text: part.text });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
project: projectId,
|
project: projectId,
|
||||||
model: model,
|
model: model,
|
||||||
request: googleRequest,
|
request: googleRequest,
|
||||||
userAgent: 'antigravity',
|
userAgent: 'antigravity',
|
||||||
|
requestType: 'agent', // CLIProxyAPI v6.6.89 compatibility
|
||||||
requestId: 'agent-' + crypto.randomUUID()
|
requestId: 'agent-' + crypto.randomUUID()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Inject systemInstruction with role: "user" at the top level (CLIProxyAPI v6.6.89 behavior)
|
||||||
|
payload.request.systemInstruction = {
|
||||||
|
role: 'user',
|
||||||
|
parts: systemParts
|
||||||
|
};
|
||||||
|
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { MIN_SIGNATURE_LENGTH, getModelFamily } from '../constants.js';
|
import { MIN_SIGNATURE_LENGTH, getModelFamily } from '../constants.js';
|
||||||
|
import { EmptyResponseError } from '../errors.js';
|
||||||
import { cacheSignature, cacheThinkingSignature } from '../format/signature-cache.js';
|
import { cacheSignature, cacheThinkingSignature } from '../format/signature-cache.js';
|
||||||
import { logger } from '../utils/logger.js';
|
import { logger } from '../utils/logger.js';
|
||||||
|
|
||||||
@@ -226,39 +227,10 @@ export async function* streamSSEResponse(response, originalModel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle no content received
|
// Handle no content received - throw error to trigger retry in streaming-handler
|
||||||
if (!hasEmittedStart) {
|
if (!hasEmittedStart) {
|
||||||
logger.warn('[CloudCode] No content parts received, emitting empty message');
|
logger.warn('[CloudCode] No content parts received, throwing for retry');
|
||||||
yield {
|
throw new EmptyResponseError('No content parts received from API');
|
||||||
type: 'message_start',
|
|
||||||
message: {
|
|
||||||
id: messageId,
|
|
||||||
type: 'message',
|
|
||||||
role: 'assistant',
|
|
||||||
content: [],
|
|
||||||
model: originalModel,
|
|
||||||
stop_reason: null,
|
|
||||||
stop_sequence: null,
|
|
||||||
usage: {
|
|
||||||
input_tokens: inputTokens - cacheReadTokens,
|
|
||||||
output_tokens: 0,
|
|
||||||
cache_read_input_tokens: cacheReadTokens,
|
|
||||||
cache_creation_input_tokens: 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
yield {
|
|
||||||
type: 'content_block_start',
|
|
||||||
index: 0,
|
|
||||||
content_block: { type: 'text', text: '' }
|
|
||||||
};
|
|
||||||
yield {
|
|
||||||
type: 'content_block_delta',
|
|
||||||
index: 0,
|
|
||||||
delta: { type: 'text_delta', text: '[No response received from API]' }
|
|
||||||
};
|
|
||||||
yield { type: 'content_block_stop', index: 0 };
|
|
||||||
} else {
|
} else {
|
||||||
// Close any open block
|
// Close any open block
|
||||||
if (currentBlockType !== null) {
|
if (currentBlockType !== null) {
|
||||||
|
|||||||
@@ -8,16 +8,17 @@
|
|||||||
import {
|
import {
|
||||||
ANTIGRAVITY_ENDPOINT_FALLBACKS,
|
ANTIGRAVITY_ENDPOINT_FALLBACKS,
|
||||||
MAX_RETRIES,
|
MAX_RETRIES,
|
||||||
|
MAX_EMPTY_RESPONSE_RETRIES,
|
||||||
MAX_WAIT_BEFORE_ERROR_MS
|
MAX_WAIT_BEFORE_ERROR_MS
|
||||||
} from '../constants.js';
|
} from '../constants.js';
|
||||||
import { isRateLimitError, isAuthError } from '../errors.js';
|
import { isRateLimitError, isAuthError, isEmptyResponseError } from '../errors.js';
|
||||||
import { formatDuration, sleep, isNetworkError } from '../utils/helpers.js';
|
import { formatDuration, sleep, isNetworkError } from '../utils/helpers.js';
|
||||||
import { logger } from '../utils/logger.js';
|
import { logger } from '../utils/logger.js';
|
||||||
import { parseResetTime } from './rate-limit-parser.js';
|
import { parseResetTime } from './rate-limit-parser.js';
|
||||||
import { buildCloudCodeRequest, buildHeaders } from './request-builder.js';
|
import { buildCloudCodeRequest, buildHeaders } from './request-builder.js';
|
||||||
import { streamSSEResponse } from './sse-streamer.js';
|
import { streamSSEResponse } from './sse-streamer.js';
|
||||||
import { getFallbackModel } from '../fallback-config.js';
|
import { getFallbackModel } from '../fallback-config.js';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a streaming request to Cloud Code with multi-account support
|
* Send a streaming request to Cloud Code with multi-account support
|
||||||
@@ -70,8 +71,19 @@ export async function* sendMessageStream(anthropicRequest, accountManager, fallb
|
|||||||
const accountCount = accountManager.getAccountCount();
|
const accountCount = accountManager.getAccountCount();
|
||||||
logger.warn(`[CloudCode] All ${accountCount} account(s) rate-limited. Waiting ${formatDuration(allWaitMs)}...`);
|
logger.warn(`[CloudCode] All ${accountCount} account(s) rate-limited. Waiting ${formatDuration(allWaitMs)}...`);
|
||||||
await sleep(allWaitMs);
|
await sleep(allWaitMs);
|
||||||
|
|
||||||
|
// Add small buffer after waiting to ensure rate limits have truly expired
|
||||||
|
await sleep(500);
|
||||||
accountManager.clearExpiredLimits();
|
accountManager.clearExpiredLimits();
|
||||||
account = accountManager.pickNext(model);
|
account = accountManager.pickNext(model);
|
||||||
|
|
||||||
|
// If still no account after waiting, try optimistic reset
|
||||||
|
// This handles cases where the API rate limit is transient
|
||||||
|
if (!account) {
|
||||||
|
logger.warn('[CloudCode] No account available after wait, attempting optimistic reset...');
|
||||||
|
accountManager.resetAllRateLimits();
|
||||||
|
account = accountManager.pickNext(model);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
@@ -143,16 +155,90 @@ export async function* sendMessageStream(anthropicRequest, accountManager, fallb
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stream the response - yield events as they arrive
|
// Stream the response with retry logic for empty responses
|
||||||
yield* streamSSEResponse(response, anthropicRequest.model);
|
// Uses a for-loop for clearer retry semantics
|
||||||
|
let currentResponse = response;
|
||||||
|
|
||||||
|
for (let emptyRetries = 0; emptyRetries <= MAX_EMPTY_RESPONSE_RETRIES; emptyRetries++) {
|
||||||
|
try {
|
||||||
|
yield* streamSSEResponse(currentResponse, anthropicRequest.model);
|
||||||
logger.debug('[CloudCode] Stream completed');
|
logger.debug('[CloudCode] Stream completed');
|
||||||
return;
|
return;
|
||||||
|
} catch (streamError) {
|
||||||
|
// Only retry on EmptyResponseError
|
||||||
|
if (!isEmptyResponseError(streamError)) {
|
||||||
|
throw streamError;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we have retries left
|
||||||
|
if (emptyRetries >= MAX_EMPTY_RESPONSE_RETRIES) {
|
||||||
|
logger.error(`[CloudCode] Empty response after ${MAX_EMPTY_RESPONSE_RETRIES} retries`);
|
||||||
|
yield* emitEmptyResponseFallback(anthropicRequest.model);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exponential backoff: 500ms, 1000ms, 2000ms
|
||||||
|
const backoffMs = 500 * Math.pow(2, emptyRetries);
|
||||||
|
logger.warn(`[CloudCode] Empty response, retry ${emptyRetries + 1}/${MAX_EMPTY_RESPONSE_RETRIES} after ${backoffMs}ms...`);
|
||||||
|
await sleep(backoffMs);
|
||||||
|
|
||||||
|
// Refetch the response
|
||||||
|
currentResponse = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: buildHeaders(token, model, 'text/event-stream'),
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle specific error codes on retry
|
||||||
|
if (!currentResponse.ok) {
|
||||||
|
const retryErrorText = await currentResponse.text();
|
||||||
|
|
||||||
|
// Rate limit error - mark account and throw to trigger account switch
|
||||||
|
if (currentResponse.status === 429) {
|
||||||
|
const resetMs = parseResetTime(currentResponse, retryErrorText);
|
||||||
|
accountManager.markRateLimited(account.email, resetMs, model);
|
||||||
|
throw new Error(`429 RESOURCE_EXHAUSTED during retry: ${retryErrorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth error - clear caches and throw with recognizable message
|
||||||
|
if (currentResponse.status === 401) {
|
||||||
|
accountManager.clearTokenCache(account.email);
|
||||||
|
accountManager.clearProjectCache(account.email);
|
||||||
|
throw new Error(`401 AUTH_INVALID during retry: ${retryErrorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For 5xx errors, don't pass to streamer - just continue to next retry
|
||||||
|
if (currentResponse.status >= 500) {
|
||||||
|
logger.warn(`[CloudCode] Retry got ${currentResponse.status}, will retry...`);
|
||||||
|
// Don't continue here - let the loop increment and refetch
|
||||||
|
// Set currentResponse to null to force refetch at loop start
|
||||||
|
emptyRetries--; // Compensate for loop increment since we didn't actually try
|
||||||
|
await sleep(1000);
|
||||||
|
// Refetch immediately for 5xx
|
||||||
|
currentResponse = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: buildHeaders(token, model, 'text/event-stream'),
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
if (currentResponse.ok) {
|
||||||
|
continue; // Try streaming with new response
|
||||||
|
}
|
||||||
|
// If still failing, let it fall through to throw
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Empty response retry failed: ${currentResponse.status} - ${retryErrorText}`);
|
||||||
|
}
|
||||||
|
// Response is OK, loop will continue to try streamSSEResponse
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
} catch (endpointError) {
|
} catch (endpointError) {
|
||||||
if (isRateLimitError(endpointError)) {
|
if (isRateLimitError(endpointError)) {
|
||||||
throw endpointError; // Re-throw to trigger account switch
|
throw endpointError; // Re-throw to trigger account switch
|
||||||
}
|
}
|
||||||
|
if (isEmptyResponseError(endpointError)) {
|
||||||
|
throw endpointError; // Re-throw empty response errors to outer handler
|
||||||
|
}
|
||||||
logger.warn(`[CloudCode] Stream error at ${endpoint}:`, endpointError.message);
|
logger.warn(`[CloudCode] Stream error at ${endpoint}:`, endpointError.message);
|
||||||
lastError = endpointError;
|
lastError = endpointError;
|
||||||
}
|
}
|
||||||
@@ -201,3 +287,49 @@ export async function* sendMessageStream(anthropicRequest, accountManager, fallb
|
|||||||
|
|
||||||
throw new Error('Max retries exceeded');
|
throw new Error('Max retries exceeded');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit a fallback message when all retry attempts fail with empty response
|
||||||
|
* @param {string} model - The model name
|
||||||
|
* @yields {Object} Anthropic-format SSE events for empty response fallback
|
||||||
|
*/
|
||||||
|
function* emitEmptyResponseFallback(model) {
|
||||||
|
// Use proper message ID format consistent with Anthropic API
|
||||||
|
const messageId = `msg_${crypto.randomBytes(16).toString('hex')}`;
|
||||||
|
|
||||||
|
yield {
|
||||||
|
type: 'message_start',
|
||||||
|
message: {
|
||||||
|
id: messageId,
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
content: [],
|
||||||
|
model: model,
|
||||||
|
stop_reason: null,
|
||||||
|
stop_sequence: null,
|
||||||
|
usage: { input_tokens: 0, output_tokens: 0 }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
yield {
|
||||||
|
type: 'content_block_start',
|
||||||
|
index: 0,
|
||||||
|
content_block: { type: 'text', text: '' }
|
||||||
|
};
|
||||||
|
|
||||||
|
yield {
|
||||||
|
type: 'content_block_delta',
|
||||||
|
index: 0,
|
||||||
|
delta: { type: 'text_delta', text: '[No response after retries - please try again]' }
|
||||||
|
};
|
||||||
|
|
||||||
|
yield { type: 'content_block_stop', index: 0 };
|
||||||
|
|
||||||
|
yield {
|
||||||
|
type: 'message_delta',
|
||||||
|
delta: { stop_reason: 'end_turn', stop_sequence: null },
|
||||||
|
usage: { output_tokens: 0 }
|
||||||
|
};
|
||||||
|
|
||||||
|
yield { type: 'message_stop' };
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ function getPlatformUserAgent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cloud Code API endpoints (in fallback order)
|
// Cloud Code API endpoints (in fallback order)
|
||||||
const ANTIGRAVITY_ENDPOINT_DAILY = 'https://daily-cloudcode-pa.sandbox.googleapis.com';
|
const ANTIGRAVITY_ENDPOINT_DAILY = 'https://daily-cloudcode-pa.googleapis.com';
|
||||||
const ANTIGRAVITY_ENDPOINT_PROD = 'https://cloudcode-pa.googleapis.com';
|
const ANTIGRAVITY_ENDPOINT_PROD = 'https://cloudcode-pa.googleapis.com';
|
||||||
|
|
||||||
// Endpoint fallback order (daily → prod)
|
// Endpoint fallback order (daily → prod)
|
||||||
@@ -76,8 +76,9 @@ export const ACCOUNT_CONFIG_PATH = config?.accountConfigPath || join(
|
|||||||
// Uses platform-specific path detection
|
// Uses platform-specific path detection
|
||||||
export const ANTIGRAVITY_DB_PATH = getAntigravityDbPath();
|
export const ANTIGRAVITY_DB_PATH = getAntigravityDbPath();
|
||||||
|
|
||||||
export const DEFAULT_COOLDOWN_MS = config?.defaultCooldownMs || (60 * 1000); // From config or 1 minute
|
export const DEFAULT_COOLDOWN_MS = config?.defaultCooldownMs || (10 * 1000); // From config or 10 seconds (upstream default)
|
||||||
export const MAX_RETRIES = config?.maxRetries || 5; // From config or 5
|
export const MAX_RETRIES = config?.maxRetries || 5; // From config or 5
|
||||||
|
export const MAX_EMPTY_RESPONSE_RETRIES = 2; // Max retries for empty API responses (from upstream)
|
||||||
export const MAX_ACCOUNTS = config?.maxAccounts || 10; // From config or 10
|
export const MAX_ACCOUNTS = config?.maxAccounts || 10; // From config or 10
|
||||||
|
|
||||||
// Rate limit wait thresholds
|
// Rate limit wait thresholds
|
||||||
@@ -146,6 +147,11 @@ export const OAUTH_CONFIG = {
|
|||||||
};
|
};
|
||||||
export const OAUTH_REDIRECT_URI = `http://localhost:${OAUTH_CONFIG.callbackPort}/oauth-callback`;
|
export const OAUTH_REDIRECT_URI = `http://localhost:${OAUTH_CONFIG.callbackPort}/oauth-callback`;
|
||||||
|
|
||||||
|
// Minimal Antigravity system instruction (from CLIProxyAPI)
|
||||||
|
// Only includes the essential identity portion to reduce token usage and improve response quality
|
||||||
|
// Reference: GitHub issue #76, CLIProxyAPI, gcli2api
|
||||||
|
export const ANTIGRAVITY_SYSTEM_INSTRUCTION = `You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding.You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question.**Absolute paths only****Proactiveness**`;
|
||||||
|
|
||||||
// Model fallback mapping - maps primary model to fallback when quota exhausted
|
// Model fallback mapping - maps primary model to fallback when quota exhausted
|
||||||
export const MODEL_FALLBACK_MAP = {
|
export const MODEL_FALLBACK_MAP = {
|
||||||
'gemini-3-pro-high': 'claude-opus-4-5-thinking',
|
'gemini-3-pro-high': 'claude-opus-4-5-thinking',
|
||||||
@@ -168,6 +174,7 @@ export default {
|
|||||||
ANTIGRAVITY_DB_PATH,
|
ANTIGRAVITY_DB_PATH,
|
||||||
DEFAULT_COOLDOWN_MS,
|
DEFAULT_COOLDOWN_MS,
|
||||||
MAX_RETRIES,
|
MAX_RETRIES,
|
||||||
|
MAX_EMPTY_RESPONSE_RETRIES,
|
||||||
MAX_ACCOUNTS,
|
MAX_ACCOUNTS,
|
||||||
MAX_WAIT_BEFORE_ERROR_MS,
|
MAX_WAIT_BEFORE_ERROR_MS,
|
||||||
MIN_SIGNATURE_LENGTH,
|
MIN_SIGNATURE_LENGTH,
|
||||||
@@ -178,5 +185,6 @@ export default {
|
|||||||
isThinkingModel,
|
isThinkingModel,
|
||||||
OAUTH_CONFIG,
|
OAUTH_CONFIG,
|
||||||
OAUTH_REDIRECT_URI,
|
OAUTH_REDIRECT_URI,
|
||||||
MODEL_FALLBACK_MAP
|
MODEL_FALLBACK_MAP,
|
||||||
|
ANTIGRAVITY_SYSTEM_INSTRUCTION
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -118,6 +118,37 @@ export class ApiError extends AntigravityError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native module error (version mismatch, rebuild required)
|
||||||
|
*/
|
||||||
|
export class NativeModuleError extends AntigravityError {
|
||||||
|
/**
|
||||||
|
* @param {string} message - Error message
|
||||||
|
* @param {boolean} rebuildSucceeded - Whether auto-rebuild succeeded
|
||||||
|
* @param {boolean} restartRequired - Whether server restart is needed
|
||||||
|
*/
|
||||||
|
constructor(message, rebuildSucceeded = false, restartRequired = false) {
|
||||||
|
super(message, 'NATIVE_MODULE_ERROR', false, { rebuildSucceeded, restartRequired });
|
||||||
|
this.name = 'NativeModuleError';
|
||||||
|
this.rebuildSucceeded = rebuildSucceeded;
|
||||||
|
this.restartRequired = restartRequired;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty response error - thrown when API returns no content
|
||||||
|
* Used to trigger retry logic in streaming handler
|
||||||
|
*/
|
||||||
|
export class EmptyResponseError extends AntigravityError {
|
||||||
|
/**
|
||||||
|
* @param {string} message - Error message
|
||||||
|
*/
|
||||||
|
constructor(message = 'No content received from API') {
|
||||||
|
super(message, 'EMPTY_RESPONSE', true, {});
|
||||||
|
this.name = 'EmptyResponseError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if an error is a rate limit error
|
* Check if an error is a rate limit error
|
||||||
* Works with both custom error classes and legacy string-based errors
|
* Works with both custom error classes and legacy string-based errors
|
||||||
@@ -147,6 +178,16 @@ export function isAuthError(error) {
|
|||||||
msg.includes('TOKEN REFRESH FAILED');
|
msg.includes('TOKEN REFRESH FAILED');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an error is an empty response error
|
||||||
|
* @param {Error} error - Error to check
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isEmptyResponseError(error) {
|
||||||
|
return error instanceof EmptyResponseError ||
|
||||||
|
error?.name === 'EmptyResponseError';
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
AntigravityError,
|
AntigravityError,
|
||||||
RateLimitError,
|
RateLimitError,
|
||||||
@@ -154,6 +195,9 @@ export default {
|
|||||||
NoAccountsError,
|
NoAccountsError,
|
||||||
MaxRetriesError,
|
MaxRetriesError,
|
||||||
ApiError,
|
ApiError,
|
||||||
|
NativeModuleError,
|
||||||
|
EmptyResponseError,
|
||||||
isRateLimitError,
|
isRateLimitError,
|
||||||
isAuthError
|
isAuthError,
|
||||||
|
isEmptyResponseError
|
||||||
};
|
};
|
||||||
|
|||||||
162
src/utils/native-module-helper.js
Normal file
162
src/utils/native-module-helper.js
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
/**
|
||||||
|
* Native Module Helper
|
||||||
|
* Detects and auto-rebuilds native Node.js modules when they become
|
||||||
|
* incompatible after a Node.js version update.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import { dirname, join } from 'path';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
import { logger } from './logger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an error is a NODE_MODULE_VERSION mismatch error
|
||||||
|
* @param {Error} error - The error to check
|
||||||
|
* @returns {boolean} True if it's a version mismatch error
|
||||||
|
*/
|
||||||
|
export function isModuleVersionError(error) {
|
||||||
|
const message = error?.message || '';
|
||||||
|
return message.includes('NODE_MODULE_VERSION') &&
|
||||||
|
message.includes('was compiled against a different Node.js version');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the module path from a NODE_MODULE_VERSION error message
|
||||||
|
* @param {Error} error - The error containing the module path
|
||||||
|
* @returns {string|null} The path to the .node file, or null if not found
|
||||||
|
*/
|
||||||
|
export function extractModulePath(error) {
|
||||||
|
const message = error?.message || '';
|
||||||
|
// Match pattern like: "The module '/path/to/module.node'"
|
||||||
|
const match = message.match(/The module '([^']+\.node)'/);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the package root directory from a .node file path
|
||||||
|
* @param {string} nodeFilePath - Path to the .node file
|
||||||
|
* @returns {string|null} Path to the package root, or null if not found
|
||||||
|
*/
|
||||||
|
export function findPackageRoot(nodeFilePath) {
|
||||||
|
// Walk up from the .node file to find package.json
|
||||||
|
let dir = dirname(nodeFilePath);
|
||||||
|
while (dir) {
|
||||||
|
const packageJsonPath = join(dir, 'package.json');
|
||||||
|
if (existsSync(packageJsonPath)) {
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
const parentDir = dirname(dir);
|
||||||
|
// Stop when we've reached the filesystem root (dirname returns same path)
|
||||||
|
if (parentDir === dir) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
dir = parentDir;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to rebuild a native module
|
||||||
|
* @param {string} packagePath - Path to the package root directory
|
||||||
|
* @returns {boolean} True if rebuild succeeded, false otherwise
|
||||||
|
*/
|
||||||
|
export function rebuildModule(packagePath) {
|
||||||
|
try {
|
||||||
|
logger.info(`[NativeModule] Rebuilding native module at: ${packagePath}`);
|
||||||
|
|
||||||
|
// Run npm rebuild in the package directory
|
||||||
|
const output = execSync('npm rebuild', {
|
||||||
|
cwd: packagePath,
|
||||||
|
stdio: 'pipe', // Capture output instead of printing
|
||||||
|
timeout: 120000 // 2 minute timeout
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log rebuild output for debugging
|
||||||
|
const outputStr = output?.toString().trim();
|
||||||
|
if (outputStr) {
|
||||||
|
logger.debug(`[NativeModule] Rebuild output:\n${outputStr}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success('[NativeModule] Rebuild completed successfully');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
// Include stdout/stderr from the failed command for troubleshooting
|
||||||
|
const stdout = error.stdout?.toString().trim();
|
||||||
|
const stderr = error.stderr?.toString().trim();
|
||||||
|
let errorDetails = `[NativeModule] Rebuild failed: ${error.message}`;
|
||||||
|
if (stdout) {
|
||||||
|
errorDetails += `\n[NativeModule] stdout: ${stdout}`;
|
||||||
|
}
|
||||||
|
if (stderr) {
|
||||||
|
errorDetails += `\n[NativeModule] stderr: ${stderr}`;
|
||||||
|
}
|
||||||
|
logger.error(errorDetails);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to auto-rebuild a native module from an error
|
||||||
|
* @param {Error} error - The NODE_MODULE_VERSION error
|
||||||
|
* @returns {boolean} True if rebuild succeeded, false otherwise
|
||||||
|
*/
|
||||||
|
export function attemptAutoRebuild(error) {
|
||||||
|
const nodePath = extractModulePath(error);
|
||||||
|
if (!nodePath) {
|
||||||
|
logger.error('[NativeModule] Could not extract module path from error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const packagePath = findPackageRoot(nodePath);
|
||||||
|
if (!packagePath) {
|
||||||
|
logger.error('[NativeModule] Could not find package root');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn('[NativeModule] Native module version mismatch detected');
|
||||||
|
logger.info('[NativeModule] Attempting automatic rebuild...');
|
||||||
|
|
||||||
|
return rebuildModule(packagePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively clear a module and its dependencies from the require cache
|
||||||
|
* This is needed after rebuilding a native module to force re-import
|
||||||
|
* @param {string} modulePath - Resolved path to the module
|
||||||
|
* @param {object} cache - The require.cache object
|
||||||
|
* @param {Set} [visited] - Set of already-visited paths to prevent cycles
|
||||||
|
*/
|
||||||
|
export function clearRequireCache(modulePath, cache, visited = new Set()) {
|
||||||
|
if (visited.has(modulePath)) return;
|
||||||
|
visited.add(modulePath);
|
||||||
|
|
||||||
|
const mod = cache[modulePath];
|
||||||
|
if (!mod) return;
|
||||||
|
|
||||||
|
// Recursively clear children first
|
||||||
|
if (mod.children) {
|
||||||
|
for (const child of mod.children) {
|
||||||
|
clearRequireCache(child.id, cache, visited);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from parent's children array
|
||||||
|
if (mod.parent && mod.parent.children) {
|
||||||
|
const idx = mod.parent.children.indexOf(mod);
|
||||||
|
if (idx !== -1) {
|
||||||
|
mod.parent.children.splice(idx, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete from cache
|
||||||
|
delete cache[modulePath];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
isModuleVersionError,
|
||||||
|
extractModulePath,
|
||||||
|
findPackageRoot,
|
||||||
|
rebuildModule,
|
||||||
|
attemptAutoRebuild,
|
||||||
|
clearRequireCache
|
||||||
|
};
|
||||||
@@ -14,7 +14,10 @@ const tests = [
|
|||||||
{ name: 'Multi-turn Tools (Streaming)', file: 'test-multiturn-thinking-tools-streaming.cjs' },
|
{ name: 'Multi-turn Tools (Streaming)', file: 'test-multiturn-thinking-tools-streaming.cjs' },
|
||||||
{ name: 'Interleaved Thinking', file: 'test-interleaved-thinking.cjs' },
|
{ name: 'Interleaved Thinking', file: 'test-interleaved-thinking.cjs' },
|
||||||
{ name: 'Image Support', file: 'test-images.cjs' },
|
{ name: 'Image Support', file: 'test-images.cjs' },
|
||||||
{ name: 'Prompt Caching', file: 'test-caching-streaming.cjs' }
|
{ name: 'Prompt Caching', file: 'test-caching-streaming.cjs' },
|
||||||
|
{ name: 'Cross-Model Thinking', file: 'test-cross-model-thinking.cjs' },
|
||||||
|
{ name: 'OAuth No-Browser Mode', file: 'test-oauth-no-browser.cjs' },
|
||||||
|
{ name: 'Empty Response Retry', file: 'test-empty-response-retry.cjs' }
|
||||||
];
|
];
|
||||||
|
|
||||||
async function runTest(test) {
|
async function runTest(test) {
|
||||||
|
|||||||
114
tests/test-empty-response-retry.cjs
Normal file
114
tests/test-empty-response-retry.cjs
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* Test for Empty Response Retry Mechanism
|
||||||
|
*
|
||||||
|
* Tests the retry logic when API returns empty responses
|
||||||
|
* Note: This is a manual/integration test that requires a real proxy server
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { streamRequest } = require('./helpers/http-client.cjs');
|
||||||
|
const { TEST_MODELS } = require('./helpers/test-models.cjs');
|
||||||
|
|
||||||
|
async function testEmptyResponseRetry() {
|
||||||
|
console.log('\n============================================================');
|
||||||
|
console.log('EMPTY RESPONSE RETRY TEST');
|
||||||
|
console.log('Tests retry mechanism for empty API responses');
|
||||||
|
console.log('============================================================\n');
|
||||||
|
|
||||||
|
console.log('Note: This test validates the retry mechanism exists in code');
|
||||||
|
console.log(' Real empty response scenarios require specific API conditions\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('TEST 1: Verify retry code exists and compiles');
|
||||||
|
console.log('----------------------------------------');
|
||||||
|
|
||||||
|
// Import the modules to ensure they compile
|
||||||
|
const errors = await import('../src/errors.js');
|
||||||
|
const streamer = await import('../src/cloudcode/sse-streamer.js');
|
||||||
|
const handler = await import('../src/cloudcode/streaming-handler.js');
|
||||||
|
const constants = await import('../src/constants.js');
|
||||||
|
|
||||||
|
console.log(' ✓ EmptyResponseError class exists:', typeof errors.EmptyResponseError === 'function');
|
||||||
|
console.log(' ✓ isEmptyResponseError helper exists:', typeof errors.isEmptyResponseError === 'function');
|
||||||
|
console.log(' ✓ MAX_EMPTY_RESPONSE_RETRIES constant:', constants.MAX_EMPTY_RESPONSE_RETRIES);
|
||||||
|
console.log(' ✓ sse-streamer.js imports EmptyResponseError');
|
||||||
|
console.log(' ✓ streaming-handler.js imports isEmptyResponseError');
|
||||||
|
console.log(' Result: PASS\n');
|
||||||
|
|
||||||
|
console.log('TEST 2: Basic request still works (no regression)');
|
||||||
|
console.log('----------------------------------------');
|
||||||
|
|
||||||
|
const response = await streamRequest({
|
||||||
|
model: TEST_MODELS.gemini,
|
||||||
|
messages: [{ role: 'user', content: 'Say hi in 3 words' }],
|
||||||
|
max_tokens: 20,
|
||||||
|
stream: true
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(` Response received: ${response.content.length > 0 ? 'YES' : 'NO'}`);
|
||||||
|
console.log(` Content blocks: ${response.content.length}`);
|
||||||
|
console.log(` Events count: ${response.events.length}`);
|
||||||
|
|
||||||
|
if (response.content.length > 0) {
|
||||||
|
console.log(' Result: PASS\n');
|
||||||
|
} else {
|
||||||
|
console.log(' Result: FAIL - No content received\n');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('TEST 3: Error class behavior');
|
||||||
|
console.log('----------------------------------------');
|
||||||
|
|
||||||
|
const testError = new errors.EmptyResponseError('Test message');
|
||||||
|
console.log(` Error name: ${testError.name}`);
|
||||||
|
console.log(` Error code: ${testError.code}`);
|
||||||
|
console.log(` Error retryable: ${testError.retryable}`);
|
||||||
|
console.log(` isEmptyResponseError recognizes it: ${errors.isEmptyResponseError(testError)}`);
|
||||||
|
|
||||||
|
const genericError = new Error('Generic error');
|
||||||
|
console.log(` isEmptyResponseError rejects generic: ${!errors.isEmptyResponseError(genericError)}`);
|
||||||
|
|
||||||
|
if (testError.name === 'EmptyResponseError' &&
|
||||||
|
testError.code === 'EMPTY_RESPONSE' &&
|
||||||
|
testError.retryable === true &&
|
||||||
|
errors.isEmptyResponseError(testError) &&
|
||||||
|
!errors.isEmptyResponseError(genericError)) {
|
||||||
|
console.log(' Result: PASS\n');
|
||||||
|
} else {
|
||||||
|
console.log(' Result: FAIL\n');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('============================================================');
|
||||||
|
console.log('SUMMARY');
|
||||||
|
console.log('============================================================');
|
||||||
|
console.log(' [PASS] Retry code exists and compiles');
|
||||||
|
console.log(' [PASS] Basic requests work (no regression)');
|
||||||
|
console.log(' [PASS] Error class behavior correct');
|
||||||
|
console.log('\n============================================================');
|
||||||
|
console.log('[EMPTY RESPONSE RETRY] ALL TESTS PASSED');
|
||||||
|
console.log('============================================================\n');
|
||||||
|
|
||||||
|
console.log('Notes:');
|
||||||
|
console.log(' - Retry mechanism is in place and ready');
|
||||||
|
console.log(' - Real empty responses will trigger automatic retry');
|
||||||
|
console.log(' - Check logs for "Empty response, retry X/Y" messages');
|
||||||
|
console.log(' - Production testing shows 88% recovery rate\n');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('\n[ERROR] Test failed:', error.message);
|
||||||
|
console.error(error.stack);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run tests
|
||||||
|
testEmptyResponseRetry()
|
||||||
|
.then(success => {
|
||||||
|
process.exit(success ? 0 : 1);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Fatal error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -106,7 +106,7 @@ Please do this step by step, reading each file before modifying.`
|
|||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: `Read src/config.js and tell me if debug mode is enabled.`
|
content: `Analyze the src/config.js file structure and explain the security implications of each setting. What are the potential risks if this config were exposed in production?`
|
||||||
},
|
},
|
||||||
{ role: 'assistant', content: result.content },
|
{ role: 'assistant', content: result.content },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -74,9 +74,10 @@ async function runTestsForModel(family, model) {
|
|||||||
|
|
||||||
// For Claude: signature is on thinking block and comes via signature_delta events
|
// For Claude: signature is on thinking block and comes via signature_delta events
|
||||||
// For Gemini: signature is on tool_use block (no signature_delta events)
|
// For Gemini: signature is on tool_use block (no signature_delta events)
|
||||||
|
// Note: Some models may skip thinking on simple first requests - signature + tool use is key
|
||||||
const hasSignature = content.hasSignature || events.signatureDeltas > 0;
|
const hasSignature = content.hasSignature || events.signatureDeltas > 0;
|
||||||
const passed = content.hasThinking && hasSignature && content.hasToolUse;
|
const passed = hasSignature && content.hasToolUse;
|
||||||
results.push({ name: 'Turn 1: Thinking + Signature + Tool Use', passed });
|
results.push({ name: 'Turn 1: Signature + Tool Use', passed });
|
||||||
if (!passed) allPassed = false;
|
if (!passed) allPassed = false;
|
||||||
|
|
||||||
if (content.hasToolUse) {
|
if (content.hasToolUse) {
|
||||||
@@ -138,8 +139,10 @@ drwxr-xr-x 4 user staff 128 Dec 19 10:00 tests`
|
|||||||
console.log(` Response: "${content.text[0].text.substring(0, 100)}..."`);
|
console.log(` Response: "${content.text[0].text.substring(0, 100)}..."`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const passed = content.hasThinking && content.hasText && events.textDeltas > 0;
|
// Text or tool use response is acceptable
|
||||||
results.push({ name: 'Turn 2: Thinking + Text response', passed });
|
// Note: Models may skip thinking on obvious responses - this is valid behavior
|
||||||
|
const passed = (content.hasText && events.textDeltas > 0) || content.hasToolUse;
|
||||||
|
results.push({ name: 'Turn 2: Text or Tool response', passed });
|
||||||
if (!passed) allPassed = false;
|
if (!passed) allPassed = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ async function runTestsForModel(family, model) {
|
|||||||
content: [{
|
content: [{
|
||||||
type: 'tool_result',
|
type: 'tool_result',
|
||||||
tool_use_id: toolUseBlock.id,
|
tool_use_id: toolUseBlock.id,
|
||||||
content: 'Found files:\n- /project/package.json\n- /project/packages/core/package.json'
|
content: 'Found files:\n- /project/package.json (root, 2.3KB, modified 2 days ago)\n- /project/packages/core/package.json (workspace, 1.1KB, modified 1 hour ago)\n- /project/packages/legacy/package.json (deprecated, 0.8KB, modified 1 year ago)\n- /project/node_modules/lodash/package.json (dependency, 3.2KB)\n\nIMPORTANT: Before proceeding, reason through which files are most relevant. Consider: Are node_modules relevant? Should deprecated packages be included? Which workspace packages matter for the user\'s question about dependencies?'
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -128,10 +128,10 @@ async function runTestsForModel(family, model) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Either tool use (to read file) or text response is acceptable
|
// Either tool use (to read file) or text response is acceptable
|
||||||
const passed = expectThinking
|
// Note: Claude may skip thinking on obvious next steps - this is valid behavior
|
||||||
? (analysis.hasThinking && (analysis.hasToolUse || analysis.hasText))
|
// We only require thinking on the first turn to verify signatures work
|
||||||
: (analysis.hasToolUse || analysis.hasText);
|
const passed = analysis.hasToolUse || analysis.hasText;
|
||||||
results.push({ name: 'Turn 2: Thinking + (Tool or Text)', passed });
|
results.push({ name: 'Turn 2: Tool or Text response', passed });
|
||||||
if (!passed) allPassed = false;
|
if (!passed) allPassed = false;
|
||||||
|
|
||||||
if (analysis.hasToolUse) {
|
if (analysis.hasToolUse) {
|
||||||
|
|||||||
217
tests/test-oauth-no-browser.cjs
Normal file
217
tests/test-oauth-no-browser.cjs
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
/**
|
||||||
|
* OAuth No-Browser Mode Unit Tests
|
||||||
|
*
|
||||||
|
* Tests the extractCodeFromInput() function which enables OAuth authentication
|
||||||
|
* on headless servers without a desktop environment.
|
||||||
|
*
|
||||||
|
* ============================================================================
|
||||||
|
* FEATURE: --no-browser OAuth Mode
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* PURPOSE:
|
||||||
|
* Allow users to add Google accounts on remote servers (headless Linux,
|
||||||
|
* Docker containers, SSH sessions) where automatic browser opening is
|
||||||
|
* not possible.
|
||||||
|
*
|
||||||
|
* USAGE:
|
||||||
|
* npm run accounts:add -- --no-browser
|
||||||
|
*
|
||||||
|
* USER FLOW:
|
||||||
|
* 1. User runs command on headless server
|
||||||
|
* 2. System displays Google OAuth URL
|
||||||
|
* 3. User opens URL on another device (phone/laptop) with a browser
|
||||||
|
* 4. User signs in to Google and authorizes the app
|
||||||
|
* 5. Browser redirects to localhost (page won't load - this is expected)
|
||||||
|
* 6. User copies the redirect URL or authorization code from address bar
|
||||||
|
* 7. User pastes into server terminal
|
||||||
|
* 8. System extracts code using extractCodeFromInput() (tested here)
|
||||||
|
* 9. Account is added successfully
|
||||||
|
*
|
||||||
|
* FUNCTION UNDER TEST:
|
||||||
|
* extractCodeFromInput(input: string) => { code: string, state: string|null }
|
||||||
|
*
|
||||||
|
* Accepts either:
|
||||||
|
* - Full callback URL: http://localhost:51121/callback?code=xxx&state=yyy
|
||||||
|
* - Raw authorization code: 4/0AQSTgQG...
|
||||||
|
*
|
||||||
|
* Throws on:
|
||||||
|
* - Empty/null input
|
||||||
|
* - Too short input (< 10 chars)
|
||||||
|
* - URL with OAuth error parameter
|
||||||
|
* - URL without code parameter
|
||||||
|
*
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* Run: node tests/test-oauth-no-browser.cjs
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Note: Using dynamic import because oauth.js is ESM
|
||||||
|
async function runTests() {
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
console.log('OAUTH NO-BROWSER MODE UNIT TESTS');
|
||||||
|
console.log('Testing: extractCodeFromInput()');
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Import the ESM module
|
||||||
|
const { extractCodeFromInput } = await import('../src/auth/oauth.js');
|
||||||
|
|
||||||
|
let allPassed = true;
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to run a single test case
|
||||||
|
* @param {string} name - Test name
|
||||||
|
* @param {Function} testFn - Test function that returns { passed, message }
|
||||||
|
*/
|
||||||
|
async function test(name, testFn) {
|
||||||
|
try {
|
||||||
|
const { passed, message } = await testFn();
|
||||||
|
results.push({ name, passed, message });
|
||||||
|
const status = passed ? 'PASS' : 'FAIL';
|
||||||
|
console.log(` [${status}] ${name}`);
|
||||||
|
if (message) console.log(` ${message}`);
|
||||||
|
if (!passed) allPassed = false;
|
||||||
|
} catch (error) {
|
||||||
|
results.push({ name, passed: false, message: error.message });
|
||||||
|
console.log(` [FAIL] ${name}`);
|
||||||
|
console.log(` Error: ${error.message}`);
|
||||||
|
allPassed = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Test Group 1: Valid URL Inputs =====
|
||||||
|
console.log('\n--- Valid URL Inputs ---');
|
||||||
|
|
||||||
|
await test('Parse full callback URL with code and state', () => {
|
||||||
|
const input = 'http://localhost:51121/oauth-callback?code=4/0AQSTg123&state=abc123';
|
||||||
|
const result = extractCodeFromInput(input);
|
||||||
|
const passed = result.code === '4/0AQSTg123' && result.state === 'abc123';
|
||||||
|
return { passed, message: `code=${result.code}, state=${result.state}` };
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('Parse URL with only code (no state)', () => {
|
||||||
|
const input = 'http://localhost:51121/oauth-callback?code=4/0AQSTg456';
|
||||||
|
const result = extractCodeFromInput(input);
|
||||||
|
const passed = result.code === '4/0AQSTg456' && result.state === null;
|
||||||
|
return { passed, message: `code=${result.code}, state=${result.state}` };
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('Parse HTTPS URL', () => {
|
||||||
|
const input = 'https://localhost:51121/callback?code=secureCode123&state=xyz';
|
||||||
|
const result = extractCodeFromInput(input);
|
||||||
|
const passed = result.code === 'secureCode123';
|
||||||
|
return { passed, message: `code=${result.code}` };
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('Parse URL with additional query params', () => {
|
||||||
|
const input = 'http://localhost:51121/?code=myCode&state=myState&scope=email';
|
||||||
|
const result = extractCodeFromInput(input);
|
||||||
|
const passed = result.code === 'myCode' && result.state === 'myState';
|
||||||
|
return { passed, message: `code=${result.code}, state=${result.state}` };
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== Test Group 2: Raw Code Inputs =====
|
||||||
|
console.log('\n--- Raw Authorization Code Inputs ---');
|
||||||
|
|
||||||
|
await test('Parse raw authorization code (Google format)', () => {
|
||||||
|
const input = '4/0AQSTgQGxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx';
|
||||||
|
const result = extractCodeFromInput(input);
|
||||||
|
const passed = result.code === input && result.state === null;
|
||||||
|
return { passed, message: `code length=${result.code.length}` };
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('Parse raw code with whitespace (should trim)', () => {
|
||||||
|
const input = ' 4/0AQSTgQGcode123 \n';
|
||||||
|
const result = extractCodeFromInput(input);
|
||||||
|
const passed = result.code === '4/0AQSTgQGcode123' && result.state === null;
|
||||||
|
return { passed, message: `trimmed code=${result.code}` };
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== Test Group 3: Error Cases =====
|
||||||
|
console.log('\n--- Error Handling ---');
|
||||||
|
|
||||||
|
await test('Throw on empty input', () => {
|
||||||
|
try {
|
||||||
|
extractCodeFromInput('');
|
||||||
|
return { passed: false, message: 'Should have thrown' };
|
||||||
|
} catch (e) {
|
||||||
|
return { passed: e.message.includes('No input'), message: e.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('Throw on null input', () => {
|
||||||
|
try {
|
||||||
|
extractCodeFromInput(null);
|
||||||
|
return { passed: false, message: 'Should have thrown' };
|
||||||
|
} catch (e) {
|
||||||
|
return { passed: e.message.includes('No input'), message: e.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('Throw on too short code', () => {
|
||||||
|
try {
|
||||||
|
extractCodeFromInput('abc');
|
||||||
|
return { passed: false, message: 'Should have thrown' };
|
||||||
|
} catch (e) {
|
||||||
|
return { passed: e.message.includes('too short'), message: e.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('Throw on OAuth error in URL', () => {
|
||||||
|
try {
|
||||||
|
const input = 'http://localhost:51121/?error=access_denied&error_description=User%20denied';
|
||||||
|
extractCodeFromInput(input);
|
||||||
|
return { passed: false, message: 'Should have thrown' };
|
||||||
|
} catch (e) {
|
||||||
|
return { passed: e.message.includes('OAuth error'), message: e.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('Throw on URL without code param', () => {
|
||||||
|
try {
|
||||||
|
extractCodeFromInput('http://localhost:51121/callback?state=onlyState');
|
||||||
|
return { passed: false, message: 'Should have thrown' };
|
||||||
|
} catch (e) {
|
||||||
|
return { passed: e.message.includes('No authorization code'), message: e.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== Test Group 4: Edge Cases =====
|
||||||
|
console.log('\n--- Edge Cases ---');
|
||||||
|
|
||||||
|
await test('Handle URL-encoded characters in code', () => {
|
||||||
|
const input = 'http://localhost:51121/?code=4%2F0AQSTg%2B%2B&state=test';
|
||||||
|
const result = extractCodeFromInput(input);
|
||||||
|
// URL class automatically decodes
|
||||||
|
const passed = result.code === '4/0AQSTg++';
|
||||||
|
return { passed, message: `decoded code=${result.code}` };
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('Accept minimum valid code length (10 chars)', () => {
|
||||||
|
const input = '1234567890';
|
||||||
|
const result = extractCodeFromInput(input);
|
||||||
|
const passed = result.code === input;
|
||||||
|
return { passed, message: `code=${result.code}` };
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== Summary =====
|
||||||
|
console.log('\n' + '='.repeat(60));
|
||||||
|
console.log('SUMMARY');
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
|
||||||
|
const passed = results.filter(r => r.passed).length;
|
||||||
|
const failed = results.filter(r => !r.passed).length;
|
||||||
|
console.log(` Total: ${results.length} | Passed: ${passed} | Failed: ${failed}`);
|
||||||
|
|
||||||
|
console.log('\n' + '='.repeat(60));
|
||||||
|
console.log(allPassed ? 'ALL TESTS PASSED' : 'SOME TESTS FAILED');
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
|
||||||
|
process.exit(allPassed ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
runTests().catch(err => {
|
||||||
|
console.error('Test suite failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user