UNPKG

converse-mcp-server

Version:

Converse MCP Server - Converse with other LLMs with chat and consensus tools

550 lines (479 loc) 18.3 kB
/** * Google (Gemini) Provider * * Provider implementation for Google Gemini models using the official @google/genai SDK v1.11+. * Implements the unified interface: async invoke(messages, options) => { content, stop_reason, rawResponse } */ import { GoogleGenAI } from '@google/genai'; import { debugLog, debugError } from '../utils/console.js'; // Define supported Gemini models with their capabilities const SUPPORTED_MODELS = { 'gemini-2.0-flash': { modelName: 'gemini-2.0-flash', friendlyName: 'Gemini (Flash 2.0)', contextWindow: 1048576, // 1M tokens maxOutputTokens: 65536, supportsStreaming: true, supportsImages: true, supportsTemperature: true, supportsThinking: false, supportsWebSearch: true, maxThinkingTokens: 0, timeout: 300000, description: 'Gemini 2.0 Flash (1M context) - Latest fast model, supports audio/video input and grounding', aliases: ['flash-2.0', 'flash2', 'flash 2.0', 'gemini flash 2.0', 'gemini-2.0-flash-latest'] }, 'gemini-2.0-flash-lite': { modelName: 'gemini-2.0-flash-lite', friendlyName: 'Gemini (Flash Lite 2.0)', contextWindow: 1048576, // 1M tokens maxOutputTokens: 65536, supportsStreaming: true, supportsImages: false, supportsTemperature: true, supportsThinking: false, supportsWebSearch: true, maxThinkingTokens: 0, timeout: 300000, description: 'Gemini 2.0 Flash Lite (1M context) - Lightweight fast model, text-only with grounding', aliases: ['flashlite', 'flash-lite', 'flash lite', 'flash-lite-2.0', 'gemini flash lite', 'gemini-2.0-flash-lite-latest'] }, 'gemini-2.5-flash': { modelName: 'gemini-2.5-flash', friendlyName: 'Gemini (Flash 2.5)', contextWindow: 1048576, // 1M tokens maxOutputTokens: 65536, supportsStreaming: true, supportsImages: true, supportsTemperature: true, supportsThinking: true, supportsWebSearch: true, maxThinkingTokens: 24576, timeout: 300000, description: 'Ultra-fast (1M context) - Quick analysis, simple queries, rapid iterations with grounding', aliases: ['flash', 'flash2.5', 'gemini-flash', 'gemini-flash-2.5', 'flash 2.5', 'gemini flash 2.5', 'gemini-2.5-flash-latest'] }, 'gemini-2.5-pro': { modelName: 'gemini-2.5-pro', friendlyName: 'Gemini (Pro 2.5)', contextWindow: 1048576, // 1M tokens maxOutputTokens: 65536, supportsStreaming: true, supportsWebSearch: true, supportsImages: true, supportsTemperature: true, supportsThinking: true, maxThinkingTokens: 32768, timeout: 300000, description: 'Deep reasoning + thinking mode (1M context) - Complex problems, architecture, deep analysis', aliases: ['pro', 'gemini pro', 'gemini-pro', 'gemini', 'pro 2.5', 'gemini pro 2.5', 'gemini-2.5-pro-latest'] } }; // Thinking mode budget percentages const THINKING_BUDGETS = { minimal: 0.005, // 0.5% of max - minimal thinking for fast responses low: 0.08, // 8% of max - light reasoning tasks medium: 0.33, // 33% of max - balanced reasoning (default) high: 0.67, // 67% of max - complex analysis max: 1.0 // 100% of max - full thinking budget }; /** * Custom error class for Google provider errors */ class GoogleProviderError extends Error { constructor(message, code, originalError = null) { super(message); this.name = 'GoogleProviderError'; this.code = code; this.originalError = originalError; } } /** * Resolve model name to canonical form, including aliases */ function resolveModelName(modelName) { const modelNameLower = modelName.toLowerCase(); // Check exact matches first for (const [supportedModel] of Object.entries(SUPPORTED_MODELS)) { if (supportedModel.toLowerCase() === modelNameLower) { return supportedModel; } } // Check aliases for (const [supportedModel, config] of Object.entries(SUPPORTED_MODELS)) { if (config.aliases) { for (const alias of config.aliases) { if (alias.toLowerCase() === modelNameLower) { return supportedModel; } } } } // Return as-is if not found (let Google API handle unknown models) return modelName; } /** * Validate Google API key format or Vertex AI marker */ function validateApiKey(apiKey) { if (!apiKey || typeof apiKey !== 'string') { return false; } // Special marker for Vertex AI mode if (apiKey === 'VERTEX_AI') { return true; } // Google API keys are typically long strings, usually starting with specific patterns // They are generally 39+ characters long return apiKey.length >= 20; } /** * Convert messages to Google Gemini format */ function convertMessagesToGemini(messages) { if (!Array.isArray(messages)) { throw new GoogleProviderError('Messages must be an array', 'INVALID_MESSAGES'); } const contents = []; let systemPrompt = null; for (const [index, msg] of messages.entries()) { if (!msg || typeof msg !== 'object') { throw new GoogleProviderError(`Message at index ${index} must be an object`, 'INVALID_MESSAGE'); } const { role, content } = msg; if (!role || !['system', 'user', 'assistant'].includes(role)) { throw new GoogleProviderError(`Invalid role "${role}" at message index ${index}`, 'INVALID_ROLE'); } if (!content) { throw new GoogleProviderError(`Message content is required at index ${index}`, 'MISSING_CONTENT'); } if (role === 'system') { // Google Gemini handles system prompts differently - they are typically prepended to the first user message systemPrompt = content; } else if (role === 'user') { const parts = []; // Handle complex content structure (array with text and images) if (Array.isArray(content)) { let textContent = ''; for (const item of content) { if (item.type === 'text') { textContent += item.text; } else if (item.type === 'image' && item.source) { // Convert Anthropic/Claude format to Google Gemini format parts.push({ inlineData: { mimeType: item.source.media_type, data: item.source.data } }); debugLog(`[Google] Converting image: ${item.source.media_type}, data length: ${item.source.data.length}`); } } // Combine system prompt with text content if present const finalTextContent = systemPrompt ? `${systemPrompt}\n\n${textContent}` : textContent; if (finalTextContent) { parts.unshift({ text: finalTextContent }); } } else { // Simple string content const userContent = systemPrompt ? `${systemPrompt}\n\n${content}` : content; parts.push({ text: userContent }); } contents.push({ role: 'user', parts }); systemPrompt = null; // Only use system prompt once } else if (role === 'assistant') { // Handle assistant messages if (Array.isArray(content)) { const parts = []; for (const item of content) { if (item.type === 'text') { parts.push({ text: item.text }); } // Assistant messages typically don't have images, but handle if needed } contents.push({ role: 'model', // Google uses 'model' instead of 'assistant' parts }); } else { contents.push({ role: 'model', parts: [{ text: content }] }); } } } return contents; } /** * Calculate thinking budget for models that support it */ function calculateThinkingBudget(modelConfig, reasoning_effort) { if (!modelConfig.supportsThinking || !modelConfig.maxThinkingTokens) { return 0; } const budget = THINKING_BUDGETS[reasoning_effort] || THINKING_BUDGETS.medium; return Math.floor(modelConfig.maxThinkingTokens * budget); } /** * Check if error is retryable */ function isErrorRetryable(error) { const errorStr = String(error).toLowerCase(); // Non-retryable errors const nonRetryableIndicators = [ 'quota exceeded', 'quota_exceeded', 'resource exhausted', 'resource_exhausted', 'context length', 'token limit', 'request too large', 'invalid request', 'invalid_request', 'read timeout', 'timeout error', '408', 'deadline exceeded' ]; if (nonRetryableIndicators.some(indicator => errorStr.includes(indicator))) { return false; } // Retryable errors const retryableIndicators = [ 'connection', 'network', 'temporary', 'unavailable', 'retry', 'internal error', '429', '500', '502', '503', '504', 'ssl', 'handshake' ]; return retryableIndicators.some(indicator => errorStr.includes(indicator)); } /** * Retry with progressive delays */ async function retryWithBackoff(fn, maxRetries = 4) { const retryDelays = [1000, 3000, 5000, 8000]; // Progressive delays in ms let lastError; for (let attempt = 0; attempt < maxRetries; attempt++) { try { return await fn(); } catch (error) { lastError = error; // If this is the last attempt or not retryable, give up if (attempt === maxRetries - 1 || !isErrorRetryable(error)) { break; } // Wait before retrying const delay = retryDelays[attempt]; debugLog(`[Google] Retrying after ${delay}ms (attempt ${attempt + 1}/${maxRetries}):`, error.message); await new Promise(resolve => setTimeout(resolve, delay)); } } throw lastError; } /** * Main Google provider implementation */ export const googleProvider = { /** * Unified provider interface: invoke messages with options * @param {Array} messages - Array of message objects with role and content * @param {Object} options - Configuration options * @returns {Object} - { content, stop_reason, rawResponse } */ async invoke(messages, options = {}) { const { model = 'gemini-2.5-flash', temperature = 0.7, maxTokens = null, stream: _unused_stream = false, // Acknowledged but not used yet reasoning_effort = 'medium', use_websearch = false, config, ..._otherOptions } = options; // Check if using Vertex AI or Gemini Developer API const useVertexAI = config?.providers?.googlegenaiusevertexai; const vertexProject = config?.providers?.googlecloudproject; const vertexLocation = config?.providers?.googlecloudlocation; const apiVersion = config?.providers?.googleapiversion || 'v1beta'; let genAI; if (useVertexAI) { // Validate Vertex AI configuration if (!vertexProject || !vertexLocation) { throw new GoogleProviderError( 'Vertex AI requires GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION', 'MISSING_VERTEX_CONFIG' ); } debugLog(`[Google] Using Vertex AI: project=${vertexProject}, location=${vertexLocation}, apiVersion=${apiVersion}`); // Initialize with Vertex AI configuration genAI = new GoogleGenAI({ vertexai: true, project: vertexProject, location: vertexLocation, apiVersion }); } else { // Use Gemini Developer API with API key const apiKey = config?.apiKeys?.google; if (!apiKey || apiKey === 'VERTEX_AI') { throw new GoogleProviderError( 'Google API key not configured. Set GOOGLE_API_KEY or GEMINI_API_KEY, or configure Vertex AI', 'MISSING_API_KEY' ); } if (!validateApiKey(apiKey)) { throw new GoogleProviderError('Invalid Google API key format', 'INVALID_API_KEY'); } debugLog(`[Google] Using Gemini Developer API with configured API key, apiVersion=${apiVersion}`); // Initialize with API key - SDK will use GOOGLE_API_KEY as the actual key name genAI = new GoogleGenAI({ apiKey, apiVersion }); } // Resolve model name const resolvedModel = resolveModelName(model); const modelConfig = SUPPORTED_MODELS[resolvedModel] || {}; // Convert messages to Google format const geminiContents = convertMessagesToGemini(messages); // Note: No need to get model instance, we use genAI.models.generateContent directly // Build generation config const generationConfig = {}; // Add temperature if model supports it if (modelConfig.supportsTemperature !== false && temperature !== undefined) { generationConfig.temperature = Math.max(0, Math.min(2, temperature)); } // Add max tokens if specified if (maxTokens) { generationConfig.maxOutputTokens = Math.min(maxTokens, modelConfig.maxOutputTokens || 65536); } // Add thinking configuration for models that support it if (modelConfig.supportsThinking && reasoning_effort) { const thinkingBudget = calculateThinkingBudget(modelConfig, reasoning_effort); if (thinkingBudget > 0) { generationConfig.thinkingConfig = { thinkingBudget }; } } // Add web search grounding if requested and model supports it if (use_websearch && modelConfig.supportsWebSearch) { generationConfig.tools = [{ googleSearch: {} }]; } try { debugLog(`[Google] Calling ${resolvedModel} with ${messages.length} messages${use_websearch && modelConfig.supportsWebSearch ? ' (with grounding)' : ''}`); const startTime = Date.now(); // Make the API call with retry logic const response = await retryWithBackoff(async () => { return await genAI.models.generateContent({ model: resolvedModel, contents: geminiContents, config: generationConfig }); }); const responseTime = Date.now() - startTime; debugLog(`[Google] Response received in ${responseTime}ms`); // Extract response data using the new SDK format const content = response.text; if (!content) { throw new GoogleProviderError('No text content received from Google', 'NO_RESPONSE_CONTENT'); } // Extract usage information from the new SDK format const usage = { input_tokens: response.usageMetadata?.promptTokenCount || 0, output_tokens: response.usageMetadata?.candidatesTokenCount || 0, total_tokens: response.usageMetadata?.totalTokenCount || 0 }; // Extract finish reason from candidates const finishReason = response.candidates?.[0]?.finishReason || 'STOP'; // Return unified response format return { content, stop_reason: finishReason, rawResponse: response, metadata: { model: resolvedModel, usage, response_time_ms: responseTime, finish_reason: finishReason, reasoning_effort: modelConfig.supportsThinking ? reasoning_effort : null, provider: 'google', web_search_used: use_websearch && modelConfig.supportsWebSearch, grounding_metadata: response.groundingMetadata || null } }; } catch (error) { debugError('[Google] Error during API call:', error); // Handle specific Google errors if (error.message?.includes('quota') || error.message?.includes('QUOTA_EXCEEDED')) { throw new GoogleProviderError('Google API quota exceeded', 'QUOTA_EXCEEDED', error); } else if (error.message?.includes('API_KEY_INVALID') || error.message?.includes('invalid api key')) { throw new GoogleProviderError('Invalid Google API key', 'INVALID_API_KEY', error); } else if (error.message?.includes('MODEL_NOT_FOUND')) { throw new GoogleProviderError(`Model ${resolvedModel} not found`, 'MODEL_NOT_FOUND', error); } else if (error.message?.includes('CONTEXT_LENGTH_EXCEEDED')) { throw new GoogleProviderError('Context length exceeded for model', 'CONTEXT_LENGTH_EXCEEDED', error); } else if (error.message?.includes('SAFETY')) { throw new GoogleProviderError('Content blocked by safety filters', 'SAFETY_ERROR', error); } else if (error.message?.includes('RATE_LIMIT_EXCEEDED')) { throw new GoogleProviderError('Google rate limit exceeded', 'RATE_LIMIT_EXCEEDED', error); } // Generic error handling throw new GoogleProviderError( `Google API error: ${error.message || 'Unknown error'}`, 'API_ERROR', error ); } }, /** * Validate configuration for Google provider * @param {Object} config - Configuration object * @returns {boolean} - True if configuration is valid */ validateConfig(config) { // Check for Vertex AI configuration const hasVertexAI = !!(config?.providers?.googlegenaiusevertexai && config?.providers?.googlecloudproject && config?.providers?.googlecloudlocation); // Check for API key configuration const hasApiKey = !!(config?.apiKeys?.google && validateApiKey(config.apiKeys.google)); return hasVertexAI || hasApiKey; }, /** * Check if provider is available with current configuration * @param {Object} config - Configuration object * @returns {boolean} - True if provider is available */ isAvailable(config) { return this.validateConfig(config); }, /** * Get supported models * @returns {Object} - Map of supported models and their configurations */ getSupportedModels() { return SUPPORTED_MODELS; }, /** * Get model configuration * @param {string} modelName - Model name * @returns {Object|null} - Model configuration or null if not found */ getModelConfig(modelName) { const resolved = resolveModelName(modelName); return SUPPORTED_MODELS[resolved] || null; } };