UNPKG

converse-mcp-server

Version:

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

985 lines (892 loc) 29.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.5-flash': { modelName: 'gemini-flash-latest', friendlyName: 'Gemini (Flash 2.5)', contextWindow: 1048576, // 1M tokens maxOutputTokens: 65536, supportsStreaming: true, supportsImages: true, supportsThinking: true, supportsWebSearch: true, maxThinkingTokens: 24576, timeout: 900000, 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', 'gemini-2.5-flash-preview-09-2025', 'gemini-2.5-flash-latest', ], }, 'gemini-2.5-flash-lite': { modelName: 'gemini-flash-lite-latest', friendlyName: 'Gemini (Flash Lite 2.5)', contextWindow: 1048576, // 1M tokens maxOutputTokens: 65536, supportsStreaming: true, supportsImages: true, supportsThinking: true, supportsWebSearch: true, maxThinkingTokens: 24576, timeout: 900000, description: 'Lightweight fast model (1M context) - Efficient quick responses with grounding', aliases: [ 'flashlite2.5', 'flash-lite', 'flash lite', 'gemini-flash-lite', 'gemini flash lite', 'gemini-2.5-flash-lite-preview-09-2025', 'gemini-2.5-flash-lite-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, supportsThinking: true, maxThinkingTokens: 32768, timeout: 900000, description: 'Deep reasoning + thinking mode (1M context) - Complex problems, architecture, deep analysis', aliases: ['pro 2.5', 'gemini pro 2.5', 'gemini-2.5-pro-latest'], }, 'gemini-3.1-pro-preview': { modelName: 'gemini-3.1-pro-preview', friendlyName: 'Gemini (Pro 3.1)', contextWindow: 1048576, // 1M tokens maxOutputTokens: 64000, supportsStreaming: true, supportsImages: true, supportsThinking: true, supportsWebSearch: true, thinkingMode: 'level', thinkingLevels: ['minimal', 'low', 'medium', 'high'], timeout: 900000, description: 'Gemini 3.1 Pro - Most advanced reasoning with expanded thinking levels (1M context)', aliases: [ 'gemini-3', 'gemini3', 'gemini-3-pro', 'gemini-3-pro-preview', '3-pro', 'gemini-3.1', 'gemini3.1', 'gemini-3.1-pro', '3.1-pro', 'gemini pro', 'gemini-pro', 'pro', ], }, 'gemini-3.5-flash': { modelName: 'gemini-3.5-flash', friendlyName: 'Gemini (Flash 3.5)', contextWindow: 1048576, // 1M tokens maxOutputTokens: 65536, supportsStreaming: true, supportsImages: true, supportsThinking: true, supportsWebSearch: true, thinkingMode: 'level', thinkingLevels: ['minimal', 'low', 'medium', 'high'], timeout: 900000, description: 'Gemini 3.5 Flash - Frontier-level agentic and coding performance at Flash speed (1M context)', aliases: [ 'gemini-3.5', 'gemini3.5', 'gemini-3.5-flash-latest', 'flash-3.5', 'flash3.5', 'gemini-flash-3.5', 'gemini flash 3.5', '3.5-flash', ], }, }; // 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|AsyncGenerator} - { content, stop_reason, rawResponse } or AsyncGenerator when stream=true */ async invoke(messages, options = {}) { const { model = 'gemini-2.5-flash', maxTokens = null, stream = false, reasoning_effort = 'medium', media_resolution = null, signal, 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 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) { if (modelConfig.thinkingMode === 'level') { let thinkingLevel; if (modelConfig.thinkingLevels) { // Model supports specific levels (e.g., Gemini 3.1 Pro: minimal/low/medium/high) const levelMap = { none: 'minimal', minimal: 'minimal', low: 'low', medium: 'medium', high: 'high', max: 'high', }; thinkingLevel = levelMap[reasoning_effort] || 'high'; if (!modelConfig.thinkingLevels.includes(thinkingLevel)) { thinkingLevel = modelConfig.thinkingLevels[ modelConfig.thinkingLevels.length - 1 ]; } } else { // Binary levels only (Gemini 3.0 Pro: low/high) thinkingLevel = ['minimal', 'low'].includes(reasoning_effort) ? 'low' : 'high'; } generationConfig.thinkingConfig = { thinkingLevel }; } else { // Gemini 2.5: Use thinking budget (token count) const thinkingBudget = calculateThinkingBudget( modelConfig, reasoning_effort, ); if (thinkingBudget > 0) { generationConfig.thinkingConfig = { thinkingBudget }; } } } // Add media resolution for Gemini 3.0 models if (modelConfig.thinkingMode === 'level') { // Default to MEDIA_RESOLUTION_HIGH for Gemini 3.0 if not specified const resolution = media_resolution || 'MEDIA_RESOLUTION_HIGH'; const validResolutions = [ 'MEDIA_RESOLUTION_LOW', 'MEDIA_RESOLUTION_MEDIUM', 'MEDIA_RESOLUTION_HIGH', 'MEDIA_RESOLUTION_UNSPECIFIED', ]; if (validResolutions.includes(resolution)) { generationConfig.mediaResolution = resolution; } } else if (media_resolution) { // For other models, only add if explicitly specified const validResolutions = [ 'MEDIA_RESOLUTION_LOW', 'MEDIA_RESOLUTION_MEDIUM', 'MEDIA_RESOLUTION_HIGH', 'MEDIA_RESOLUTION_UNSPECIFIED', ]; if (validResolutions.includes(media_resolution)) { generationConfig.mediaResolution = media_resolution; } } // Attach web search grounding where the model supports it; the model // decides per-request whether to actually search. if (modelConfig.supportsWebSearch) { generationConfig.tools = [{ googleSearch: {} }]; } // Handle streaming requests if (stream) { // Check if model supports streaming if (modelConfig.supportsStreaming === false) { debugLog( `[Google] Model ${resolvedModel} doesn't support streaming, falling back to non-streaming mode`, ); } else { return this._createStreamingGenerator( genAI, resolvedModel, geminiContents, generationConfig, modelConfig, reasoning_effort, signal, ); } } try { debugLog( `[Google] Calling ${resolvedModel} with ${messages.length} messages${modelConfig.supportsWebSearch ? ' (with grounding)' : ''}`, ); // Check if already aborted before making request if (signal?.aborted) { throw new Error(`Request aborted: ${signal.reason || 'Cancelled'}`); } const startTime = Date.now(); // Make the API call with retry logic and abort signal support const response = await retryWithBackoff(async () => { if (signal?.aborted) { throw new Error(`Request aborted: ${signal.reason || 'Cancelled'}`); } 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: !!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, ); } }, /** * Create streaming generator for Google provider * @param {Object} genAI - GoogleGenAI instance * @param {string} resolvedModel - Resolved model name * @param {Array} geminiContents - Converted messages for Gemini format * @param {Object} generationConfig - Generation configuration * @param {Object} modelConfig - Model configuration * @param {string} reasoning_effort - Reasoning effort level * @returns {AsyncGenerator} - Streaming generator yielding chunks */ async *_createStreamingGenerator( genAI, resolvedModel, geminiContents, generationConfig, modelConfig, reasoning_effort, signal, ) { debugLog( `[Google] Starting streaming for ${resolvedModel} with ${geminiContents.length} messages${modelConfig.supportsWebSearch ? ' (with grounding)' : ''}`, ); const startTime = Date.now(); let totalContent = ''; let finalUsage = null; let finishReason = null; let groundingMetadata = null; try { // Check if already aborted before starting if (signal?.aborted) { throw new Error(`Request aborted: ${signal.reason || 'Cancelled'}`); } // Yield start event yield { type: 'start', timestamp: new Date().toISOString(), model: resolvedModel, provider: 'google', thinking_mode: modelConfig.supportsThinking && reasoning_effort, web_search: !!modelConfig.supportsWebSearch, }; // Create streaming request with retry logic and abort signal support const streamResult = await retryWithBackoff(async () => { if (signal?.aborted) { throw new Error(`Request aborted: ${signal.reason || 'Cancelled'}`); } // Google GenAI client doesn't directly support AbortSignal in the same way // but we can check for cancellation before and during processing return await genAI.models.generateContentStream({ model: resolvedModel, contents: geminiContents, config: generationConfig, }); }); // Process streaming chunks for await (const chunk of streamResult) { try { // Check for cancellation during stream processing if (signal?.aborted) { debugLog( `[Google] Stream aborted during processing: ${signal.reason || 'Cancelled'}`, ); break; } const content = chunk.text || ''; if (content) { totalContent += content; yield { type: 'delta', content, timestamp: new Date().toISOString(), model: resolvedModel, provider: 'google', }; } // Check for finish reason in chunk if (chunk.candidates?.[0]?.finishReason) { finishReason = chunk.candidates[0].finishReason; } } catch (chunkError) { debugError('[Google] Error processing streaming chunk:', chunkError); yield { type: 'error', error: chunkError.message, timestamp: new Date().toISOString(), }; } } // Get final aggregated response for metadata try { const finalResponse = await streamResult.response; // Extract usage metadata from final response finalUsage = { input_tokens: finalResponse.usageMetadata?.promptTokenCount || 0, output_tokens: finalResponse.usageMetadata?.candidatesTokenCount || 0, total_tokens: finalResponse.usageMetadata?.totalTokenCount || 0, }; // Extract grounding metadata if the model supports web search if (modelConfig.supportsWebSearch) { groundingMetadata = finalResponse.groundingMetadata || null; } // Use finish reason from final response if not already set if (!finishReason && finalResponse.candidates?.[0]?.finishReason) { finishReason = finalResponse.candidates[0].finishReason; } } catch (finalResponseError) { debugError( '[Google] Error getting final response metadata:', finalResponseError, ); } const responseTime = Date.now() - startTime; // Yield completion event with metadata yield { type: 'completion', content: totalContent, stop_reason: finishReason || 'STOP', timestamp: new Date().toISOString(), metadata: { model: resolvedModel, usage: finalUsage || { input_tokens: 0, output_tokens: 0, total_tokens: 0, }, response_time_ms: responseTime, finish_reason: finishReason || 'STOP', reasoning_effort: modelConfig.supportsThinking ? reasoning_effort : null, provider: 'google', web_search_used: !!modelConfig.supportsWebSearch, grounding_metadata: groundingMetadata, thinking_mode_enabled: !!( modelConfig.supportsThinking && reasoning_effort ), }, }; debugLog( `[Google] Streaming completed in ${responseTime}ms, ${finalUsage?.total_tokens || 0} total tokens`, ); } catch (error) { debugError('[Google] Streaming error:', error); // Yield error event yield { type: 'error', error: error.message || 'Unknown streaming error', timestamp: new Date().toISOString(), provider: 'google', }; // Re-throw with proper error handling 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 streaming error: ${error.message || 'Unknown error'}`, 'STREAMING_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; }, };