initial commit
This commit is contained in:
335
src/cloudcode-client.js
Normal file
335
src/cloudcode-client.js
Normal 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
92
src/constants.js
Normal 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
489
src/format-converter.js
Normal 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
36
src/index.js
Normal 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
236
src/server.js
Normal 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
181
src/token-extractor.js
Normal 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
|
||||
};
|
||||
Reference in New Issue
Block a user