@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
JavaScript
"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],
};