UNPKG

converse-mcp-server

Version:

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

369 lines (323 loc) 12.9 kB
/** * OpenRouter Provider * * Provider implementation for OpenRouter's unified API gateway using OpenAI-compatible API. * Implements the unified interface: async invoke(messages, options) => { content, stop_reason, rawResponse } * * OpenRouter provides access to multiple AI models through a single API endpoint. * IMPORTANT: Requires HTTP-Referer header for compliance tracking. */ import { createOpenAICompatibleProvider } from './openai-compatible.js'; import { debugLog } from '../utils/console.js'; import { ProviderError, ErrorCodes } from './interface.js'; import { fetchModelEndpointsWithCache } from './openrouter-endpoints-client.js'; // Define supported OpenRouter models with their capabilities // Only including the three specific models requested const SUPPORTED_MODELS = { 'qwen/qwen3-235b-a22b-thinking-2507': { modelName: 'qwen/qwen3-235b-a22b-thinking-2507', friendlyName: 'Qwen3 235B Thinking (via OpenRouter)', contextWindow: 32768, maxOutputTokens: 8192, supportsStreaming: true, supportsImages: false, supportsTemperature: true, supportsWebSearch: false, supportsThinking: true, timeout: 300000, description: 'Qwen3 235B Thinking model with enhanced reasoning capabilities', aliases: ['qwen3-thinking', 'qwen-thinking', 'qwen3 thinking', 'qwen thinking', 'qwen3-235b-thinking'] }, 'qwen/qwen3-coder': { modelName: 'qwen/qwen3-coder', friendlyName: 'Qwen3 Coder (via OpenRouter)', contextWindow: 32768, maxOutputTokens: 8192, supportsStreaming: true, supportsImages: false, supportsTemperature: true, supportsWebSearch: false, timeout: 300000, description: 'Qwen3 Coder specialized for programming tasks', aliases: ['qwen3-coder', 'qwen-coder', 'qwen3 coder', 'qwen coder', 'qwen-3-coder'] }, 'moonshotai/kimi-k2': { modelName: 'moonshotai/kimi-k2', friendlyName: 'Kimi K2 (via OpenRouter)', contextWindow: 200000, maxOutputTokens: 8192, supportsStreaming: true, supportsImages: false, supportsTemperature: true, supportsWebSearch: false, timeout: 300000, description: 'Moonshot AI Kimi K2 with extended context window', aliases: ['kimi-k2', 'moonshot-kimi', 'kimi k2', 'kimi', 'moonshot kimi', 'moonshot-k2', 'k2'] }, 'openrouter/auto': { modelName: 'openrouter/auto', friendlyName: 'OpenRouter Auto (via NotDiamond)', contextWindow: 128000, // Safe default for auto-routing maxOutputTokens: 8192, // Safe default supportsStreaming: true, supportsImages: false, // Conservative default supportsTemperature: true, supportsWebSearch: false, timeout: 300000, description: 'Auto-selects the best model for your prompt using NotDiamond routing', aliases: ['openrouter auto', 'auto router', 'auto-router', 'openrouter-auto'] } }; // OpenRouter error class class OpenRouterProviderError extends ProviderError { constructor(message, code, originalError = null) { super(message, code, originalError); this.name = 'OpenRouterProviderError'; } } /** * Validate OpenRouter API key format */ function validateApiKey(apiKey) { if (!apiKey || typeof apiKey !== 'string') { return false; } // OpenRouter API keys typically start with 'sk-or-' and are 40+ characters return apiKey.startsWith('sk-or-') && apiKey.length >= 40; } /** * Get custom headers for OpenRouter */ function getCustomHeaders(config) { const headers = {}; // REQUIRED: HTTP-Referer header for compliance // Handle both camelCase (from tests) and lowercase (from config.js) keys const referer = config?.providers?.openrouterreferer || config?.providers?.openrouterReferer || 'https://github.com/FallDownTheSystem/converse'; headers['HTTP-Referer'] = referer; // Optional: X-Title header for request tracking const title = config?.providers?.openroutertitle || config?.providers?.openrouterTitle; if (title) { headers['X-Title'] = title; } debugLog(`[OpenRouter] Using referer: ${referer}`); return headers; } /** * Transform request to handle OpenRouter-specific requirements */ async function transformRequest(requestPayload, { modelConfig }) { // OpenRouter supports additional parameters const transformed = { ...requestPayload }; // Ensure model name includes provider prefix if not already present if (!transformed.model.includes('/')) { debugLog(`[OpenRouter] Warning: Model name '${transformed.model}' should include provider prefix (e.g., 'anthropic/claude-3.5-sonnet')`); } // OpenRouter supports provider-specific parameters through 'provider' field // This is useful for passing model-specific settings if (modelConfig.providerSettings) { transformed.provider = modelConfig.providerSettings; } return transformed; } /** * Transform response to handle OpenRouter-specific fields */ async function transformResponse(result, rawResponse) { // OpenRouter adds additional metadata if (rawResponse.id) { result.metadata.request_id = rawResponse.id; } // OpenRouter provides pricing information if (rawResponse.usage) { if (rawResponse.usage.prompt_cost) { result.metadata.prompt_cost = rawResponse.usage.prompt_cost; } if (rawResponse.usage.completion_cost) { result.metadata.completion_cost = rawResponse.usage.completion_cost; } if (rawResponse.usage.total_cost) { result.metadata.total_cost = rawResponse.usage.total_cost; } } // OpenRouter may return the actual provider used if (rawResponse.provider) { result.metadata.actual_provider = rawResponse.provider; } return result; } /** * Create OpenRouter provider using OpenAI-compatible base */ export const openrouterProvider = createOpenAICompatibleProvider({ baseURL: 'https://openrouter.ai/api/v1', providerName: 'OpenRouter', supportedModels: SUPPORTED_MODELS, validateApiKey, transformRequest, transformResponse, customHeaders: {}, // Headers are dynamic, set via getCustomHeaders defaultParams: { // OpenRouter default parameters top_p: 1, frequency_penalty: 0, presence_penalty: 0 } }); /** * Check if a model string follows OpenRouter's provider/model format */ function isOpenRouterModelFormat(modelName) { return typeof modelName === 'string' && modelName.includes('/'); } /** * Create a dynamic model configuration from minimal information */ function createDynamicModelConfig(modelName) { return { modelName, friendlyName: `${modelName} (via OpenRouter)`, contextWindow: 8192, // Safe default maxOutputTokens: 4096, // Safe default supportsStreaming: true, supportsImages: false, // Conservative default supportsTemperature: true, supportsWebSearch: false, timeout: 300000, description: `Dynamic model: ${modelName}`, isDynamic: true // Flag to identify dynamic models }; } // Store for dynamically discovered models const dynamicModels = new Map(); // Override methods to support dynamic models const originalGetSupportedModels = openrouterProvider.getSupportedModels; openrouterProvider.getSupportedModels = function() { const staticModels = originalGetSupportedModels.call(this); // Merge dynamic models if any exist if (dynamicModels.size > 0) { const allModels = { ...staticModels }; for (const [modelName, config] of dynamicModels) { allModels[modelName] = config; } return allModels; } return staticModels; }; // Create an async version of getModelConfig for API fetching openrouterProvider.getModelConfigAsync = async function(modelName) { // First check static models const staticConfig = this.getModelConfig(modelName); if (staticConfig && !staticConfig.isDynamic) { return staticConfig; } // Check if already in dynamic models cache if (dynamicModels.has(modelName)) { return dynamicModels.get(modelName); } // If dynamic models are enabled and model follows format, fetch from API const config = this._lastConfig || {}; const dynamicModelsEnabled = config?.providers?.openrouterdynamicmodels || config?.providers?.openrouterDynamicModels; if (dynamicModelsEnabled && isOpenRouterModelFormat(modelName)) { debugLog(`[OpenRouter] Fetching dynamic model config for: ${modelName}`); // Fetch from API with caching const apiConfig = await fetchModelEndpointsWithCache(modelName); if (apiConfig) { // Store in dynamic models cache dynamicModels.set(modelName, apiConfig); return apiConfig; } else { // Model not found on API, create default config to avoid repeated lookups const defaultConfig = createDynamicModelConfig(modelName); defaultConfig.notFoundOnApi = true; dynamicModels.set(modelName, defaultConfig); return defaultConfig; } } return null; }; const originalGetModelConfig = openrouterProvider.getModelConfig; openrouterProvider.getModelConfig = function(modelName) { // First check static models const staticConfig = originalGetModelConfig.call(this, modelName); if (staticConfig) { return staticConfig; } // Check dynamic models cache if (dynamicModels.has(modelName)) { return dynamicModels.get(modelName); } // Check if dynamic models are enabled const config = this._lastConfig || {}; const dynamicModelsEnabled = config?.providers?.openrouterdynamicmodels || config?.providers?.openrouterDynamicModels; // Only allow dynamic models if explicitly enabled AND model has slash format if (dynamicModelsEnabled && isOpenRouterModelFormat(modelName)) { // Note: This is a fallback for synchronous calls // The async version should be preferred for accurate model info const dynamicConfig = createDynamicModelConfig(modelName); dynamicConfig.needsApiUpdate = true; return dynamicConfig; } // If model has slash format but dynamic models disabled, return null // This will cause the model to be rejected as not found return null; }; // Override the invoke method to add dynamic headers and model support const originalInvoke = openrouterProvider.invoke; openrouterProvider.invoke = async function(messages, options = {}) { // Store config for use in getModelConfig this._lastConfig = options.config; // Validate referer configuration // Handle both camelCase (from tests) and lowercase (from config.js) keys if (!options.config?.providers?.openrouterreferer && !options.config?.providers?.openrouterReferer) { throw new OpenRouterProviderError( 'OpenRouter requires HTTP-Referer header. Please set OPENROUTER_REFERER in your environment', ErrorCodes.INVALID_REQUEST ); } // Check if we need to fetch dynamic model config const modelName = options.model; if (modelName) { const existingConfig = this.getModelConfig(modelName); // If model not found and has slash format, check if dynamic models are enabled if (!existingConfig && isOpenRouterModelFormat(modelName)) { const dynamicModelsEnabled = options.config?.providers?.openrouterdynamicmodels || options.config?.providers?.openrouterDynamicModels; if (!dynamicModelsEnabled) { throw new OpenRouterProviderError( `Model '${modelName}' requires OPENROUTER_DYNAMIC_MODELS=true to be set`, ErrorCodes.MODEL_NOT_FOUND ); } } // If the model needs API update, fetch it now if (existingConfig?.needsApiUpdate) { const dynamicModelsEnabled = options.config?.providers?.openrouterdynamicmodels || options.config?.providers?.openrouterDynamicModels; if (dynamicModelsEnabled) { debugLog(`[OpenRouter] Fetching API config for model: ${modelName}`); await this.getModelConfigAsync(modelName); } } } // Create a modified config with custom headers const modifiedOptions = { ...options, config: { ...options.config, // Inject custom headers into the provider config providers: { ...options.config.providers, _customHeaders: getCustomHeaders(options.config) } } }; // Call original invoke with modified options return originalInvoke.call(this, messages, modifiedOptions); }; // Note: The base module needs to be updated to use _customHeaders if present // This is a temporary workaround - in production, the openai-compatible.js // should be updated to accept a function for customHeaders