UNPKG

converse-mcp-server

Version:

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

786 lines (706 loc) 24.2 kB
/** * Mistral Provider * * Provider implementation for Mistral AI models using the official @mistralai/mistralai SDK. * Implements the unified interface: async invoke(messages, options) => { content, stop_reason, rawResponse } */ import { debugLog, debugError } from '../utils/console.js'; import { ProviderError, ErrorCodes, StopReasons } from './interface.js'; // Define supported Mistral models with their capabilities. // Reasoning (`reasoning_effort`) is supported only on Medium 3.5 and Small 4; // Mistral Large 3 has no adjustable reasoning (see resolveReasoningEffort). const SUPPORTED_MODELS = { 'mistral-medium-3-5': { modelName: 'mistral-medium-3-5', friendlyName: 'Mistral Medium 3.5', contextWindow: 262144, maxOutputTokens: 32768, supportsStreaming: true, supportsImages: true, supportsWebSearch: false, supportsReasoning: true, timeout: 900000, description: 'Mistral Medium 3.5 - Frontier-class multimodal model with adjustable reasoning', aliases: [ 'mistral', 'mistral-medium', 'mistral-medium-latest', 'mistral-medium-3.5', ], }, 'mistral-small-2603': { modelName: 'mistral-small-2603', friendlyName: 'Mistral Small 4', contextWindow: 262144, maxOutputTokens: 32768, supportsStreaming: true, supportsImages: true, supportsWebSearch: false, supportsReasoning: true, timeout: 540000, description: 'Mistral Small 4 - Hybrid multimodal model unifying instruct, reasoning, and coding', aliases: ['mistral-small', 'mistral-small-latest'], }, 'mistral-large-2512': { modelName: 'mistral-large-2512', friendlyName: 'Mistral Large 3', contextWindow: 262144, maxOutputTokens: 32768, supportsStreaming: true, supportsImages: true, supportsWebSearch: false, supportsReasoning: false, timeout: 900000, description: 'Mistral Large 3 - Open-weight MoE flagship (no adjustable reasoning)', aliases: ['mistral-large', 'mistral-large-latest'], }, }; /** * Map a Converse reasoning_effort level to Mistral's documented request value. * Mistral documents only "high" and "none". Every enabled level maps to "high" * to preserve enabled-reasoning intent (Converse's default `medium` runs * thinking-on); only `none` disables. Returns null when reasoning must not be * forwarded — i.e. the model does not support it (Large 3) or is an unknown * pass-through ID (capability-gated). */ function resolveReasoningEffort(level, modelConfig) { if (!modelConfig?.supportsReasoning) { return null; } return level === 'none' ? 'none' : 'high'; } /** * Walk a Mistral `message.content` array, separating reasoning (ThinkChunk) * from the visible answer (TextChunk). A ReferenceChunk carries citation * metadata only (reference_ids, no visible text) and is ignored for text. * Any other content-bearing chunk type is unexpected and raises. * @returns {{ text: string, reasoning: string }} */ function normalizeContentChunks(content) { let text = ''; let reasoning = ''; for (const chunk of content) { const type = chunk?.type; if (type === 'thinking') { for (const inner of chunk.thinking || []) { if (inner?.type === 'text') { reasoning += inner.text || ''; } } } else if (type === 'text') { text += chunk.text || ''; } else if (type === 'reference') { // Citation metadata (reference_ids) — no visible text; ignore. } else { throw new MistralProviderError( `Unexpected Mistral content chunk type "${type}"`, ErrorCodes.API_ERROR, ); } } return { text, reasoning }; } /** * Convert one streaming `delta.content` array into ordered normalized events. * ThinkChunk → thinking, TextChunk → delta, ReferenceChunk → ignored. Unknown * chunk types in a streamed delta are skipped (non-fatal) rather than aborting * an in-flight stream. * @returns {Array<{ kind: 'delta'|'thinking', text: string }>} */ function streamEventsFromDelta(delta) { const events = []; for (const chunk of delta) { const type = chunk?.type; if (type === 'thinking') { let thinkText = ''; for (const inner of chunk.thinking || []) { if (inner?.type === 'text') { thinkText += inner.text || ''; } } if (thinkText) { events.push({ kind: 'thinking', text: thinkText }); } } else if (type === 'text') { if (chunk.text) { events.push({ kind: 'delta', text: chunk.text }); } } // ReferenceChunk and any unknown chunk type carry no visible delta text. } return events; } /** * Map Mistral finish reasons to unified format */ const STOP_REASON_MAP = { stop: StopReasons.STOP, length: StopReasons.LENGTH, model_length: StopReasons.LENGTH, tool_calls: StopReasons.TOOL_USE, error: StopReasons.ERROR, }; /** * Custom error class for Mistral provider errors */ class MistralProviderError extends ProviderError { constructor(message, code, originalError = null) { super(message, code, originalError); this.name = 'MistralProviderError'; } } /** * 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 Mistral API handle unknown models) return modelName; } /** * Validate Mistral API key format */ function validateApiKey(apiKey) { if (!apiKey || typeof apiKey !== 'string') { return false; } // Mistral API keys are typically 32+ character strings return apiKey.length >= 32; } /** * Convert messages to Mistral format */ function convertMessagesToMistral(messages) { if (!Array.isArray(messages)) { throw new MistralProviderError( 'Messages must be an array', ErrorCodes.INVALID_MESSAGES, ); } return messages.map((msg, index) => { if (!msg || typeof msg !== 'object') { throw new MistralProviderError( `Message at index ${index} must be an object`, ErrorCodes.INVALID_MESSAGE, ); } const { role, content } = msg; if (!role || !['system', 'user', 'assistant'].includes(role)) { throw new MistralProviderError( `Invalid role "${role}" at message index ${index}`, ErrorCodes.INVALID_ROLE, ); } if (!content) { throw new MistralProviderError( `Message content is required at index ${index}`, ErrorCodes.MISSING_CONTENT, ); } // Handle complex content structure (array with text and images) if (Array.isArray(content)) { const mistralContent = []; for (const item of content) { if (item.type === 'text') { mistralContent.push({ type: 'text', text: item.text, }); } else if (item.type === 'image' && item.source) { // Convert Anthropic/Claude format to Mistral format mistralContent.push({ type: 'image_url', imageUrl: `data:${item.source.media_type};base64,${item.source.data}`, }); debugLog( `[Mistral] Converting image: ${item.source.media_type}, data length: ${item.source.data.length}`, ); } } return { role, content: mistralContent }; } // Simple string content return { role, content }; }); } // Lazy load the Mistral SDK let MistralSDK = null; async function getMistralSDK() { if (!MistralSDK) { try { const module = await import('@mistralai/mistralai'); MistralSDK = module.Mistral || module.default; } catch (error) { throw new MistralProviderError( 'Failed to load Mistral SDK. Please install @mistralai/mistralai', ErrorCodes.API_ERROR, error, ); } } return MistralSDK; } /** * Extract rate limit information from headers */ function extractRateLimitInfo(headers) { if (!headers) return null; const rateLimitInfo = {}; // Mistral uses standard rate limit headers if (headers['x-ratelimit-limit']) { rateLimitInfo.limit = parseInt(headers['x-ratelimit-limit']); } if (headers['x-ratelimit-remaining']) { rateLimitInfo.remaining = parseInt(headers['x-ratelimit-remaining']); } if (headers['x-ratelimit-reset']) { rateLimitInfo.reset = new Date( parseInt(headers['x-ratelimit-reset']) * 1000, ); } return Object.keys(rateLimitInfo).length > 0 ? rateLimitInfo : null; } /** * Main Mistral provider implementation */ export const mistralProvider = { /** * 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 = 'mistral-medium-3-5', maxTokens = null, stream = false, reasoning_effort = 'medium', config, // Filter out options not meant for the API web_search, // eslint-disable-line no-unused-vars continuation_id, // eslint-disable-line no-unused-vars continuationStore, // eslint-disable-line no-unused-vars ...otherOptions } = options; // Validate API key if (!config?.apiKeys?.mistral) { throw new MistralProviderError( 'Mistral API key not configured', ErrorCodes.MISSING_API_KEY, ); } if (!validateApiKey(config.apiKeys.mistral)) { throw new MistralProviderError( 'Invalid Mistral API key format', ErrorCodes.INVALID_API_KEY, ); } // Get Mistral SDK const Mistral = await getMistralSDK(); // Initialize Mistral client const mistral = new Mistral({ apiKey: config.apiKeys.mistral, }); // Resolve model name const resolvedModel = resolveModelName(model); const modelConfig = SUPPORTED_MODELS[resolvedModel] || {}; // Convert and validate messages first const mistralMessages = convertMessagesToMistral(messages); // Check if messages contain images and if model supports them const hasImages = messages.some( (msg) => Array.isArray(msg.content) && msg.content.some((item) => item.type === 'image'), ); if (hasImages && !modelConfig.supportsImages) { throw new MistralProviderError( `Model ${resolvedModel} does not support images`, ErrorCodes.INVALID_REQUEST, ); } // Build request payload const requestPayload = { model: resolvedModel, messages: mistralMessages, stream, ...otherOptions, }; // Forward reasoning effort only when the resolved model supports it // (Mistral Large 3 and unknown pass-through IDs receive no reasoning param). const reasoningEffort = resolveReasoningEffort(reasoning_effort, modelConfig); if (reasoningEffort) { requestPayload.reasoning_effort = reasoningEffort; } // Add max tokens if specified if (maxTokens) { const tokenLimit = Math.min( maxTokens, modelConfig.maxOutputTokens || 32768, ); requestPayload.max_tokens = tokenLimit; // Standard parameter name requestPayload.maxTokens = tokenLimit; // Alternative parameter name } // Handle streaming requests if (stream && modelConfig.supportsStreaming !== false) { return this._createStreamingGenerator( mistral, requestPayload, resolvedModel, modelConfig, ); } try { debugLog( `[Mistral] Calling ${resolvedModel} with ${mistralMessages.length} messages`, ); const startTime = Date.now(); // Make the API call const response = await mistral.chat.complete(requestPayload); const responseTime = Date.now() - startTime; debugLog(`[Mistral] Response received in ${responseTime}ms`); // Extract response data const choice = response.choices?.[0]; if (!choice) { throw new MistralProviderError( 'No response choice received from Mistral', ErrorCodes.NO_RESPONSE_CHOICE, ); } // Content may be a plain string or an array of ThinkChunk/TextChunk/ // ReferenceChunk (when reasoning_effort="high" or citations are used). const rawContent = choice.message?.content; let content = ''; let reasoningContent = ''; if (typeof rawContent === 'string') { content = rawContent; } else if (Array.isArray(rawContent)) { const parsed = normalizeContentChunks(rawContent); content = parsed.text; reasoningContent = parsed.reasoning; } // A reasoning turn may carry empty visible content but present reasoning // or tool calls — accept those (an empty tool_calls array does not count). const toolCalls = choice.message?.tool_calls; const hasToolCalls = Array.isArray(toolCalls) && toolCalls.length > 0; if (!content && !reasoningContent && !hasToolCalls) { throw new MistralProviderError( 'No content in response from Mistral', ErrorCodes.NO_RESPONSE_CONTENT, ); } // Map finish reason const finishReason = choice.finish_reason || 'stop'; const stopReason = STOP_REASON_MAP[finishReason] || StopReasons.OTHER; // Extract usage information const usage = response.usage || {}; // Extract rate limit info if available const rateLimitInfo = extractRateLimitInfo(response.headers); // Return unified response format return { content, stop_reason: stopReason, rawResponse: response, metadata: { model: response.model || resolvedModel, usage: { input_tokens: usage.prompt_tokens || 0, output_tokens: usage.completion_tokens || 0, total_tokens: usage.total_tokens || 0, }, response_time_ms: responseTime, finish_reason: finishReason, provider: 'mistral', rate_limit: rateLimitInfo, ...(reasoningContent && { reasoning_content: reasoningContent }), }, }; } catch (error) { debugError('[Mistral] Error during API call:', error); // Re-throw our own errors if (error instanceof MistralProviderError) { throw error; } // Handle specific Mistral errors if (error.status === 401 || error.message?.includes('Unauthorized')) { throw new MistralProviderError( 'Invalid Mistral API key', ErrorCodes.INVALID_API_KEY, error, ); } else if ( error.status === 429 || error.message?.includes('rate limit') ) { throw new MistralProviderError( 'Mistral rate limit exceeded', ErrorCodes.RATE_LIMIT_EXCEEDED, error, ); } else if (error.status === 403 || error.message?.includes('quota')) { throw new MistralProviderError( 'Mistral API quota exceeded', ErrorCodes.QUOTA_EXCEEDED, error, ); } else if (error.status === 404 || error.message?.includes('model')) { throw new MistralProviderError( `Model ${resolvedModel} not found`, ErrorCodes.MODEL_NOT_FOUND, error, ); } else if ( error.status === 400 || error.message?.includes('Invalid request') ) { throw new MistralProviderError( `Invalid request: ${error.message}`, ErrorCodes.INVALID_REQUEST, error, ); } else if ( error.message?.includes('Context length exceeded') || error.message?.includes('context') ) { throw new MistralProviderError( 'Context length exceeded for model', ErrorCodes.CONTEXT_LENGTH_EXCEEDED, error, ); } // Generic error handling throw new MistralProviderError( `Mistral API error: ${error.message || 'Unknown error'}`, ErrorCodes.API_ERROR, error, ); } }, /** * Create streaming generator for Mistral responses * @param {Object} mistral - Mistral client instance * @param {Object} requestPayload - Request payload for the API * @param {string} resolvedModel - Resolved model name * @param {Object} _modelConfig - Model configuration (reserved) * @returns {AsyncGenerator} - Streaming generator yielding events */ async *_createStreamingGenerator( mistral, requestPayload, resolvedModel, _modelConfig, ) { debugLog( `[Mistral] Starting streaming for ${resolvedModel} with ${requestPayload.messages?.length} messages`, ); const startTime = Date.now(); let totalContent = ''; let finalUsage = null; let finishReason = null; try { // Yield start event yield { type: 'start', timestamp: new Date().toISOString(), model: resolvedModel, provider: 'mistral', }; // Create stream using Mistral SDK's streaming API const stream = await mistral.chat.stream(requestPayload); // Process streaming chunks for await (const chunk of stream) { try { // Mistral wraps the response in a "data" field const chunkData = chunk.data || chunk; // Extract content from the chunk. delta.content may be a plain // string (answer phase / reasoning_effort="none"), a list of // ThinkChunks (thinking phase), or a mixed list carrying a closing // ThinkChunk plus the first TextChunk in one delta (transition). const choice = chunkData.choices?.[0]; if (choice) { const delta = choice.delta?.content; if (typeof delta === 'string') { if (delta) { totalContent += delta; yield { type: 'delta', content: delta, timestamp: new Date().toISOString(), }; } } else if (Array.isArray(delta)) { for (const event of streamEventsFromDelta(delta)) { if (event.kind === 'delta') { totalContent += event.text; yield { type: 'delta', content: event.text, timestamp: new Date().toISOString(), }; } else { yield { type: 'thinking', content: event.text, timestamp: new Date().toISOString(), }; } } } // Capture finish reason when available if (choice.finish_reason || choice.finishReason) { finishReason = choice.finish_reason || choice.finishReason; } } // Handle usage information (typically in final chunk) if (chunkData.usage) { finalUsage = chunkData.usage; } // Break if we have a finish reason indicating completion if ( finishReason && finishReason !== null && finishReason !== 'null' ) { break; } } catch (chunkError) { debugError('[Mistral] Error processing stream chunk:', chunkError); yield { type: 'error', error: { message: `Chunk processing error: ${chunkError.message}`, code: 'CHUNK_PROCESSING_ERROR', recoverable: true, }, timestamp: new Date().toISOString(), }; } } const responseTime = Date.now() - startTime; debugLog(`[Mistral] Streaming completed in ${responseTime}ms`); // Yield usage information if available if (finalUsage) { yield { type: 'usage', usage: { input_tokens: finalUsage.prompt_tokens || 0, output_tokens: finalUsage.completion_tokens || 0, total_tokens: finalUsage.total_tokens || 0, }, timestamp: new Date().toISOString(), }; } // Yield end event with final metadata yield { type: 'end', content: totalContent, stop_reason: STOP_REASON_MAP[finishReason] || StopReasons.OTHER, metadata: { model: resolvedModel, usage: { input_tokens: finalUsage?.prompt_tokens || 0, output_tokens: finalUsage?.completion_tokens || 0, total_tokens: finalUsage?.total_tokens || 0, }, response_time_ms: responseTime, finish_reason: finishReason || 'stop', provider: 'mistral', }, timestamp: new Date().toISOString(), }; } catch (error) { debugError('[Mistral] Streaming error:', error); // Handle specific Mistral errors in streaming context let errorCode = ErrorCodes.API_ERROR; let errorMessage = `Mistral streaming error: ${error.message || 'Unknown error'}`; let recoverable = false; if (error.status === 401 || error.message?.includes('Unauthorized')) { errorCode = ErrorCodes.INVALID_API_KEY; errorMessage = 'Invalid Mistral API key'; } else if ( error.status === 429 || error.message?.includes('rate limit') ) { errorCode = ErrorCodes.RATE_LIMIT_EXCEEDED; errorMessage = 'Mistral rate limit exceeded'; recoverable = true; } else if (error.status === 403 || error.message?.includes('quota')) { errorCode = ErrorCodes.QUOTA_EXCEEDED; errorMessage = 'Mistral API quota exceeded'; } else if (error.status === 404 || error.message?.includes('model')) { errorCode = ErrorCodes.MODEL_NOT_FOUND; errorMessage = `Model ${resolvedModel} not found`; } else if ( error.message?.includes('Context length exceeded') || error.message?.includes('context') ) { errorCode = ErrorCodes.CONTEXT_LENGTH_EXCEEDED; errorMessage = 'Context length exceeded for model'; } yield { type: 'error', error: { message: errorMessage, code: errorCode, recoverable, }, timestamp: new Date().toISOString(), }; // Re-throw as MistralProviderError for consistency throw new MistralProviderError(errorMessage, errorCode, error); } }, /** * Validate configuration for Mistral provider * @param {Object} config - Configuration object * @returns {boolean} - True if configuration is valid */ validateConfig(config) { return !!( config?.apiKeys?.mistral && validateApiKey(config.apiKeys.mistral) ); }, /** * 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; }, };