initial commit
This commit is contained in:
165
src/server.js
165
src/server.js
@@ -1,16 +1,43 @@
|
||||
/**
|
||||
* Express Server - Anthropic-compatible API
|
||||
* Proxies to Google Cloud Code via Antigravity
|
||||
* Supports multi-account load balancing
|
||||
*/
|
||||
|
||||
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';
|
||||
import { sendMessage, sendMessageStream, listModels } from './cloudcode-client.js';
|
||||
import { forceRefresh } from './token-extractor.js';
|
||||
import { REQUEST_BODY_LIMIT } from './constants.js';
|
||||
import { AccountManager } from './account-manager.js';
|
||||
|
||||
const app = express();
|
||||
|
||||
// Initialize account manager (will be fully initialized on first request or startup)
|
||||
const accountManager = new AccountManager();
|
||||
|
||||
// Track initialization status
|
||||
let isInitialized = false;
|
||||
let initError = null;
|
||||
|
||||
/**
|
||||
* Ensure account manager is initialized
|
||||
*/
|
||||
async function ensureInitialized() {
|
||||
if (isInitialized) return;
|
||||
|
||||
try {
|
||||
await accountManager.initialize();
|
||||
isInitialized = true;
|
||||
const status = accountManager.getStatus();
|
||||
console.log(`[Server] Account pool initialized: ${status.summary}`);
|
||||
} catch (error) {
|
||||
initError = error;
|
||||
console.error('[Server] Failed to initialize account manager:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: REQUEST_BODY_LIMIT }));
|
||||
@@ -27,13 +54,20 @@ function parseError(error) {
|
||||
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('429') || error.message.includes('RESOURCE_EXHAUSTED') || error.message.includes('QUOTA_EXHAUSTED')) {
|
||||
errorType = 'overloaded_error'; // Claude Code recognizes this type
|
||||
statusCode = 529; // Use 529 for overloaded (Claude API convention)
|
||||
|
||||
// Try to extract the quota reset time from the error
|
||||
const resetMatch = error.message.match(/quota will reset after (\d+h\d+m\d+s|\d+m\d+s|\d+s)/i);
|
||||
const modelMatch = error.message.match(/"model":\s*"([^"]+)"/);
|
||||
const model = modelMatch ? modelMatch[1] : 'the model';
|
||||
|
||||
if (resetMatch) {
|
||||
errorMessage = `You have exhausted your capacity on ${model}. Quota will reset after ${resetMatch[1]}.`;
|
||||
} else {
|
||||
errorMessage = `You have exhausted your capacity on ${model}. Please wait for your quota to reset.`;
|
||||
}
|
||||
} else if (error.message.includes('invalid_request_error') || error.message.includes('INVALID_ARGUMENT')) {
|
||||
errorType = 'invalid_request_error';
|
||||
statusCode = 400;
|
||||
@@ -63,19 +97,15 @@ app.use((req, res, next) => {
|
||||
*/
|
||||
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
|
||||
}
|
||||
await ensureInitialized();
|
||||
const status = accountManager.getStatus();
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
hasToken: !!token,
|
||||
tokenPrefix: token ? token.substring(0, 10) + '...' : null,
|
||||
project: project || 'unknown',
|
||||
accounts: status.summary,
|
||||
available: status.available,
|
||||
rateLimited: status.rateLimited,
|
||||
invalid: status.invalid,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -87,16 +117,55 @@ app.get('/health', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Account pool status endpoint
|
||||
*/
|
||||
app.get('/accounts', async (req, res) => {
|
||||
try {
|
||||
await ensureInitialized();
|
||||
const status = accountManager.getStatus();
|
||||
|
||||
res.json({
|
||||
total: status.total,
|
||||
available: status.available,
|
||||
rateLimited: status.rateLimited,
|
||||
invalid: status.invalid,
|
||||
accounts: status.accounts.map(a => ({
|
||||
email: a.email,
|
||||
source: a.source,
|
||||
isRateLimited: a.isRateLimited,
|
||||
rateLimitResetTime: a.rateLimitResetTime
|
||||
? new Date(a.rateLimitResetTime).toISOString()
|
||||
: null,
|
||||
isInvalid: a.isInvalid,
|
||||
invalidReason: a.invalidReason,
|
||||
lastUsed: a.lastUsed
|
||||
? new Date(a.lastUsed).toISOString()
|
||||
: null
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
status: 'error',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Force token refresh endpoint
|
||||
*/
|
||||
app.post('/refresh-token', async (req, res) => {
|
||||
try {
|
||||
clearProjectCache();
|
||||
await ensureInitialized();
|
||||
// Clear all caches
|
||||
accountManager.clearTokenCache();
|
||||
accountManager.clearProjectCache();
|
||||
// Force refresh default token
|
||||
const token = await forceRefresh();
|
||||
res.json({
|
||||
status: 'ok',
|
||||
message: 'Token refreshed successfully',
|
||||
message: 'Token caches cleared and refreshed',
|
||||
tokenPrefix: token.substring(0, 10) + '...'
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -119,6 +188,9 @@ app.get('/v1/models', (req, res) => {
|
||||
*/
|
||||
app.post('/v1/messages', async (req, res) => {
|
||||
try {
|
||||
// Ensure account manager is initialized
|
||||
await ensureInitialized();
|
||||
|
||||
const {
|
||||
model,
|
||||
messages,
|
||||
@@ -161,6 +233,15 @@ app.post('/v1/messages', async (req, res) => {
|
||||
|
||||
console.log(`[API] Request for model: ${request.model}, stream: ${!!stream}`);
|
||||
|
||||
// Debug: Log message structure to diagnose tool_use/tool_result ordering
|
||||
console.log('[API] Message structure:');
|
||||
messages.forEach((msg, i) => {
|
||||
const contentTypes = Array.isArray(msg.content)
|
||||
? msg.content.map(c => c.type || 'text').join(', ')
|
||||
: (typeof msg.content === 'string' ? 'text' : 'unknown');
|
||||
console.log(` [${i}] ${msg.role}: ${contentTypes}`);
|
||||
});
|
||||
|
||||
if (stream) {
|
||||
// Handle streaming response
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
@@ -168,10 +249,15 @@ app.post('/v1/messages', async (req, res) => {
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('X-Accel-Buffering', 'no');
|
||||
|
||||
// Flush headers immediately to start the stream
|
||||
res.flushHeaders();
|
||||
|
||||
try {
|
||||
// Use the streaming generator
|
||||
for await (const event of sendMessageStream(request)) {
|
||||
// Use the streaming generator with account manager
|
||||
for await (const event of sendMessageStream(request, accountManager)) {
|
||||
res.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
|
||||
// Flush after each event for real-time streaming
|
||||
if (res.flush) res.flush();
|
||||
}
|
||||
res.end();
|
||||
|
||||
@@ -189,7 +275,7 @@ app.post('/v1/messages', async (req, res) => {
|
||||
|
||||
} else {
|
||||
// Handle non-streaming response
|
||||
const response = await sendMessage(request);
|
||||
const response = await sendMessage(request, accountManager);
|
||||
res.json(response);
|
||||
}
|
||||
|
||||
@@ -202,7 +288,8 @@ app.post('/v1/messages', async (req, res) => {
|
||||
if (errorType === 'authentication_error') {
|
||||
console.log('[API] Token might be expired, attempting refresh...');
|
||||
try {
|
||||
clearProjectCache();
|
||||
accountManager.clearProjectCache();
|
||||
accountManager.clearTokenCache();
|
||||
await forceRefresh();
|
||||
errorMessage = 'Token was expired and has been refreshed. Please retry your request.';
|
||||
} catch (refreshError) {
|
||||
@@ -210,13 +297,25 @@ app.post('/v1/messages', async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
res.status(statusCode).json({
|
||||
type: 'error',
|
||||
error: {
|
||||
type: errorType,
|
||||
message: errorMessage
|
||||
}
|
||||
});
|
||||
console.log(`[API] Returning error response: ${statusCode} ${errorType} - ${errorMessage}`);
|
||||
|
||||
// Check if headers have already been sent (for streaming that failed mid-way)
|
||||
if (res.headersSent) {
|
||||
console.log('[API] Headers already sent, writing error as SSE event');
|
||||
res.write(`event: error\ndata: ${JSON.stringify({
|
||||
type: 'error',
|
||||
error: { type: errorType, message: errorMessage }
|
||||
})}\n\n`);
|
||||
res.end();
|
||||
} else {
|
||||
res.status(statusCode).json({
|
||||
type: 'error',
|
||||
error: {
|
||||
type: errorType,
|
||||
message: errorMessage
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user