initial commit

This commit is contained in:
Badri Narayanan S
2025-12-18 00:06:00 +05:30
commit 52d72b7bff
799 changed files with 141087 additions and 0 deletions

335
src/cloudcode-client.js Normal file
View File

@@ -0,0 +1,335 @@
/**
* Cloud Code Client for Antigravity
*
* Communicates with Google's Cloud Code internal API using the
* v1internal:streamGenerateContent endpoint with proper request wrapping.
*
* Based on: https://github.com/NoeFabris/opencode-antigravity-auth
*/
import crypto from 'crypto';
import { getToken, refreshToken } from './token-extractor.js';
import {
ANTIGRAVITY_ENDPOINT_FALLBACKS,
ANTIGRAVITY_HEADERS,
AVAILABLE_MODELS,
DEFAULT_PROJECT_ID,
STREAMING_CHUNK_SIZE
} from './constants.js';
import {
mapModelName,
convertAnthropicToGoogle,
convertGoogleToAnthropic,
convertStreamingChunk
} from './format-converter.js';
// Cache the project ID
let cachedProject = null;
/**
* Get the user's cloudaicompanion project from the API
*/
export async function getProject(token) {
if (cachedProject) {
return cachedProject;
}
console.log('[CloudCode] Getting project from loadCodeAssist...');
// Try each endpoint
for (const endpoint of ANTIGRAVITY_ENDPOINT_FALLBACKS) {
try {
const response = await fetch(`${endpoint}/v1internal:loadCodeAssist`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
...ANTIGRAVITY_HEADERS
},
body: JSON.stringify({
metadata: {
ideType: 'IDE_UNSPECIFIED',
platform: 'PLATFORM_UNSPECIFIED',
pluginType: 'GEMINI'
}
})
});
if (!response.ok) {
console.log(`[CloudCode] loadCodeAssist failed at ${endpoint}: ${response.status}`);
continue;
}
const data = await response.json();
// Extract project ID from response
if (typeof data.cloudaicompanionProject === 'string' && data.cloudaicompanionProject) {
cachedProject = data.cloudaicompanionProject;
console.log(`[CloudCode] Got project: ${cachedProject}`);
return cachedProject;
}
if (data.cloudaicompanionProject?.id) {
cachedProject = data.cloudaicompanionProject.id;
console.log(`[CloudCode] Got project: ${cachedProject}`);
return cachedProject;
}
console.log(`[CloudCode] No project in response from ${endpoint}`);
} catch (error) {
console.log(`[CloudCode] Error at ${endpoint}:`, error.message);
}
}
// Use default project if discovery fails
console.log(`[CloudCode] Using default project: ${DEFAULT_PROJECT_ID}`);
cachedProject = DEFAULT_PROJECT_ID;
return cachedProject;
}
/**
* Clear the cached project
*/
export function clearProjectCache() {
cachedProject = null;
}
/**
* Refresh token and get project - helper to avoid duplicate logic
*/
async function refreshAndGetProject() {
await refreshToken();
const token = await getToken();
clearProjectCache();
const project = await getProject(token);
return { token, project };
}
/**
* Build the wrapped request body for Cloud Code API
*/
function buildCloudCodeRequest(anthropicRequest, projectId) {
const model = mapModelName(anthropicRequest.model);
const googleRequest = convertAnthropicToGoogle(anthropicRequest);
// Add session ID
googleRequest.sessionId = '-' + Math.floor(Math.random() * 9000000000000000000).toString();
const payload = {
project: projectId,
model: model,
request: googleRequest,
userAgent: 'antigravity',
requestId: 'agent-' + crypto.randomUUID()
};
// Debug: log if tools are present
if (googleRequest.tools) {
console.log('[CloudCode] Tools in request:', JSON.stringify(googleRequest.tools).substring(0, 500));
}
return payload;
}
/**
* Send a non-streaming request to Cloud Code
*/
export async function sendMessage(anthropicRequest) {
let token = await getToken();
let project;
try {
project = await getProject(token);
} catch (err) {
console.log('[CloudCode] Project fetch failed, refreshing token...');
({ token, project } = await refreshAndGetProject());
}
const model = mapModelName(anthropicRequest.model);
const payload = buildCloudCodeRequest(anthropicRequest, project);
console.log(`[CloudCode] Sending request for model: ${model}`);
// Try each endpoint
let lastError = null;
for (const endpoint of ANTIGRAVITY_ENDPOINT_FALLBACKS) {
try {
const url = `${endpoint}/v1internal:generateContent`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
...ANTIGRAVITY_HEADERS
},
body: JSON.stringify(payload)
});
if (!response.ok) {
const errorText = await response.text();
console.log(`[CloudCode] Error at ${endpoint}: ${response.status} - ${errorText}`);
// Handle auth errors
if (response.status === 401) {
console.log('[CloudCode] Auth error, refreshing token...');
({ token, project } = await refreshAndGetProject());
// Retry with new token
payload.project = project;
continue;
}
// Handle rate limiting
if (response.status === 429) {
lastError = new Error(`Rate limited: ${errorText}`);
continue;
}
// Try next endpoint for 4xx/5xx errors
if (response.status >= 400) {
lastError = new Error(`API error ${response.status}: ${errorText}`);
continue;
}
}
const data = await response.json();
console.log('[CloudCode] Response received');
return convertGoogleToAnthropic(data, anthropicRequest.model);
} catch (error) {
console.log(`[CloudCode] Error at ${endpoint}:`, error.message);
lastError = error;
}
}
throw lastError || new Error('All endpoints failed');
}
/**
* Send a streaming request to Cloud Code
* Note: Antigravity's streaming API doesn't actually stream text incrementally,
* so we use the non-streaming API and simulate SSE events for client compatibility.
*/
export async function* sendMessageStream(anthropicRequest) {
// Get the full response first
const fullResponse = await sendMessage(anthropicRequest);
console.log('[CloudCode] Simulating stream from full response');
// Emit message_start
yield {
type: 'message_start',
message: {
id: fullResponse.id,
type: 'message',
role: 'assistant',
content: [],
model: fullResponse.model,
stop_reason: null,
stop_sequence: null,
usage: { input_tokens: fullResponse.usage?.input_tokens || 0, output_tokens: 0 }
}
};
// Process each content block
let blockIndex = 0;
for (const block of fullResponse.content) {
if (block.type === 'text') {
// content_block_start
yield {
type: 'content_block_start',
index: blockIndex,
content_block: { type: 'text', text: '' }
};
// Stream text in chunks for a more realistic streaming experience
const text = block.text;
for (let i = 0; i < text.length; i += STREAMING_CHUNK_SIZE) {
const chunk = text.slice(i, i + STREAMING_CHUNK_SIZE);
yield {
type: 'content_block_delta',
index: blockIndex,
delta: { type: 'text_delta', text: chunk }
};
}
// content_block_stop
yield {
type: 'content_block_stop',
index: blockIndex
};
blockIndex++;
} else if (block.type === 'tool_use') {
// content_block_start for tool_use
yield {
type: 'content_block_start',
index: blockIndex,
content_block: {
type: 'tool_use',
id: block.id,
name: block.name,
input: {}
}
};
// Send input as delta
yield {
type: 'content_block_delta',
index: blockIndex,
delta: {
type: 'input_json_delta',
partial_json: JSON.stringify(block.input)
}
};
// content_block_stop
yield {
type: 'content_block_stop',
index: blockIndex
};
blockIndex++;
}
}
// message_delta
yield {
type: 'message_delta',
delta: {
stop_reason: fullResponse.stop_reason,
stop_sequence: fullResponse.stop_sequence
},
usage: { output_tokens: fullResponse.usage?.output_tokens || 0 }
};
// message_stop
yield { type: 'message_stop' };
}
/**
* List available models
*/
export function listModels() {
return {
object: 'list',
data: AVAILABLE_MODELS.map(m => ({
id: m.id,
object: 'model',
created: Math.floor(Date.now() / 1000),
owned_by: 'anthropic',
description: m.description
}))
};
}
export default {
sendMessage,
sendMessageStream,
listModels,
clearProjectCache,
getProject
};

92
src/constants.js Normal file
View File

@@ -0,0 +1,92 @@
/**
* Constants for Antigravity Cloud Code API integration
* Based on: https://github.com/NoeFabris/opencode-antigravity-auth
*/
// Cloud Code API endpoints (in fallback order)
export const ANTIGRAVITY_ENDPOINT_DAILY = 'https://daily-cloudcode-pa.sandbox.googleapis.com';
export const ANTIGRAVITY_ENDPOINT_AUTOPUSH = 'https://autopush-cloudcode-pa.sandbox.googleapis.com';
export const ANTIGRAVITY_ENDPOINT_PROD = 'https://cloudcode-pa.googleapis.com';
// Endpoint fallback order (daily → autopush → prod)
export const ANTIGRAVITY_ENDPOINT_FALLBACKS = [
ANTIGRAVITY_ENDPOINT_DAILY,
ANTIGRAVITY_ENDPOINT_AUTOPUSH,
ANTIGRAVITY_ENDPOINT_PROD
];
// Primary endpoint
export const ANTIGRAVITY_ENDPOINT = ANTIGRAVITY_ENDPOINT_DAILY;
// Required headers for Antigravity API requests
export const ANTIGRAVITY_HEADERS = {
'User-Agent': 'antigravity/1.11.5 darwin/arm64',
'X-Goog-Api-Client': 'google-cloud-sdk vscode_cloudshelleditor/0.1',
'Client-Metadata': JSON.stringify({
ideType: 'IDE_UNSPECIFIED',
platform: 'PLATFORM_UNSPECIFIED',
pluginType: 'GEMINI'
})
};
// Model name mappings: Anthropic format → Antigravity format
export const MODEL_MAPPINGS = {
// Claude models
'claude-3-opus-20240229': 'claude-opus-4-5-thinking',
'claude-3-5-opus-20240229': 'claude-opus-4-5-thinking',
'claude-3-5-sonnet-20241022': 'claude-sonnet-4-5',
'claude-3-5-sonnet-20240620': 'claude-sonnet-4-5',
'claude-3-sonnet-20240229': 'claude-sonnet-4-5',
'claude-sonnet-4-5': 'claude-sonnet-4-5',
'claude-sonnet-4-5-thinking': 'claude-sonnet-4-5-thinking',
'claude-opus-4-5-thinking': 'claude-opus-4-5-thinking'
};
// Available models exposed by this proxy
export const AVAILABLE_MODELS = [
{
id: 'claude-sonnet-4-5',
name: 'Claude Sonnet 4.5 (Antigravity)',
description: 'Claude Sonnet 4.5 via Antigravity Cloud Code',
context: 200000,
output: 64000
},
{
id: 'claude-sonnet-4-5-thinking',
name: 'Claude Sonnet 4.5 Thinking (Antigravity)',
description: 'Claude Sonnet 4.5 with extended thinking via Antigravity',
context: 200000,
output: 64000
},
{
id: 'claude-opus-4-5-thinking',
name: 'Claude Opus 4.5 Thinking (Antigravity)',
description: 'Claude Opus 4.5 with extended thinking via Antigravity',
context: 200000,
output: 64000
}
];
// Default project ID if none can be discovered
export const DEFAULT_PROJECT_ID = 'rising-fact-p41fc';
// Centralized configuration constants
export const STREAMING_CHUNK_SIZE = 20;
export const TOKEN_REFRESH_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
export const REQUEST_BODY_LIMIT = '50mb';
export const ANTIGRAVITY_AUTH_PORT = 9092;
export const DEFAULT_PORT = 8080;
export default {
ANTIGRAVITY_ENDPOINT,
ANTIGRAVITY_ENDPOINT_FALLBACKS,
ANTIGRAVITY_HEADERS,
MODEL_MAPPINGS,
AVAILABLE_MODELS,
DEFAULT_PROJECT_ID,
STREAMING_CHUNK_SIZE,
TOKEN_REFRESH_INTERVAL_MS,
REQUEST_BODY_LIMIT,
ANTIGRAVITY_AUTH_PORT,
DEFAULT_PORT
};

489
src/format-converter.js Normal file
View File

@@ -0,0 +1,489 @@
/**
* Format Converter
* Converts between Anthropic Messages API format and Google Generative AI format
*
* Based on patterns from:
* - https://github.com/NoeFabris/opencode-antigravity-auth
* - https://github.com/1rgs/claude-code-proxy
*/
import crypto from 'crypto';
import { MODEL_MAPPINGS } from './constants.js';
/**
* Map Anthropic model name to Antigravity model name
*/
export function mapModelName(anthropicModel) {
return MODEL_MAPPINGS[anthropicModel] || anthropicModel;
}
/**
* Convert Anthropic message content to Google Generative AI parts
*/
function convertContentToParts(content, isClaudeModel = false) {
if (typeof content === 'string') {
return [{ text: content }];
}
if (!Array.isArray(content)) {
return [{ text: String(content) }];
}
const parts = [];
for (const block of content) {
if (block.type === 'text') {
parts.push({ text: block.text });
} else if (block.type === 'image') {
// Handle image content
if (block.source?.type === 'base64') {
// Base64-encoded image
parts.push({
inlineData: {
mimeType: block.source.media_type,
data: block.source.data
}
});
} else if (block.source?.type === 'url') {
// URL-referenced image
parts.push({
fileData: {
mimeType: block.source.media_type || 'image/jpeg',
fileUri: block.source.url
}
});
}
} else if (block.type === 'document') {
// Handle document content (e.g. PDF)
if (block.source?.type === 'base64') {
parts.push({
inlineData: {
mimeType: block.source.media_type,
data: block.source.data
}
});
} else if (block.source?.type === 'url') {
parts.push({
fileData: {
mimeType: block.source.media_type || 'application/pdf',
fileUri: block.source.url
}
});
}
} else if (block.type === 'tool_use') {
// Convert tool_use to functionCall (Google format)
// For Claude models, include the id field
const functionCall = {
name: block.name,
args: block.input || {}
};
if (isClaudeModel && block.id) {
functionCall.id = block.id;
}
parts.push({ functionCall });
} else if (block.type === 'tool_result') {
// Convert tool_result to functionResponse (Google format)
let responseContent = block.content;
if (typeof responseContent === 'string') {
responseContent = { result: responseContent };
} else if (Array.isArray(responseContent)) {
const texts = responseContent
.filter(c => c.type === 'text')
.map(c => c.text)
.join('\n');
responseContent = { result: texts };
}
const functionResponse = {
name: block.tool_use_id || 'unknown',
response: responseContent
};
// For Claude models, the id field must match the tool_use_id
if (isClaudeModel && block.tool_use_id) {
functionResponse.id = block.tool_use_id;
}
parts.push({ functionResponse });
} else if (block.type === 'thinking' || block.type === 'redacted_thinking') {
// Skip thinking blocks for Claude models - thinking is handled by the model itself
// For non-Claude models, convert to Google's thought format
if (!isClaudeModel && block.type === 'thinking') {
parts.push({
text: block.thinking,
thought: true
});
}
}
}
return parts.length > 0 ? parts : [{ text: '' }];
}
/**
* Convert Anthropic role to Google role
*/
function convertRole(role) {
if (role === 'assistant') return 'model';
if (role === 'user') return 'user';
return 'user'; // Default to user
}
/**
* Convert Anthropic Messages API request to the format expected by Cloud Code
*
* Uses Google Generative AI format, but for Claude models:
* - Keeps tool_result in Anthropic format (required by Claude API)
*
* @param {Object} anthropicRequest - Anthropic format request
* @returns {Object} Request body for Cloud Code API
*/
export function convertAnthropicToGoogle(anthropicRequest) {
const { messages, system, max_tokens, temperature, top_p, top_k, stop_sequences, tools, tool_choice, thinking } = anthropicRequest;
const isClaudeModel = (anthropicRequest.model || '').toLowerCase().includes('claude');
const googleRequest = {
contents: [],
generationConfig: {}
};
// Handle system instruction
if (system) {
let systemParts = [];
if (typeof system === 'string') {
systemParts = [{ text: system }];
} else if (Array.isArray(system)) {
// Filter for text blocks as system prompts are usually text
// Anthropic supports text blocks in system prompts
systemParts = system
.filter(block => block.type === 'text')
.map(block => ({ text: block.text }));
}
if (systemParts.length > 0) {
googleRequest.systemInstruction = {
parts: systemParts
};
}
}
// Convert messages to contents
for (const msg of messages) {
const parts = convertContentToParts(msg.content, isClaudeModel);
const content = {
role: convertRole(msg.role),
parts: parts
};
googleRequest.contents.push(content);
}
// Generation config
if (max_tokens) {
googleRequest.generationConfig.maxOutputTokens = max_tokens;
}
if (temperature !== undefined) {
googleRequest.generationConfig.temperature = temperature;
}
if (top_p !== undefined) {
googleRequest.generationConfig.topP = top_p;
}
if (top_k !== undefined) {
googleRequest.generationConfig.topK = top_k;
}
if (stop_sequences && stop_sequences.length > 0) {
googleRequest.generationConfig.stopSequences = stop_sequences;
}
// Extended thinking is disabled for Claude models
// The model itself (e.g., claude-opus-4-5-thinking) handles thinking internally
// Enabling thinkingConfig causes signature issues in multi-turn conversations
// Convert tools to Google format
if (tools && tools.length > 0) {
const functionDeclarations = tools.map((tool, idx) => {
// Extract name from various possible locations
const name = tool.name || tool.function?.name || tool.custom?.name || `tool-${idx}`;
// Extract description from various possible locations
const description = tool.description || tool.function?.description || tool.custom?.description || '';
// Extract schema from various possible locations
const schema = tool.input_schema
|| tool.function?.input_schema
|| tool.function?.parameters
|| tool.custom?.input_schema
|| tool.parameters
|| { type: 'object' };
return {
name: String(name).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64),
description: description,
parameters: sanitizeSchema(schema)
};
});
googleRequest.tools = [{ functionDeclarations }];
console.log('[FormatConverter] Tools:', JSON.stringify(googleRequest.tools).substring(0, 300));
}
return googleRequest;
}
/**
* Sanitize JSON schema for Google API compatibility
* Removes unsupported fields like additionalProperties
*/
function sanitizeSchema(schema) {
if (!schema || typeof schema !== 'object') {
return schema;
}
// Fields to skip entirely - not compatible with Claude's JSON Schema 2020-12
const UNSUPPORTED_FIELDS = new Set([
'$schema',
'additionalProperties',
'default',
'anyOf',
'allOf',
'oneOf',
'minLength',
'maxLength',
'pattern',
'format',
'minimum',
'maximum',
'exclusiveMinimum',
'exclusiveMaximum',
'minItems',
'maxItems',
'uniqueItems',
'minProperties',
'maxProperties',
'$id',
'$ref',
'$defs',
'definitions',
'patternProperties',
'unevaluatedProperties',
'unevaluatedItems',
'if',
'then',
'else',
'not',
'contentEncoding',
'contentMediaType'
]);
const sanitized = {};
for (const [key, value] of Object.entries(schema)) {
// Skip unsupported fields
if (UNSUPPORTED_FIELDS.has(key)) {
continue;
}
if (key === 'properties' && value && typeof value === 'object') {
sanitized.properties = {};
for (const [propKey, propValue] of Object.entries(value)) {
sanitized.properties[propKey] = sanitizeSchema(propValue);
}
} else if (key === 'items' && value && typeof value === 'object') {
// Handle items - could be object or array
if (Array.isArray(value)) {
sanitized.items = value.map(item => sanitizeSchema(item));
} else if (value.anyOf || value.allOf || value.oneOf) {
// Replace complex items with permissive type
sanitized.items = {};
} else {
sanitized.items = sanitizeSchema(value);
}
} else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
// Recursively sanitize nested objects that aren't properties/items
sanitized[key] = sanitizeSchema(value);
} else {
sanitized[key] = value;
}
}
return sanitized;
}
/**
* Convert Google Generative AI response to Anthropic Messages API format
*
* @param {Object} googleResponse - Google format response (the inner response object)
* @param {string} model - The model name used
* @param {boolean} isStreaming - Whether this is a streaming response
* @returns {Object} Anthropic format response
*/
export function convertGoogleToAnthropic(googleResponse, model, isStreaming = false) {
// Handle the response wrapper
const response = googleResponse.response || googleResponse;
const candidates = response.candidates || [];
const firstCandidate = candidates[0] || {};
const content = firstCandidate.content || {};
const parts = content.parts || [];
// Convert parts to Anthropic content blocks
const anthropicContent = [];
let toolCallCounter = 0;
for (const part of parts) {
if (part.text !== undefined) {
// Skip thinking blocks (thought: true) - the model handles thinking internally
if (part.thought === true) {
continue;
}
anthropicContent.push({
type: 'text',
text: part.text
});
} else if (part.functionCall) {
// Convert functionCall to tool_use
// Use the id from the response if available, otherwise generate one
anthropicContent.push({
type: 'tool_use',
id: part.functionCall.id || `toolu_${crypto.randomBytes(12).toString('hex')}`,
name: part.functionCall.name,
input: part.functionCall.args || {}
});
toolCallCounter++;
}
}
// Determine stop reason
const finishReason = firstCandidate.finishReason;
let stopReason = 'end_turn';
if (finishReason === 'STOP') {
stopReason = 'end_turn';
} else if (finishReason === 'MAX_TOKENS') {
stopReason = 'max_tokens';
} else if (finishReason === 'TOOL_USE' || toolCallCounter > 0) {
stopReason = 'tool_use';
}
// Extract usage metadata
const usageMetadata = response.usageMetadata || {};
return {
id: `msg_${crypto.randomBytes(16).toString('hex')}`,
type: 'message',
role: 'assistant',
content: anthropicContent.length > 0 ? anthropicContent : [{ type: 'text', text: '' }],
model: model,
stop_reason: stopReason,
stop_sequence: null,
usage: {
input_tokens: usageMetadata.promptTokenCount || 0,
output_tokens: usageMetadata.candidatesTokenCount || 0
}
};
}
/**
* Parse SSE data and extract the response object
*/
export function parseSSEResponse(data) {
if (!data || !data.startsWith('data:')) {
return null;
}
const jsonStr = data.slice(5).trim();
if (!jsonStr) {
return null;
}
try {
return JSON.parse(jsonStr);
} catch (e) {
console.error('[FormatConverter] Failed to parse SSE data:', e.message);
return null;
}
}
/**
* Convert a streaming chunk to Anthropic SSE format
*/
export function convertStreamingChunk(googleChunk, model, index, isFirst, isLast) {
const events = [];
const response = googleChunk.response || googleChunk;
const candidates = response.candidates || [];
const firstCandidate = candidates[0] || {};
const content = firstCandidate.content || {};
const parts = content.parts || [];
if (isFirst) {
// message_start event
events.push({
type: 'message_start',
message: {
id: `msg_${crypto.randomBytes(16).toString('hex')}`,
type: 'message',
role: 'assistant',
content: [],
model: model,
stop_reason: null,
stop_sequence: null,
usage: { input_tokens: 0, output_tokens: 0 }
}
});
// content_block_start event
events.push({
type: 'content_block_start',
index: 0,
content_block: { type: 'text', text: '' }
});
}
// Extract text from parts and emit as delta
for (const part of parts) {
if (part.text !== undefined) {
events.push({
type: 'content_block_delta',
index: 0,
delta: { type: 'text_delta', text: part.text }
});
}
}
if (isLast) {
// content_block_stop event
events.push({
type: 'content_block_stop',
index: 0
});
// Determine stop reason
const finishReason = firstCandidate.finishReason;
let stopReason = 'end_turn';
if (finishReason === 'MAX_TOKENS') {
stopReason = 'max_tokens';
}
// Extract usage
const usageMetadata = response.usageMetadata || {};
// message_delta event
events.push({
type: 'message_delta',
delta: { stop_reason: stopReason, stop_sequence: null },
usage: { output_tokens: usageMetadata.candidatesTokenCount || 0 }
});
// message_stop event
events.push({ type: 'message_stop' });
}
return events;
}
export default {
mapModelName,
convertAnthropicToGoogle,
convertGoogleToAnthropic,
parseSSEResponse,
convertStreamingChunk
};

36
src/index.js Normal file
View File

@@ -0,0 +1,36 @@
/**
* Antigravity Claude Proxy
* Entry point - starts the proxy server
*/
import app from './server.js';
import { DEFAULT_PORT } from './constants.js';
const PORT = process.env.PORT || DEFAULT_PORT;
app.listen(PORT, () => {
console.log(`
╔══════════════════════════════════════════════════════════════╗
║ Antigravity Claude Proxy Server ║
╠══════════════════════════════════════════════════════════════╣
║ ║
║ Server running at: http://localhost:${PORT}
║ ║
║ Endpoints: ║
║ POST /v1/messages - Anthropic Messages API ║
║ GET /v1/models - List available models ║
║ GET /health - Health check ║
║ POST /refresh-token - Force token refresh ║
║ ║
║ Usage with Claude Code: ║
║ export ANTHROPIC_BASE_URL=http://localhost:${PORT}
║ export ANTHROPIC_API_KEY=dummy ║
║ claude ║
║ ║
║ Prerequisites: ║
║ - Antigravity must be running ║
║ - Have a chat panel open in Antigravity ║
║ ║
╚══════════════════════════════════════════════════════════════╝
`);
});

236
src/server.js Normal file
View File

@@ -0,0 +1,236 @@
/**
* Express Server - Anthropic-compatible API
* Proxies to Google Cloud Code via Antigravity
*/
import express from 'express';
import cors from 'cors';
import { sendMessage, sendMessageStream, listModels, clearProjectCache, getProject } from './cloudcode-client.js';
import { getToken, forceRefresh } from './token-extractor.js';
import { AVAILABLE_MODELS, REQUEST_BODY_LIMIT } from './constants.js';
const app = express();
// Middleware
app.use(cors());
app.use(express.json({ limit: REQUEST_BODY_LIMIT }));
/**
* Parse error message to extract error type, status code, and user-friendly message
*/
function parseError(error) {
let errorType = 'api_error';
let statusCode = 500;
let errorMessage = error.message;
if (error.message.includes('401') || error.message.includes('UNAUTHENTICATED')) {
errorType = 'authentication_error';
statusCode = 401;
errorMessage = 'Authentication failed. Make sure Antigravity is running with a valid token.';
} else if (error.message.includes('429') || error.message.includes('RESOURCE_EXHAUSTED')) {
errorType = 'rate_limit_error';
statusCode = 429;
const resetMatch = error.message.match(/quota will reset after (\d+h\d+m\d+s|\d+s)/i);
errorMessage = resetMatch
? `Rate limited. Quota will reset after ${resetMatch[1]}.`
: 'Rate limited. Please wait and try again.';
} else if (error.message.includes('invalid_request_error') || error.message.includes('INVALID_ARGUMENT')) {
errorType = 'invalid_request_error';
statusCode = 400;
const msgMatch = error.message.match(/"message":"([^"]+)"/);
if (msgMatch) errorMessage = msgMatch[1];
} else if (error.message.includes('All endpoints failed')) {
errorType = 'api_error';
statusCode = 503;
errorMessage = 'Unable to connect to Claude API. Check that Antigravity is running.';
} else if (error.message.includes('PERMISSION_DENIED')) {
errorType = 'permission_error';
statusCode = 403;
errorMessage = 'Permission denied. Check your Antigravity license.';
}
return { errorType, statusCode, errorMessage };
}
// Request logging middleware
app.use((req, res, next) => {
console.log(`[${new Date().toISOString()}] ${req.method} ${req.path}`);
next();
});
/**
* Health check endpoint
*/
app.get('/health', async (req, res) => {
try {
const token = await getToken();
let project = null;
try {
project = await getProject(token);
} catch (e) {
// Project fetch might fail if token just refreshed
}
res.json({
status: 'ok',
hasToken: !!token,
tokenPrefix: token ? token.substring(0, 10) + '...' : null,
project: project || 'unknown',
timestamp: new Date().toISOString()
});
} catch (error) {
res.status(503).json({
status: 'error',
error: error.message,
timestamp: new Date().toISOString()
});
}
});
/**
* Force token refresh endpoint
*/
app.post('/refresh-token', async (req, res) => {
try {
clearProjectCache();
const token = await forceRefresh();
res.json({
status: 'ok',
message: 'Token refreshed successfully',
tokenPrefix: token.substring(0, 10) + '...'
});
} catch (error) {
res.status(500).json({
status: 'error',
error: error.message
});
}
});
/**
* List models endpoint (OpenAI-compatible format)
*/
app.get('/v1/models', (req, res) => {
res.json(listModels());
});
/**
* Main messages endpoint - Anthropic Messages API compatible
*/
app.post('/v1/messages', async (req, res) => {
try {
const {
model,
messages,
max_tokens,
stream,
system,
tools,
tool_choice,
thinking,
top_p,
top_k,
temperature
} = req.body;
// Validate required fields
if (!messages || !Array.isArray(messages)) {
return res.status(400).json({
type: 'error',
error: {
type: 'invalid_request_error',
message: 'messages is required and must be an array'
}
});
}
// Build the request object
const request = {
model: model || 'claude-3-5-sonnet-20241022',
messages,
max_tokens: max_tokens || 4096,
stream,
system,
tools,
tool_choice,
thinking,
top_p,
top_k,
temperature
};
console.log(`[API] Request for model: ${request.model}, stream: ${!!stream}`);
if (stream) {
// Handle streaming response
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
try {
// Use the streaming generator
for await (const event of sendMessageStream(request)) {
res.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
}
res.end();
} catch (streamError) {
console.error('[API] Stream error:', streamError);
const { errorType, errorMessage } = parseError(streamError);
res.write(`event: error\ndata: ${JSON.stringify({
type: 'error',
error: { type: errorType, message: errorMessage }
})}\n\n`);
res.end();
}
} else {
// Handle non-streaming response
const response = await sendMessage(request);
res.json(response);
}
} catch (error) {
console.error('[API] Error:', error);
let { errorType, statusCode, errorMessage } = parseError(error);
// For auth errors, try to refresh token
if (errorType === 'authentication_error') {
console.log('[API] Token might be expired, attempting refresh...');
try {
clearProjectCache();
await forceRefresh();
errorMessage = 'Token was expired and has been refreshed. Please retry your request.';
} catch (refreshError) {
errorMessage = 'Could not refresh token. Make sure Antigravity is running.';
}
}
res.status(statusCode).json({
type: 'error',
error: {
type: errorType,
message: errorMessage
}
});
}
});
/**
* Catch-all for unsupported endpoints
*/
app.use('*', (req, res) => {
res.status(404).json({
type: 'error',
error: {
type: 'not_found_error',
message: `Endpoint ${req.method} ${req.originalUrl} not found`
}
});
});
export default app;

181
src/token-extractor.js Normal file
View File

@@ -0,0 +1,181 @@
/**
* Token Extractor Module
* Extracts OAuth tokens from Antigravity's SQLite database
*
* The database is automatically updated by Antigravity when tokens refresh,
* so this approach doesn't require any manual intervention.
*/
import { execSync } from 'child_process';
import { homedir } from 'os';
import { join } from 'path';
import fetch from 'node-fetch';
import { TOKEN_REFRESH_INTERVAL_MS, ANTIGRAVITY_AUTH_PORT } from './constants.js';
// Cache for the extracted token
let cachedToken = null;
let cachedConfig = null;
let tokenExtractedAt = null;
// Antigravity's SQLite database path
const ANTIGRAVITY_DB_PATH = join(
homedir(),
'Library/Application Support/Antigravity/User/globalStorage/state.vscdb'
);
/**
* Extract token from Antigravity's SQLite database
* This is the preferred method as the DB is auto-updated
*/
function extractTokenFromDB() {
try {
const result = execSync(
`sqlite3 "${ANTIGRAVITY_DB_PATH}" "SELECT value FROM ItemTable WHERE key = 'antigravityAuthStatus';"`,
{ encoding: 'utf-8', timeout: 5000 }
);
if (!result || !result.trim()) {
throw new Error('No auth status found in database');
}
const authData = JSON.parse(result.trim());
return {
apiKey: authData.apiKey,
name: authData.name,
email: authData.email,
// Include other fields we might need
...authData
};
} catch (error) {
console.error('[Token] Database extraction failed:', error.message);
throw error;
}
}
/**
* Extract the chat params from Antigravity's HTML page (fallback method)
*/
async function extractChatParams() {
try {
const response = await fetch(`http://127.0.0.1:${ANTIGRAVITY_AUTH_PORT}/`);
const html = await response.text();
// Find the base64-encoded chatParams in the HTML
const match = html.match(/window\.chatParams\s*=\s*'([^']+)'/);
if (!match) {
throw new Error('Could not find chatParams in Antigravity page');
}
// Decode base64
const base64Data = match[1];
const jsonString = Buffer.from(base64Data, 'base64').toString('utf-8');
const config = JSON.parse(jsonString);
return config;
} catch (error) {
if (error.code === 'ECONNREFUSED') {
throw new Error(
`Cannot connect to Antigravity on port ${ANTIGRAVITY_AUTH_PORT}. ` +
'Make sure Antigravity is running.'
);
}
throw error;
}
}
/**
* Get fresh token data - tries DB first, falls back to HTML page
*/
async function getTokenData() {
// Try database first (preferred - always has fresh token)
try {
const dbData = extractTokenFromDB();
if (dbData?.apiKey) {
console.log('[Token] Got fresh token from SQLite database');
return dbData;
}
} catch (err) {
console.log('[Token] DB extraction failed, trying HTML page...');
}
// Fallback to HTML page
try {
const pageData = await extractChatParams();
if (pageData?.apiKey) {
console.log('[Token] Got token from HTML page (may be stale)');
return pageData;
}
} catch (err) {
console.log('[Token] HTML page extraction failed:', err.message);
}
throw new Error(
'Could not extract token from Antigravity. ' +
'Make sure Antigravity is running and you are logged in.'
);
}
/**
* Check if the cached token needs refresh
*/
function needsRefresh() {
if (!cachedToken || !tokenExtractedAt) {
return true;
}
return Date.now() - tokenExtractedAt > TOKEN_REFRESH_INTERVAL_MS;
}
/**
* Get the current OAuth token (with caching)
*/
export async function getToken() {
if (needsRefresh()) {
const data = await getTokenData();
cachedToken = data.apiKey;
cachedConfig = data;
tokenExtractedAt = Date.now();
}
return cachedToken;
}
/**
* Get the full configuration from Antigravity
*/
export async function getConfig() {
if (needsRefresh()) {
await getToken(); // This will refresh the cache
}
return cachedConfig;
}
/**
* Get available models from the cached config
*/
export async function getAvailableModels() {
const config = await getConfig();
if (!config?.initialUserStatus?.cascadeModelConfigData?.clientModelConfigs) {
return [];
}
return config.initialUserStatus.cascadeModelConfigData.clientModelConfigs;
}
/**
* Force refresh the token (useful if requests start failing)
*/
export async function forceRefresh() {
cachedToken = null;
cachedConfig = null;
tokenExtractedAt = null;
return getToken();
}
// Alias for forceRefresh
export const refreshToken = forceRefresh;
export default {
getToken,
getConfig,
getAvailableModels,
forceRefresh,
refreshToken
};