// Anthropic to Gemini Proxy Server // Converts Anthropic API format to Google Gemini format import express from 'express'; import { logger } from '../utils/logger.js'; export class AnthropicToGeminiProxy { app; geminiApiKey; geminiBaseUrl; defaultModel; constructor(config) { this.app = express(); this.geminiApiKey = config.geminiApiKey; this.geminiBaseUrl = config.geminiBaseUrl || 'https://generativelanguage.googleapis.com/v1beta'; this.defaultModel = config.defaultModel || 'gemini-2.0-flash-exp'; this.setupMiddleware(); this.setupRoutes(); } setupMiddleware() { // Parse JSON bodies this.app.use(express.json({ limit: '50mb' })); // Logging middleware this.app.use((req, res, next) => { logger.debug('Gemini proxy request', { method: req.method, path: req.path, headers: Object.keys(req.headers) }); next(); }); } setupRoutes() { // Health check this.app.get('/health', (req, res) => { res.json({ status: 'ok', service: 'anthropic-to-gemini-proxy' }); }); // Anthropic Messages API → Gemini generateContent this.app.post('/v1/messages', async (req, res) => { try { const anthropicReq = req.body; // Convert Anthropic format to Gemini format const geminiReq = this.convertAnthropicToGemini(anthropicReq); logger.info('Converting Anthropic request to Gemini', { anthropicModel: anthropicReq.model, geminiModel: this.defaultModel, messageCount: geminiReq.contents.length, stream: anthropicReq.stream, apiKeyPresent: !!this.geminiApiKey, apiKeyPrefix: this.geminiApiKey?.substring(0, 10) }); // Determine endpoint based on streaming const endpoint = anthropicReq.stream ? 'streamGenerateContent' : 'generateContent'; // BUG FIX: Add &alt=sse for streaming to get Server-Sent Events format const streamParam = anthropicReq.stream ? '&alt=sse' : ''; const url = `${this.geminiBaseUrl}/models/${this.defaultModel}:${endpoint}?key=${this.geminiApiKey}${streamParam}`; // Forward to Gemini const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(geminiReq) }); if (!response.ok) { const error = await response.text(); logger.error('Gemini API error', { status: response.status, error }); return res.status(response.status).json({ error: { type: 'api_error', message: error } }); } // Handle streaming vs non-streaming if (anthropicReq.stream) { // Stream response res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); const reader = response.body?.getReader(); if (!reader) { throw new Error('No response body'); } const decoder = new TextDecoder(); let chunkCount = 0; while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value); chunkCount++; logger.info('Gemini stream chunk received', { chunkCount, chunkLength: chunk.length, chunkPreview: chunk.substring(0, 200) }); const anthropicChunk = this.convertGeminiStreamToAnthropic(chunk); logger.info('Anthropic stream chunk generated', { chunkCount, anthropicLength: anthropicChunk.length, anthropicPreview: anthropicChunk.substring(0, 200) }); res.write(anthropicChunk); } logger.info('Gemini stream complete', { totalChunks: chunkCount }); res.end(); } else { // Non-streaming response const geminiRes = await response.json(); // DEBUG: Log raw Gemini response logger.info('Raw Gemini API response', { hasResponse: !!geminiRes, hasCandidates: !!geminiRes.candidates, candidatesLength: geminiRes.candidates?.length, firstCandidate: geminiRes.candidates?.[0], fullResponse: JSON.stringify(geminiRes).substring(0, 500) }); const anthropicRes = this.convertGeminiToAnthropic(geminiRes); logger.info('Gemini proxy response sent', { model: this.defaultModel, usage: anthropicRes.usage, contentBlocks: anthropicRes.content?.length, hasText: anthropicRes.content?.some((c) => c.type === 'text'), firstContent: anthropicRes.content?.[0] }); res.json(anthropicRes); } } catch (error) { logger.error('Gemini proxy error', { error: error.message, stack: error.stack }); res.status(500).json({ error: { type: 'proxy_error', message: error.message } }); } }); // Fallback for other Anthropic API endpoints this.app.use((req, res) => { logger.warn('Unsupported endpoint', { path: req.path, method: req.method }); res.status(404).json({ error: { type: 'not_found', message: `Endpoint ${req.path} not supported by Gemini proxy` } }); }); } convertAnthropicToGemini(anthropicReq) { const contents = []; // Add system message as first user message if present // Gemini doesn't have a dedicated system role, so we prepend it to the first user message let systemPrefix = ''; if (anthropicReq.system) { systemPrefix = `System: ${anthropicReq.system}\n\n`; } // Add tool instructions for Gemini to understand file operations // Since Gemini doesn't have native tool calling, we instruct it to use structured XML-like commands const toolInstructions = ` IMPORTANT: You have access to file system operations through structured commands. Use these exact formats: content here command here When you need to create, edit, or read files, use these structured commands in your response. The system will automatically execute these commands and provide results. `; // Prepend tool instructions to system prompt if (systemPrefix) { systemPrefix = toolInstructions + systemPrefix; } else { systemPrefix = toolInstructions; } // Convert Anthropic messages to Gemini format for (let i = 0; i < anthropicReq.messages.length; i++) { const msg = anthropicReq.messages[i]; let text; if (typeof msg.content === 'string') { text = msg.content; } else if (Array.isArray(msg.content)) { // Extract text from content blocks text = msg.content .filter(block => block.type === 'text') .map(block => block.text) .join('\n'); } else { text = ''; } // Add system prefix to first user message if (i === 0 && msg.role === 'user' && systemPrefix) { text = systemPrefix + text; } contents.push({ role: msg.role === 'assistant' ? 'model' : 'user', parts: [{ text }] }); } const geminiReq = { contents }; // Add generation config if temperature or max_tokens specified if (anthropicReq.temperature !== undefined || anthropicReq.max_tokens !== undefined) { geminiReq.generationConfig = {}; if (anthropicReq.temperature !== undefined) { geminiReq.generationConfig.temperature = anthropicReq.temperature; } if (anthropicReq.max_tokens !== undefined) { geminiReq.generationConfig.maxOutputTokens = anthropicReq.max_tokens; } } // Convert MCP/Anthropic tools to Gemini tools format if (anthropicReq.tools && anthropicReq.tools.length > 0) { geminiReq.tools = [{ functionDeclarations: anthropicReq.tools.map(tool => { // Clean schema: Remove fields that Gemini doesn't support const cleanSchema = (schema) => { if (!schema || typeof schema !== 'object') return schema; const { $schema, additionalProperties, exclusiveMinimum, exclusiveMaximum, ...rest } = schema; const cleaned = { ...rest }; // Recursively clean nested objects if (cleaned.properties) { cleaned.properties = Object.fromEntries(Object.entries(cleaned.properties).map(([key, value]) => [ key, cleanSchema(value) ])); } // Clean items if present if (cleaned.items) { cleaned.items = cleanSchema(cleaned.items); } return cleaned; }; return { name: tool.name, description: tool.description || '', parameters: cleanSchema(tool.input_schema) || { type: 'object', properties: {}, required: [] } }; }) }]; logger.info('Forwarding MCP tools to Gemini', { toolCount: anthropicReq.tools.length, toolNames: anthropicReq.tools.map(t => t.name) }); } return geminiReq; } parseStructuredCommands(text) { const toolUses = []; let cleanText = text; // Parse file_write commands const fileWriteRegex = /([\s\S]*?)<\/file_write>/g; let match; while ((match = fileWriteRegex.exec(text)) !== null) { toolUses.push({ type: 'tool_use', id: `tool_${Date.now()}_${toolUses.length}`, name: 'Write', input: { file_path: match[1], content: match[2].trim() } }); cleanText = cleanText.replace(match[0], `[File written: ${match[1]}]`); } // Parse file_read commands const fileReadRegex = //g; while ((match = fileReadRegex.exec(text)) !== null) { toolUses.push({ type: 'tool_use', id: `tool_${Date.now()}_${toolUses.length}`, name: 'Read', input: { file_path: match[1] } }); cleanText = cleanText.replace(match[0], `[Reading file: ${match[1]}]`); } // Parse bash commands const bashRegex = /([\s\S]*?)<\/bash_command>/g; while ((match = bashRegex.exec(text)) !== null) { toolUses.push({ type: 'tool_use', id: `tool_${Date.now()}_${toolUses.length}`, name: 'Bash', input: { command: match[1].trim() } }); cleanText = cleanText.replace(match[0], `[Executing: ${match[1].trim()}]`); } return { cleanText: cleanText.trim(), toolUses }; } convertGeminiToAnthropic(geminiRes) { const candidate = geminiRes.candidates?.[0]; if (!candidate) { logger.error('No candidates in Gemini response', { geminiRes }); throw new Error('No candidates in Gemini response'); } const content = candidate.content; const parts = content?.parts || []; logger.info('Converting Gemini to Anthropic', { hasParts: !!parts, partsCount: parts.length, partTypes: parts.map((p) => Object.keys(p)) }); // Extract text and function calls let rawText = ''; const functionCalls = []; for (const part of parts) { if (part.text) { rawText += part.text; logger.info('Found text in part', { textLength: part.text.length, textPreview: part.text.substring(0, 100) }); } if (part.functionCall) { functionCalls.push(part.functionCall); } } logger.info('Extracted content from Gemini', { rawTextLength: rawText.length, functionCallsCount: functionCalls.length, rawTextPreview: rawText.substring(0, 200) }); // Parse structured commands from Gemini's text response const { cleanText, toolUses } = this.parseStructuredCommands(rawText); // Build content array with text and tool uses const contentBlocks = []; if (cleanText) { contentBlocks.push({ type: 'text', text: cleanText }); } // Add tool uses from structured commands contentBlocks.push(...toolUses); // Add tool uses from Gemini function calls (MCP tools) if (functionCalls.length > 0) { for (const functionCall of functionCalls) { contentBlocks.push({ type: 'tool_use', id: `tool_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, name: functionCall.name, input: functionCall.args || {} }); } logger.info('Converted Gemini function calls to Anthropic format', { functionCallCount: functionCalls.length, functionNames: functionCalls.map((fc) => fc.name) }); } return { id: `msg_${Date.now()}`, type: 'message', role: 'assistant', model: this.defaultModel, content: contentBlocks.length > 0 ? contentBlocks : [ { type: 'text', text: rawText } ], stop_reason: this.mapFinishReason(candidate.finishReason), usage: { input_tokens: geminiRes.usageMetadata?.promptTokenCount || 0, output_tokens: geminiRes.usageMetadata?.candidatesTokenCount || 0 } }; } convertGeminiStreamToAnthropic(chunk) { // Gemini streaming returns Server-Sent Events format: "data: {json}" const lines = chunk.split('\n').filter(line => line.trim()); const anthropicChunks = []; for (const line of lines) { try { // Parse SSE format: "data: {json}" if (line.startsWith('data: ')) { const jsonStr = line.substring(6); // Remove "data: " prefix const parsed = JSON.parse(jsonStr); const candidate = parsed.candidates?.[0]; const text = candidate?.content?.parts?.[0]?.text; if (text) { anthropicChunks.push(`event: content_block_delta\ndata: ${JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text } })}\n\n`); } // Check for finish if (candidate?.finishReason) { anthropicChunks.push('event: message_stop\ndata: {}\n\n'); } } } catch (e) { // Ignore parse errors logger.debug('Failed to parse Gemini stream chunk', { line, error: e.message }); } } return anthropicChunks.join(''); } mapFinishReason(reason) { const mapping = { 'STOP': 'end_turn', 'MAX_TOKENS': 'max_tokens', 'SAFETY': 'stop_sequence', 'RECITATION': 'stop_sequence', 'OTHER': 'end_turn' }; return mapping[reason || 'STOP'] || 'end_turn'; } start(port) { this.app.listen(port, () => { logger.info('Anthropic to Gemini proxy started', { port, geminiBaseUrl: this.geminiBaseUrl, defaultModel: this.defaultModel }); console.log(`\nāœ… Gemini Proxy running at http://localhost:${port}`); console.log(` Gemini Base URL: ${this.geminiBaseUrl}`); console.log(` Default Model: ${this.defaultModel}\n`); }); } } // CLI entry point if (import.meta.url === `file://${process.argv[1]}`) { const port = parseInt(process.env.PORT || '3001'); const geminiApiKey = process.env.GOOGLE_GEMINI_API_KEY; if (!geminiApiKey) { console.error('āŒ Error: GOOGLE_GEMINI_API_KEY environment variable required'); process.exit(1); } const proxy = new AnthropicToGeminiProxy({ geminiApiKey, geminiBaseUrl: process.env.GEMINI_BASE_URL, defaultModel: process.env.COMPLETION_MODEL || process.env.REASONING_MODEL }); proxy.start(port); } //# sourceMappingURL=anthropic-to-gemini.js.map