UNPKG

@contextprompt/completions-sdk

Version:

Multi-provider completions SDK with MCP support for OpenAI, Claude, and Gemini

1,136 lines (1,135 loc) 56.2 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.OpenAI = void 0; const openai_1 = __importDefault(require("openai")); const dotenv_1 = __importDefault(require("dotenv")); const index_js_1 = require("@modelcontextprotocol/sdk/client/index.js"); const child_process_1 = require("child_process"); dotenv_1.default.config(); // ============================================================================ // CONFIGURATION & UTILITIES // ============================================================================ const logger = { debug: (message, data = {}) => { if (process.env.DEBUG === "true") { console.debug(`[DEBUG] ${message}`, data); } }, info: (message, data = {}) => { console.info(`[INFO] ${message}`, data); }, error: (message, error = {}) => { console.error(`[ERROR] ${message}`, error); }, }; const PROVIDER_CONFIGS = { "claude-": { name: "Anthropic Claude", baseURL: "https://api.anthropic.com/v1/", apiKeyEnv: "ANTHROPIC_API_KEY", }, "gemini-": { name: "Google Gemini", baseURL: "https://generativelanguage.googleapis.com/v1beta/openai/", apiKeyEnv: "GEMINI_API_KEY", }, }; const providerClients = new Map(); // ============================================================================ // ENVIRONMENT VARIABLE & TEMPLATE SUBSTITUTION // ============================================================================ function substituteVariables(value) { if (typeof value !== "string") return value; // Replace ${VAR} with environment variables let result = value.replace(/\$\{([^}]+)\}/g, (match, varName) => { return process.env[varName] || match; }); // Replace {{VAR}} with template variables (also from env for now) result = result.replace(/\{\{([^}]+)\}\}/g, (match, varName) => { return process.env[varName] || match; }); return result; } function processServerConfig(config) { const processed = { ...config }; // Process URL if (processed.url) { processed.url = substituteVariables(processed.url); } // Process headers if (processed.headers) { processed.headers = Object.fromEntries(Object.entries(processed.headers).map(([key, value]) => [ key, substituteVariables(value), ])); } // Process args if (processed.args) { processed.args = processed.args.map((arg) => substituteVariables(arg)); } return processed; } function getProviderConfig(modelName) { for (const [prefix, config] of Object.entries(PROVIDER_CONFIGS)) { if (modelName.startsWith(prefix)) { logger.info(`Model ${modelName} matched provider: ${config.name}`); return config; } } logger.info(`Model ${modelName} using default OpenAI provider`); return null; } function getProviderClient(providerConfig) { const key = providerConfig.baseURL; if (!providerClients.has(key)) { const apiKey = process.env[providerConfig.apiKeyEnv]; if (!apiKey) { throw new Error(`Missing API key for ${providerConfig.name}. Please set ${providerConfig.apiKeyEnv} environment variable.`); } logger.debug(`Creating new client for ${providerConfig.name}`); providerClients.set(key, new openai_1.default({ apiKey, baseURL: providerConfig.baseURL })); } return providerClients.get(key); } // ============================================================================ // PROVIDER OPTIMIZATION // ============================================================================ function optimizeParametersForProvider(options, modelName) { const optimized = { ...options }; if (modelName.startsWith("gemini-")) { // Gemini requires max_tokens to be removed for proper content generation delete optimized.max_tokens; // Ensure stream parameter is properly set for Gemini if (optimized.stream) { optimized.stream = true; } logger.debug(`Gemini optimization: Removed max_tokens${optimized.stream ? ", configured streaming" : ""}`); } return optimized; } function sanitizeToolSchemaForGemini(tool) { const sanitized = JSON.parse(JSON.stringify(tool)); delete sanitized._mcpServerUrl; if (!sanitized.function.parameters) { sanitized.function.parameters = { type: "object", properties: {}, required: [], }; return sanitized; } const params = sanitized.function.parameters; // Remove JSON Schema metadata that Gemini doesn't like delete params.$schema; delete params.additionalProperties; // Clean nested properties recursively function cleanSchemaProperties(obj) { if (typeof obj !== "object" || obj === null) return obj; const cleaned = {}; for (const [key, value] of Object.entries(obj)) { // Skip problematic JSON Schema keywords if ([ "anyOf", "oneOf", "allOf", "not", "$ref", "const", "examples", "default", "$schema", "additionalProperties", ].includes(key)) { continue; } if (typeof value === "object" && value !== null) { cleaned[key] = Array.isArray(value) ? value.map((item) => typeof item === "object" ? cleanSchemaProperties(item) : item) : cleanSchemaProperties(value); } else { cleaned[key] = value; } } return cleaned; } if (params.properties) { params.properties = cleanSchemaProperties(params.properties); } if (!Array.isArray(params.required)) { params.required = []; } // Ensure basic types for Gemini compatibility if (params.properties) { for (const [propName, propSchema] of Object.entries(params.properties)) { if (propSchema.type && !["string", "number", "boolean", "array", "object"].includes(propSchema.type)) { propSchema.type = "string"; } } } return sanitized; } function optimizeToolsForProvider(tools, modelName) { if (!modelName.startsWith("gemini-") || !tools || tools.length === 0) { return tools; } const sanitizedTools = tools.map((tool) => sanitizeToolSchemaForGemini(tool)); logger.debug(`Sanitized ${sanitizedTools.length} tool(s) for Gemini compatibility`); console.log(`✅ Gemini: Using ${sanitizedTools.length} tool(s) (schema sanitized)`); return sanitizedTools; } // ============================================================================ // STREAMING NORMALIZATION // ============================================================================ async function* normalizeStream(stream, modelName) { try { for await (const chunk of stream) { if (modelName.startsWith("claude-")) { // Skip Claude ping chunks, pass through others if (chunk.type === "ping") continue; yield chunk; } else { // OpenAI and Gemini use the same format yield chunk; } } } catch (error) { logger.error("Stream processing error:", error); throw error; } } // ============================================================================ // MAIN SDK CLASS // ============================================================================ class MultiProviderOpenAI extends openai_1.default { constructor(options = {}) { super({ apiKey: process.env["OPENAI_API_KEY"], ...options }); // MCP configuration - support both new and legacy formats this.mcpServersConfig = options.mcpServers || {}; // Legacy support for mcpServerUrls if (options.mcpServerUrls && Array.isArray(options.mcpServerUrls)) { options.mcpServerUrls.forEach((url, index) => { this.mcpServersConfig[`legacy_server_${index}`] = { type: "streamable-http", url: url, }; }); } // Legacy support for single mcpServerUrl if (options.mcpServerUrl && typeof options.mcpServerUrl === "string") { this.mcpServersConfig["legacy_server"] = { type: "streamable-http", url: options.mcpServerUrl, }; } this.mcpEnabled = options.mcpEnabled !== false; this.mcpServerInstances = new Map(); this.mcpToolsCache = null; this.mcpLastCacheTime = 0; this.CACHE_DURATION = 30000; this.toolToServerMap = new Map(); this.originalOpenAIOptions = { apiKey: process.env["OPENAI_API_KEY"], ...options, }; // Initialize MCP and override chat completions this.mcpInitializationPromise = this.initializeMCP().catch((error) => { logger.error("MCP initialization failed:", error.message); return Promise.resolve(); }); this._overrideChatCompletions(); } _overrideChatCompletions() { const originalChat = this.chat; this.chat = { ...originalChat, completions: { ...originalChat.completions, create: async (options) => { // Check if MCP functionality is needed if (options.mcpServers || (this.mcpEnabled && Object.keys(this.mcpServersConfig).length > 0)) { return this.createWithMCPTools(options); } // Fall back to regular OpenAI API return this.createChatCompletion(options); }, createWithMCPTools: async (options) => this.createWithMCPTools(options), }, }; } // ============================================================================ // MCP METHODS // ============================================================================ async ensureMCPInitialized() { if (this.mcpInitializationPromise) { logger.debug("Waiting for MCP initialization to complete"); await this.mcpInitializationPromise; logger.debug("MCP initialization completed"); } } async initializeMCP() { if (!this.mcpEnabled || Object.keys(this.mcpServersConfig).length === 0) return; try { this.mcpClient = new index_js_1.Client({ name: "multi-agent-client", version: "1.0.0" }, { capabilities: { tools: {} } }); const serverNames = Object.keys(this.mcpServersConfig); logger.debug(`Initializing ${serverNames.length} MCP servers`); const initPromises = serverNames.map((serverName) => this._initializeMCPServer(serverName)); await Promise.allSettled(initPromises); const connectedServers = Array.from(this.mcpServerInstances.entries()).filter(([, instance]) => instance.initialized); if (connectedServers.length > 0) { try { const tools = await this._getMCPToolsInternal(); if (tools.length > 0) { console.log(`🔌 Connected to ${connectedServers.length} MCP server(s)`); this._displayToolList(tools); } else { logger.debug(`Connected to ${connectedServers.length} MCP servers but no tools available`); } } catch (error) { logger.debug(`Failed to display MCP tools: ${error.message}`); } } else { logger.debug("No MCP servers connected"); } } catch (error) { logger.error("Failed to initialize MCP", { error: error.message }); } } async _initializeMCPServer(serverName) { const config = this.mcpServersConfig[serverName]; if (!config) { logger.error(`Server configuration not found: ${serverName}`); return; } const processedConfig = processServerConfig(config); const serverType = this._getServerType(processedConfig); const instance = { name: serverName, config: processedConfig, type: serverType, initialized: false, startTime: Date.now(), }; this.mcpServerInstances.set(serverName, instance); try { if (serverType === "command") { await this._initializeCommandServer(instance); } else { await this._initializeHttpServer(instance); } } catch (error) { instance.lastError = error.message; logger.debug(`Failed to initialize ${serverName}: ${error.message}`); } } _getServerType(config) { if (config.command) { return "command"; } else if (config.type) { return config.type; } else if (config.url) { return "streamable-http"; // Default for URL-based servers } throw new Error("Invalid server configuration: must specify either command or url/type"); } async _initializeCommandServer(instance) { const { config, name } = instance; logger.debug(`Starting command-based MCP server: ${name}`); const args = config.args || []; const env = { ...process.env, ...config.env }; const childProcess = (0, child_process_1.spawn)(config.command, args, { env, stdio: ["pipe", "pipe", "pipe"], }); instance.process = childProcess; // Wait for the process to start await new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error(`Command server ${name} failed to start within timeout`)); }, 10000); childProcess.on("spawn", () => { clearTimeout(timeout); instance.initialized = true; logger.debug(`Command MCP server ${name} started with PID ${childProcess.pid}`); resolve(void 0); }); childProcess.on("error", (error) => { clearTimeout(timeout); reject(error); }); }); } async _initializeHttpServer(instance) { const { config, name, type } = instance; logger.debug(`Initializing HTTP MCP server: ${name} (${type})`); try { const response = await this._mcpRequest(name, "initialize", { protocolVersion: "2024-11-05", capabilities: { tools: {} }, clientInfo: { name: "multi-agent-client", version: "1.0.0" }, }, null, instance); const sessionId = response.response?.headers?.get?.("mcp-session-id"); if (response.data.result) { try { await this._mcpRequest(name, "notifications/initialized", {}, sessionId, instance); } catch (error) { logger.debug(`Notification failed but continuing: ${error.message}`); } instance.sessionId = sessionId; instance.initialized = true; logger.debug(`HTTP MCP server ${name} initialized`); } } catch (error) { throw new Error(`Failed to initialize HTTP server ${name}: ${error.message}`); } } async _mcpRequest(serverName, method, params = {}, sessionId = null, customInstance) { const instance = customInstance || this.mcpServerInstances.get(serverName); if (!instance) { throw new Error(`MCP server not found: ${serverName}`); } if (instance.type === "command") { throw new Error(`Direct MCP requests not supported for command-based servers: ${serverName}`); } const url = instance.config.url; const headers = { "Content-Type": "application/json", Accept: "application/json, text/event-stream", ...(sessionId && { "mcp-session-id": sessionId }), ...(instance.config.headers || {}), }; const response = await fetch(url, { method: "POST", headers, body: JSON.stringify({ jsonrpc: "2.0", id: Date.now(), method, ...(Object.keys(params).length > 0 && { params }), }), signal: instance.config.timeout ? AbortSignal.timeout(instance.config.timeout) : undefined, }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const text = await response.text(); const lines = text.trim().split("\n"); const dataLine = lines .find((line) => line.startsWith("data: ")) ?.substring(6); if (!dataLine) { throw new Error(`Invalid SSE response format: ${text}`); } const data = JSON.parse(dataLine); if (data.error) { throw new Error(`MCP Error: ${data.error.message}`); } return { data, response }; } async getMCPTools() { await this.ensureMCPInitialized(); const now = Date.now(); if (this.mcpToolsCache && now - this.mcpLastCacheTime < this.CACHE_DURATION) { return this.mcpToolsCache; } if (!this.mcpEnabled || this.mcpServerInstances.size === 0) { logger.debug(`getMCPTools: enabled=${this.mcpEnabled}, servers=${this.mcpServerInstances.size}`); return []; } const allTools = await this._getMCPToolsInternal(); this.mcpToolsCache = allTools; this.mcpLastCacheTime = now; return allTools; } async _getMCPToolsInternal() { const allTools = []; this.toolToServerMap.clear(); for (const [serverName, instance] of this.mcpServerInstances.entries()) { if (!instance.initialized) continue; try { if (instance.type === "command") { // For command-based servers, we'd need to implement stdio communication logger.debug(`Command-based servers not yet implemented for ${serverName}`); continue; } const { data } = await this._mcpRequest(serverName, "tools/list", {}, instance.sessionId); const tools = data.result?.tools || []; logger.debug(`Retrieved ${tools.length} tools from ${serverName}`); const formattedTools = tools.map((tool) => { this.toolToServerMap.set(tool.name, serverName); return { type: "function", function: { name: tool.name, description: tool.description, parameters: tool.inputSchema, }, _mcpServerName: serverName, }; }); allTools.push(...formattedTools); } catch (error) { logger.debug(`Failed to get tools from ${serverName}: ${error.message}`); } } logger.debug(`Retrieved ${allTools.length} total tools from ${this.mcpServerInstances.size} servers`); return allTools; } _displayToolList(tools) { console.log(`🛠️ Available MCP tools (${tools.length}):`); tools.forEach((tool, index) => { console.log(` ${index + 1}. ${tool.function.name} - ${tool.function.description}`); }); console.log(""); } async callMCPTool(nameOrServer, argsOrToolName, args) { // Determine which signature is being used if (args !== undefined) { // New signature: callMCPTool(serverName, toolName, args) return this._callToolOnServer(nameOrServer, argsOrToolName, args); } else { // Legacy signature: callMCPTool(toolName, args) return this._callToolByName(nameOrServer, argsOrToolName); } } async _callToolByName(toolName, args) { const serverName = this.toolToServerMap?.get(toolName); if (!serverName) { throw new Error(`Tool ${toolName} not found on any connected MCP server`); } return this._callToolOnServer(serverName, toolName, args); } async _callToolOnServer(serverName, toolName, args) { const instance = this.mcpServerInstances.get(serverName); if (!instance) { throw new Error(`MCP server not found: ${serverName}`); } if (!instance.initialized) { throw new Error(`Server ${serverName} is not initialized`); } try { if (instance.type === "command") { throw new Error(`Command-based tool calling not yet implemented for ${serverName}`); } const { data } = await this._mcpRequest(serverName, "tools/call", { name: toolName, arguments: args }, instance.sessionId); logger.debug(`Tool ${toolName} called successfully on ${serverName}`); return data.result; } catch (error) { logger.error(`Failed to call tool ${toolName} on ${serverName}: ${error.message}`); throw error; } } // New methods from README async listAvailableTools(serverName) { if (serverName) { return this._getToolsFromServer(serverName); } return this.getMCPTools(); } async _getToolsFromServer(serverName) { const instance = this.mcpServerInstances.get(serverName); if (!instance) { throw new Error(`MCP server not found: ${serverName}`); } if (!instance.initialized) { throw new Error(`Server ${serverName} is not initialized`); } if (instance.type === "command") { throw new Error(`Tool listing not yet implemented for command-based servers: ${serverName}`); } try { const { data } = await this._mcpRequest(serverName, "tools/list", {}, instance.sessionId); const tools = data.result?.tools || []; return tools.map((tool) => ({ type: "function", function: { name: tool.name, description: tool.description, parameters: tool.inputSchema, }, _mcpServerName: serverName, })); } catch (error) { logger.error(`Failed to get tools from ${serverName}: ${error.message}`); throw error; } } async getMCPServerStatus(serverName) { if (serverName) { return this._getServerStatus(serverName); } const statuses = []; for (const [name, instance] of this.mcpServerInstances.entries()) { statuses.push(this._getServerStatus(name)); } return statuses; } _getServerStatus(serverName) { const instance = this.mcpServerInstances.get(serverName); if (!instance) { throw new Error(`MCP server not found: ${serverName}`); } const status = { name: serverName, type: instance.type, status: instance.initialized ? "connected" : instance.lastError ? "error" : "disconnected", lastError: instance.lastError, uptime: instance.startTime ? Date.now() - instance.startTime : undefined, }; if (instance.type === "command" && instance.process) { status.pid = instance.process.pid; } else if (instance.type !== "command") { status.url = instance.config.url; } return status; } async restartMCPServer(serverName) { const instance = this.mcpServerInstances.get(serverName); if (!instance) { throw new Error(`MCP server not found: ${serverName}`); } logger.info(`Restarting MCP server: ${serverName}`); // Stop the current instance if (instance.type === "command" && instance.process) { instance.process.kill(); } // Reset instance state instance.initialized = false; instance.lastError = undefined; instance.startTime = Date.now(); // Restart await this._initializeMCPServer(serverName); } async getMCPServerCapabilities(serverName) { const instance = this.mcpServerInstances.get(serverName); if (!instance) { throw new Error(`MCP server not found: ${serverName}`); } if (!instance.initialized) { throw new Error(`Server ${serverName} is not initialized`); } if (instance.type === "command") { throw new Error(`Capabilities querying not yet implemented for command-based servers: ${serverName}`); } try { const { data } = await this._mcpRequest(serverName, "initialize", { protocolVersion: "2024-11-05", capabilities: { tools: {} }, clientInfo: { name: "multi-agent-client", version: "1.0.0" }, }, instance.sessionId); return data.result?.capabilities || {}; } catch (error) { logger.error(`Failed to get capabilities from ${serverName}: ${error.message}`); throw error; } } // ============================================================================ // PER-REQUEST MCP SERVER METHODS // ============================================================================ async _initializePerRequestServers(mcpServersConfig) { const perRequestServers = new Map(); for (const [serverName, config] of Object.entries(mcpServersConfig)) { const tempServerName = `temp_${serverName}_${Date.now()}`; const processedConfig = processServerConfig(config); const serverType = this._getServerType(processedConfig); const instance = { name: tempServerName, config: processedConfig, type: serverType, initialized: false, startTime: Date.now(), }; perRequestServers.set(tempServerName, instance); try { if (serverType === "command") { await this._initializeCommandServer(instance); } else { await this._initializeHttpServer(instance); } logger.debug(`Per-request server ${tempServerName} initialized successfully`); } catch (error) { instance.lastError = error.message; logger.debug(`Failed to initialize per-request server ${tempServerName}: ${error.message}`); } } return perRequestServers; } async _getToolsFromPerRequestServers(perRequestServers) { const allTools = []; for (const [serverName, instance] of perRequestServers.entries()) { if (!instance.initialized) continue; try { if (instance.type === "command") { logger.debug(`Command-based servers not yet implemented for per-request ${serverName}`); continue; } const { data } = await this._mcpRequest(serverName, "tools/list", {}, instance.sessionId, instance); const tools = data.result?.tools || []; logger.debug(`Retrieved ${tools.length} tools from per-request server ${serverName}`); const formattedTools = tools.map((tool) => { // Add per-request tools to the tool mapping this.toolToServerMap.set(tool.name, serverName); return { type: "function", function: { name: tool.name, description: tool.description, parameters: tool.inputSchema, }, _mcpServerName: serverName, _isPerRequest: true, }; }); allTools.push(...formattedTools); } catch (error) { logger.debug(`Failed to get tools from per-request server ${serverName}: ${error.message}`); } } return allTools; } _cleanupPerRequestServers(perRequestServers) { for (const [serverName, instance] of perRequestServers.entries()) { if (instance.type === "command" && instance.process) { instance.process.kill(); logger.debug(`Cleaned up per-request command server ${serverName}`); } // Clean up tool mappings for per-request servers for (const [toolName, mappedServerName] of this.toolToServerMap.entries()) { if (mappedServerName === serverName) { this.toolToServerMap.delete(toolName); } } } } async _callToolWithServers(toolName, args, perRequestServers) { // First, check if the tool is from a per-request server for (const [serverName, instance] of perRequestServers.entries()) { if (!instance.initialized) continue; try { if (instance.type === "command") { continue; // Skip command servers for now } // Check if this server has the tool (we could cache this, but for simplicity...) const { data } = await this._mcpRequest(serverName, "tools/list", {}, instance.sessionId, instance); const tools = data.result?.tools || []; const hasTool = tools.some((tool) => tool.name === toolName); if (hasTool) { const result = await this._mcpRequest(serverName, "tools/call", { name: toolName, arguments: args }, instance.sessionId, instance); logger.debug(`Tool ${toolName} called successfully on per-request server ${serverName}`); return result.data.result; } } catch (error) { logger.debug(`Failed to call tool ${toolName} on per-request server ${serverName}: ${error.message}`); } } // Fall back to global servers return this._callToolByName(toolName, args); } // ============================================================================ // CORE COMPLETION METHODS // ============================================================================ async createChatCompletion(options) { const modelName = options.model; const providerConfig = getProviderConfig(modelName); const optimizedOptions = optimizeParametersForProvider(options, modelName); if (providerConfig) { logger.debug(`Routing request to ${providerConfig.name}`, { model: modelName, streaming: !!optimizedOptions.stream, }); const client = getProviderClient(providerConfig); const response = await client.chat.completions.create(optimizedOptions); return optimizedOptions.stream ? normalizeStream(response, modelName) : response; } else { logger.debug(`Routing request to OpenAI`, { model: modelName, streaming: !!optimizedOptions.stream, }); const openaiClient = new openai_1.default(this.originalOpenAIOptions); const response = await openaiClient.chat.completions.create(optimizedOptions); return optimizedOptions.stream ? normalizeStream(response, modelName) : response; } } async createWithMCPTools(options) { // Handle per-request MCP servers let perRequestServers = new Map(); try { logger.debug("Starting createWithMCPTools"); let perRequestTools = []; if (options.mcpServers) { logger.debug("Initializing per-request MCP servers"); perRequestServers = await this._initializePerRequestServers(options.mcpServers); perRequestTools = await this._getToolsFromPerRequestServers(perRequestServers); logger.debug(`Got ${perRequestTools.length} tools from per-request servers`); } // Get global MCP tools const globalMcpTools = await this.getMCPTools(); logger.debug(`Got ${globalMcpTools.length} global MCP tools`); const userTools = options.tools || []; const allTools = [...userTools, ...globalMcpTools, ...perRequestTools]; const enhancedOptions = { ...options }; // Remove custom options from the request (not part of OpenAI API) delete enhancedOptions.mcpServers; delete enhancedOptions.toolExecutionFeedback; if (allTools.length > 0) { enhancedOptions.tools = optimizeToolsForProvider(allTools, options.model); enhancedOptions.tool_choice = options.tool_choice || "auto"; logger.debug(`Enhanced request with ${enhancedOptions.tools.length} tools`); } else if (options.tool_choice) { delete enhancedOptions.tool_choice; logger.debug("No tools available, removed tool_choice"); } // Handle streaming requests with tool execution if (options.stream) { logger.debug("Streaming request with tool execution support"); return this._createStreamingWithTools(enhancedOptions, perRequestServers, options.toolExecutionFeedback); } // Handle non-streaming requests const response = await this.createChatCompletion(enhancedOptions); logger.debug("Got response from LLM, processing tool calls"); // Process tool calls for non-streaming if (response.choices?.[0]?.message?.tool_calls) { const result = await this._processToolCalls(response, options, perRequestServers); // Cleanup per-request servers after processing tool calls if (perRequestServers.size > 0) { this._cleanupPerRequestServers(perRequestServers); } return result; } logger.debug("createWithMCPTools completed successfully"); // Cleanup per-request servers for requests without tool calls if (perRequestServers.size > 0) { this._cleanupPerRequestServers(perRequestServers); } return response; } catch (error) { logger.error("Failed to create completion with MCP tools", { error: error.message, }); // Cleanup per-request servers on error if (perRequestServers.size > 0) { this._cleanupPerRequestServers(perRequestServers); } throw error; } } // Enhanced streaming with tool execution feedback async *_createStreamingWithTools(options, perRequestServers = new Map(), toolExecutionFeedback = false) { logger.debug("Starting streaming with tool execution"); try { // Start initial streaming request const initialStream = await this.createChatCompletion(options); let assistantMessage = { role: "assistant", content: "", tool_calls: [], }; let toolCallsBuffer = new Map(); // index -> partial tool call let hasToolCalls = false; // Stream initial response and collect tool calls for await (const chunk of initialStream) { // Yield content chunks immediately if (chunk.choices?.[0]?.delta?.content) { assistantMessage.content += chunk.choices[0].delta.content; yield chunk; } // Collect tool calls if (chunk.choices?.[0]?.delta?.tool_calls) { hasToolCalls = true; // Emit tool call detected event if feedback enabled if (toolExecutionFeedback && toolCallsBuffer.size === 0) { yield { type: "tool_execution_event", event: { type: "tool_call_detected", message: "Tool calls detected, preparing to execute...", }, }; } for (const toolCallDelta of chunk.choices[0].delta.tool_calls) { const index = toolCallDelta.index; if (!toolCallsBuffer.has(index)) { toolCallsBuffer.set(index, { id: toolCallDelta.id || `call_${Date.now()}_${index}`, type: "function", function: { name: toolCallDelta.function?.name || "", arguments: toolCallDelta.function?.arguments || "", }, }); } else { const existing = toolCallsBuffer.get(index); // Update ID if provided if (toolCallDelta.id) { existing.id = toolCallDelta.id; } // Append function name if (toolCallDelta.function?.name) { existing.function.name += toolCallDelta.function.name; } // Append function arguments if (toolCallDelta.function?.arguments) { existing.function.arguments += toolCallDelta.function.arguments; } } } // Yield tool call chunks yield chunk; } // Handle finish reason if (chunk.choices?.[0]?.finish_reason) { yield chunk; } } // If no tool calls, we're done if (!hasToolCalls) { logger.debug("No tool calls detected, streaming complete"); // Cleanup per-request servers before returning if (perRequestServers.size > 0) { this._cleanupPerRequestServers(perRequestServers); } return; } // Convert to array and validate assistantMessage.tool_calls = Array.from(toolCallsBuffer.values()).filter((toolCall) => { // Ensure all tool calls have valid data if (!toolCall.id || !toolCall.function.name) { logger.error(`Invalid tool call: missing ID or name`, toolCall); return false; } return true; }); if (assistantMessage.tool_calls.length === 0) { logger.debug("No valid tool calls found after filtering"); // Cleanup per-request servers before returning if (perRequestServers.size > 0) { this._cleanupPerRequestServers(perRequestServers); } return; } logger.debug(`Executing ${assistantMessage.tool_calls.length} tool calls`); // Emit tool execution start event if (toolExecutionFeedback) { yield { type: "tool_execution_event", event: { type: "tool_execution_start", message: `Executing ${assistantMessage.tool_calls.length} tool(s)...`, tools: assistantMessage.tool_calls.map((tc) => tc.function.name), totalTools: assistantMessage.tool_calls.length, }, }; } const toolResults = []; for (const [index, toolCall] of assistantMessage.tool_calls.entries()) { // Emit tool executing event if (toolExecutionFeedback) { yield { type: "tool_execution_event", event: { type: "tool_executing", message: `Executing ${toolCall.function.name}... (${index + 1}/${assistantMessage.tool_calls.length})`, toolName: toolCall.function.name, progress: (index + 1) / assistantMessage.tool_calls.length, }, }; } try { logger.debug(`Executing tool: ${toolCall.function.name}`, { id: toolCall.id, arguments: toolCall.function.arguments.substring(0, 100) + "...", }); let args; try { // Validate JSON completeness before parsing if (!toolCall.function.arguments || toolCall.function.arguments.trim() === "") { throw new Error("Empty arguments"); } args = JSON.parse(toolCall.function.arguments); } catch (parseError) { logger.error(`Failed to parse tool arguments for ${toolCall.function.name}:`, { error: parseError.message, arguments: toolCall.function.arguments, }); toolResults.push({ tool_call_id: toolCall.id, role: "tool", content: `Error: Invalid tool arguments - ${parseError.message}`, }); // Emit tool error event if (toolExecutionFeedback) { yield { type: "tool_execution_event", event: { type: "tool_error", message: `❌ ${toolCall.function.name} failed: Invalid arguments`, toolName: toolCall.function.name, error: parseError.message, success: false, }, }; } continue; } if (this.toolToServerMap.has(toolCall.function.name)) { if ((this.mcpEnabled && this.mcpClient && this.mcpServerInstances.size > 0) || perRequestServers.size > 0) { const startTime = Date.now(); const result = await this._callToolWithServers(toolCall.function.name, args, perRequestServers); const duration = Date.now() - startTime; // Extract content safely from various possible result structures let content = JSON.stringify(result); try { if (result.content && Array.isArray(result.content) && result.content[0]?.text) { content = result.content[0].text; } else if (typeof result.content === 'string') { content = result.content; } else if (typeof result === 'string') { content = result; } } catch (e) { // Fallback to JSON string content = JSON.stringify(result); } toolResults.push({ tool_call_id: toolCall.id, role: "tool", content: content, }); // Emit tool completed event if (toolExecutionFeedback) { yield { type: "tool_execution_event", event: { type: "tool_completed", message: `✅ ${toolCall.function.name} completed (${duration}ms)`, toolName: toolCall.function.name, duration, success: true, }, }; } logger.debug(`Tool ${toolCall.function.name} completed successfully`); } else { toolResults.push({ tool_call_id: toolCall.id, role: "tool", content: `Tool ${toolCall.function.name} is not available (MCP server not connected)`, }); } } else { toolResults.push({ tool_call_id: toolCall.id, role: "tool", content: `Tool ${toolCall.function.name} requires external execution`, }); } } catch (error) { logger.error(`Tool call failed: ${toolCall.function.name}`, { error: error.message, }); toolResults.push({ tool_call_id: toolCall.id, role: "tool", content: `Error: ${error.message}`, }); // Emit tool error event if (toolExecutionFeedback) { yield { type: "tool_execution_event", event: { type: "tool_error", message: `❌ ${toolCall.function.name} failed: ${error.message}`, toolName: toolCall.function.name, error: error.message, success: false, }, }; } } } // Only proceed with follow-up if we have valid tool results if (toolResults.length === 0) { logger.debug("No tool results to process"); // Cleanup per-request servers before returning if (perRequestServers.size > 0) { this._cleanupPerRequestServers(perRequestServers); } return; } // Emit tools completed event if (toolExecutionFeedback) { const successful = toolResults.filter((r) => !r.content.startsWith("Error:")).length; yield { type: "tool_execution_event", event: { type: "tools_completed", message: "All tools completed. Generating final response...", totalTools: assistantMessage.tool_calls.length, successful, }, }; } // Make follow-up streaming request with tool results logger.debug("Making follow-up streaming request with tool results"); const followUpOptions = { ...options, messages: [...options.messages, assistantMessage, ...toolResults], };