converse-mcp-server
Version:
Converse MCP Server - Converse with other LLMs with chat and consensus tools
433 lines (368 loc) • 15.1 kB
JavaScript
/**
* OpenAI-Compatible Provider Base Module
*
* Factory function that creates providers for OpenAI-compatible APIs.
* This module handles common functionality for providers that use the OpenAI SDK
* with custom base URLs (e.g., DeepSeek, OpenRouter).
*/
import OpenAI from 'openai';
import { debugLog, debugError } from '../utils/console.js';
import { ProviderError, ErrorCodes, StopReasons } from './interface.js';
/**
* Configuration for OpenAI-compatible provider
* @typedef {Object} OpenAICompatibleConfig
* @property {string} baseURL - API base URL
* @property {string} apiKey - API key
* @property {Object} [customHeaders] - Custom headers to include in requests
* @property {string} [providerName] - Provider name for logging/errors
* @property {Object<string, ModelConfig>} supportedModels - Supported models
* @property {Function} [validateApiKey] - Custom API key validation function
* @property {Function} [transformRequest] - Transform request before sending
* @property {Function} [transformResponse] - Transform response after receiving
* @property {Object} [defaultParams] - Default parameters for all requests
*/
/**
* Map common stop/finish reasons to unified format
*/
const STOP_REASON_MAP = {
// Standard OpenAI reasons
'stop': StopReasons.STOP,
'length': StopReasons.LENGTH,
'max_tokens': StopReasons.LENGTH,
'tool_calls': StopReasons.TOOL_USE,
'function_call': StopReasons.TOOL_USE,
'content_filter': StopReasons.CONTENT_FILTER,
// Provider-specific variations
'finish': StopReasons.STOP,
'complete': StopReasons.STOP,
'completed': StopReasons.STOP,
'token_limit': StopReasons.LENGTH,
'token_limit_reached': StopReasons.LENGTH,
'safety': StopReasons.SAFETY,
'filtered': StopReasons.CONTENT_FILTER,
// Default
'null': StopReasons.STOP,
'undefined': StopReasons.STOP
};
/**
* Normalize stop reason to unified format
*/
function normalizeStopReason(reason) {
if (!reason) return StopReasons.STOP;
const normalized = STOP_REASON_MAP[reason.toLowerCase()];
return normalized || StopReasons.OTHER;
}
/**
* Default API key validator (checks for non-empty string)
*/
function defaultValidateApiKey(apiKey) {
return !!(apiKey && typeof apiKey === 'string' && apiKey.length > 0);
}
/**
* Convert messages to OpenAI format
*/
function convertMessages(messages, providerName) {
if (!Array.isArray(messages)) {
throw new ProviderError('Messages must be an array', ErrorCodes.INVALID_MESSAGES);
}
return messages.map((msg, index) => {
if (!msg || typeof msg !== 'object') {
throw new ProviderError(`Message at index ${index} must be an object`, ErrorCodes.INVALID_MESSAGE);
}
const { role, content } = msg;
if (!role || !['system', 'user', 'assistant'].includes(role)) {
throw new ProviderError(`Invalid role "${role}" at message index ${index}`, ErrorCodes.INVALID_ROLE);
}
if (!content) {
throw new ProviderError(`Message content is required at index ${index}`, ErrorCodes.MISSING_CONTENT);
}
// Handle complex content structure (array with text and images)
if (Array.isArray(content)) {
const convertedContent = [];
for (const item of content) {
if (item.type === 'text') {
convertedContent.push({
type: 'text',
text: item.text
});
} else if (item.type === 'image' && item.source) {
// Convert Anthropic/Claude format to OpenAI format
convertedContent.push({
type: 'image_url',
image_url: {
url: `data:${item.source.media_type};base64,${item.source.data}`,
detail: 'auto'
}
});
debugLog(`[${providerName}] Converting image: ${item.source.media_type}, data length: ${item.source.data.length}`);
}
}
return { role, content: convertedContent };
}
// Simple string content
return { role, content };
});
}
/**
* Resolve model name using aliases
*/
function resolveModelName(modelName, supportedModels) {
const modelNameLower = modelName.toLowerCase();
// Check exact matches first
for (const [supportedModel] of Object.entries(supportedModels)) {
if (supportedModel.toLowerCase() === modelNameLower) {
return supportedModel;
}
}
// Check aliases
for (const [supportedModel, config] of Object.entries(supportedModels)) {
if (config.aliases) {
for (const alias of config.aliases) {
if (alias.toLowerCase() === modelNameLower) {
return supportedModel;
}
}
}
}
// Return as-is if not found
return modelName;
}
/**
* Handle common OpenAI-compatible API errors
*/
function handleApiError(error, providerName, resolvedModel) {
// Extract error details from different error formats
const status = error.response?.status || error.status;
const errorMessage = error.response?.data?.error?.message || error.message || 'Unknown error';
const errorCode = error.response?.data?.error?.code || error.code;
// Map common error codes and status codes
if (status === 401 || errorCode === 'invalid_api_key' || errorMessage?.includes('Invalid API key')) {
throw new ProviderError(`Invalid ${providerName} API key`, ErrorCodes.INVALID_API_KEY, error);
} else if (status === 429 || error.type === 'rate_limit_error' || errorCode === 'rate_limit_exceeded' || errorMessage?.includes('Rate limit exceeded')) {
throw new ProviderError(`${providerName} rate limit exceeded`, ErrorCodes.RATE_LIMIT_EXCEEDED, error);
} else if (status === 403 || errorCode === 'insufficient_quota' || errorMessage?.includes('quota exceeded')) {
throw new ProviderError(`${providerName} API quota exceeded`, ErrorCodes.QUOTA_EXCEEDED, error);
} else if (status === 404 || errorCode === 'model_not_found' || errorMessage?.includes('Model') && errorMessage?.includes('not found')) {
throw new ProviderError(`Model ${resolvedModel} not found`, ErrorCodes.MODEL_NOT_FOUND, error);
} else if (status === 400 && (errorMessage?.includes('Context length exceeded') || errorMessage?.includes('context'))) {
throw new ProviderError('Context length exceeded for model', ErrorCodes.CONTEXT_LENGTH_EXCEEDED, error);
} else if (error.type === 'invalid_request_error' || (status === 400 && !errorMessage?.includes('context'))) {
throw new ProviderError(`Invalid request: ${errorMessage}`, ErrorCodes.INVALID_REQUEST, error);
} else if (error.code === 'ETIMEDOUT' || error.code === 'ECONNABORTED') {
throw new ProviderError(`${providerName} request timeout`, ErrorCodes.TIMEOUT_ERROR, error);
} else if (error.code?.startsWith('E') || errorMessage?.includes('network')) {
throw new ProviderError(`${providerName} network error: ${errorMessage}`, ErrorCodes.NETWORK_ERROR, error);
}
// Generic error
throw new ProviderError(
`${providerName} API error: ${error.message || 'Unknown error'}`,
ErrorCodes.API_ERROR,
error
);
}
/**
* Create an OpenAI-compatible provider
* @param {OpenAICompatibleConfig} providerConfig - Provider configuration
* @returns {Provider} - Provider implementation
*/
export function createOpenAICompatibleProvider(providerConfig) {
const {
baseURL,
apiKey,
customHeaders = {},
providerName = 'OpenAI-Compatible',
supportedModels = {},
validateApiKey = defaultValidateApiKey,
transformRequest,
transformResponse,
defaultParams = {}
} = providerConfig;
// Create custom error class for this provider
class CustomProviderError extends ProviderError {
constructor(message, code, originalError = null) {
super(message, code, originalError);
this.name = `${providerName}ProviderError`;
}
}
return {
/**
* Unified provider interface: invoke messages with options
*/
async invoke(messages, options = {}) {
const {
model = Object.keys(supportedModels)[0], // Default to first model
temperature = 0.7,
maxTokens = null,
stream = false,
// eslint-disable-next-line no-unused-vars
reasoning_effort = 'medium',
// eslint-disable-next-line no-unused-vars
use_websearch = false,
config,
...otherOptions
} = options;
// Get API key from config or use provider default
const effectiveApiKey = config?.apiKeys?.[providerName.toLowerCase()] || apiKey;
// Validate API key
if (!effectiveApiKey) {
throw new CustomProviderError(`${providerName} API key not configured`, ErrorCodes.MISSING_API_KEY);
}
if (!validateApiKey(effectiveApiKey)) {
throw new CustomProviderError(`Invalid ${providerName} API key format`, ErrorCodes.INVALID_API_KEY);
}
// Initialize OpenAI client with custom configuration
const clientOptions = {
apiKey: effectiveApiKey,
baseURL,
defaultHeaders: {
...customHeaders,
// Support dynamic headers from provider config
...(config?.providers?._customHeaders || {})
}
};
// Add timeout if specified in model config
const resolvedModel = resolveModelName(model, supportedModels);
const modelConfig = supportedModels[resolvedModel] || {};
if (modelConfig.timeout) {
clientOptions.timeout = modelConfig.timeout;
}
const openai = new OpenAI(clientOptions);
// Convert and validate messages
const openaiMessages = convertMessages(messages, providerName);
// 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 === false) {
throw new CustomProviderError(
`Model ${resolvedModel} does not support images`,
ErrorCodes.INVALID_REQUEST
);
}
// Build request payload
let requestPayload = {
model: resolvedModel,
messages: openaiMessages,
stream,
...defaultParams,
...otherOptions
};
// Add temperature if model supports it and not already set by defaultParams
if (modelConfig.supportsTemperature !== false && temperature !== undefined && !defaultParams.temperature) {
requestPayload.temperature = Math.max(0, Math.min(2, temperature));
}
// Add max tokens if specified
if (maxTokens) {
requestPayload.max_tokens = Math.min(maxTokens, modelConfig.maxOutputTokens || 100000);
}
// Note: Most OpenAI-compatible APIs don't support reasoning_effort or use_websearch
// These are silently ignored unless the provider has custom handling
// Apply custom request transformation if provided
if (transformRequest) {
requestPayload = await transformRequest(requestPayload, { model: resolvedModel, modelConfig });
}
try {
debugLog(`[${providerName}] Calling ${resolvedModel} with ${openaiMessages.length} messages`);
const startTime = Date.now();
// Make the API call
const response = await openai.chat.completions.create(requestPayload);
const responseTime = Date.now() - startTime;
debugLog(`[${providerName}] Response received in ${responseTime}ms`);
// Extract response data
const choice = response.choices?.[0];
if (!choice) {
throw new CustomProviderError('No response choice received', ErrorCodes.NO_RESPONSE_CHOICE);
}
const content = choice.message?.content;
if (!content) {
throw new CustomProviderError('No content in response', ErrorCodes.NO_RESPONSE_CONTENT);
}
// Extract and normalize finish reason
const finishReason = choice.finish_reason || 'stop';
const stopReason = normalizeStopReason(finishReason);
// Extract usage information
const usage = response.usage || {};
// Build unified response
let result = {
content,
stop_reason: stopReason,
rawResponse: response,
metadata: {
model: response.model || resolvedModel,
usage: {
input_tokens: usage.prompt_tokens || usage.input_tokens || 0,
output_tokens: usage.completion_tokens || usage.output_tokens || 0,
total_tokens: usage.total_tokens || 0
},
response_time_ms: responseTime,
finish_reason: finishReason,
provider: providerName.toLowerCase()
}
};
// Apply custom response transformation if provided
if (transformResponse) {
result = await transformResponse(result, response);
}
return result;
} catch (error) {
debugError(`[${providerName}] Error during API call:`, error);
// Re-throw our own errors
if (error instanceof CustomProviderError) {
throw error;
}
handleApiError(error, providerName, resolvedModel);
}
},
/**
* Validate configuration
*/
validateConfig(config) {
const effectiveApiKey = config?.apiKeys?.[providerName.toLowerCase()] || apiKey;
return !!(effectiveApiKey && validateApiKey(effectiveApiKey));
},
/**
* Check if provider is available
*/
isAvailable(config) {
return this.validateConfig(config);
},
/**
* Get supported models
*/
getSupportedModels() {
return supportedModels;
},
/**
* Get model configuration
*/
getModelConfig(modelName) {
const resolved = resolveModelName(modelName, supportedModels);
return supportedModels[resolved] || null;
}
};
}
/**
* Retry helper for rate-limited requests
*/
export async function retryWithBackoff(fn, maxRetries = 3, initialDelay = 1000) {
let lastError;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
// Don't retry on non-retryable errors
if (error.code && ![ErrorCodes.RATE_LIMIT_EXCEEDED, ErrorCodes.TIMEOUT_ERROR, ErrorCodes.NETWORK_ERROR].includes(error.code)) {
throw error;
}
// Wait before retrying
if (attempt < maxRetries - 1) {
const delay = initialDelay * Math.pow(2, attempt);
debugLog(`Retrying after ${delay}ms (attempt ${attempt + 1}/${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
throw lastError;
}