UNPKG

@juspay/neurolink

Version:

Universal AI Development Platform with working MCP integration, multi-provider support, voice (TTS/STT/realtime), and professional CLI. 58+ external MCP servers discoverable, multimodal file processing, RAG pipelines. Build, test, and deploy AI applicatio

986 lines (985 loc) 90.7 kB
async function loadBedrockControl() { return await import(/* @vite-ignore */ "@aws-sdk/client-bedrock"); } import { BedrockRuntimeClient, ConverseCommand, ConverseStreamCommand, ImageFormat, } from "@aws-sdk/client-bedrock-runtime"; import path from "path"; import { createAnalytics } from "../core/analytics.js"; import { BaseProvider } from "../core/baseProvider.js"; import { DEFAULT_MAX_STEPS } from "../core/constants.js"; import { AuthenticationError, ProviderError, RateLimitError, } from "../types/index.js"; import { isAbortError, withTimeout } from "../utils/errorHandling.js"; import { emitToolEndFromStepFinish } from "../utils/toolEndEmitter.js"; import { logger } from "../utils/logger.js"; import { calculateCost } from "../utils/pricing.js"; import { buildMultimodalMessagesArray } from "../utils/messageBuilder.js"; import { buildMultimodalOptions } from "../utils/multimodalOptionsBuilder.js"; import { convertZodToJsonSchema } from "../utils/schemaConversion.js"; import { SpanKind, SpanStatusCode } from "@opentelemetry/api"; import { tracers } from "../telemetry/index.js"; const bedrockTracer = tracers.provider; // Bedrock-specific types now imported from ../types/providerSpecific.js export class AmazonBedrockProvider extends BaseProvider { bedrockClient; conversationHistory = []; region; /** * Parse the region segment from a Bedrock ARN. * Returns null when the input is not an ARN. * * Supports all AWS partitions: * - `arn:aws:bedrock:…` (commercial) * - `arn:aws-cn:bedrock:…` (China) * - `arn:aws-us-gov:bedrock:…` (GovCloud) */ static extractRegionFromArn(modelId) { if (!modelId) { return null; } const match = modelId.match(/^arn:aws[a-z0-9-]*:bedrock:([^:]+):/); return match?.[1] ?? null; } constructor(modelName, neurolink, region, credentials) { super(modelName, "bedrock", neurolink); // When the model is given as a Bedrock ARN (e.g. an inference profile // like `arn:aws:bedrock:us-east-1:123:inference-profile/foo`), Bedrock // requires the runtime client's region to match the region embedded // in the ARN — otherwise it returns "The provided model identifier is // invalid." Auto-extract so users don't have to keep AWS_REGION in // sync with their model ARN. const resolvedModel = modelName || process.env.BEDROCK_MODEL || this.modelName; const arnRegion = AmazonBedrockProvider.extractRegionFromArn(resolvedModel); this.region = credentials?.region || region || arnRegion || process.env.AWS_REGION || "us-east-1"; logger.debug("[AmazonBedrockProvider] Starting constructor with extensive logging for debugging"); // Log environment variables for debugging logger.debug(`[AmazonBedrockProvider] Environment check: AWS_REGION=${process.env.AWS_REGION || "undefined"}, AWS_ACCESS_KEY_ID=${process.env.AWS_ACCESS_KEY_ID ? "SET" : "undefined"}, AWS_SECRET_ACCESS_KEY=${process.env.AWS_SECRET_ACCESS_KEY ? "SET" : "undefined"}`); try { // Create BedrockRuntimeClient with clean configuration like working Bedrock-MCP-Connector // Absolutely no proxy interference - let AWS SDK handle everything natively logger.debug("[AmazonBedrockProvider] Creating BedrockRuntimeClient with clean configuration"); this.bedrockClient = new BedrockRuntimeClient({ region: this.region, // Clean configuration - AWS SDK will handle credentials via: // 1. IAM roles (preferred in production) // 2. Environment variables // 3. AWS config files // 4. Instance metadata ...(credentials?.accessKeyId && credentials?.secretAccessKey ? { credentials: { accessKeyId: credentials.accessKeyId, secretAccessKey: credentials.secretAccessKey, ...(credentials.sessionToken ? { sessionToken: credentials.sessionToken } : {}), }, } : {}), }); logger.debug(`[AmazonBedrockProvider] Successfully created BedrockRuntimeClient with model: ${this.modelName}, region: ${this.region}`); } catch (error) { logger.error(`[AmazonBedrockProvider] CRITICAL: Failed to initialize BedrockRuntimeClient:`, error); throw error; } } /** * Perform initial health check to catch credential/connectivity issues early * This prevents the health check failure we saw in production logs */ async performInitialHealthCheck() { const { BedrockClient, ListFoundationModelsCommand } = await loadBedrockControl(); const bedrockClient = new BedrockClient({ region: this.region, }); try { logger.debug("[AmazonBedrockProvider] Starting initial health check to validate credentials and connectivity"); // Try to list foundation models as a lightweight health check const command = new ListFoundationModelsCommand({}); const startTime = Date.now(); await bedrockClient.send(command); const responseTime = Date.now() - startTime; logger.debug(`[AmazonBedrockProvider] Health check PASSED - credentials valid, connectivity good, responseTime: ${responseTime}ms`); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error(`[AmazonBedrockProvider] Health check FAILED - this will cause production failures:`, { error: errorMessage, errorType: error instanceof Error ? error.constructor.name : "Unknown", region: process.env.AWS_REGION || "us-east-1", hasAccessKey: !!process.env.AWS_ACCESS_KEY_ID, hasSecretKey: !!process.env.AWS_SECRET_ACCESS_KEY, }); // Don't throw here - let the actual usage fail with better context } finally { try { bedrockClient.destroy(); } catch { // Ignore destroy errors during cleanup } } } // Not using AI SDK approach in conversation management getAISDKModel() { throw new Error("AmazonBedrockProvider does not use AI SDK models"); } getProviderName() { return "bedrock"; } getDefaultModel() { return process.env.BEDROCK_MODEL || "anthropic.claude-sonnet-4-6"; } /** * Get the default embedding model for Amazon Bedrock * @returns The default Bedrock embedding model name */ getDefaultEmbeddingModel() { return (process.env.BEDROCK_EMBEDDING_MODEL || process.env.AWS_EMBEDDING_MODEL || "amazon.titan-embed-text-v2:0"); } // Override the main generate method to implement conversation management async generate(optionsOrPrompt) { logger.debug("[AmazonBedrockProvider] generate() called with conversation management"); const generateStartTime = Date.now(); const options = typeof optionsOrPrompt === "string" ? { prompt: optionsOrPrompt } : optionsOrPrompt; // Clear conversation history for new generation this.conversationHistory = []; // Check for multimodal input (images, PDFs, CSVs, files) // Cast to any to access multimodal properties (runtime check is safe) const input = options.input; const hasMultimodalInput = !!(input?.images?.length || input?.content?.length || input?.files?.length || input?.csvFiles?.length || input?.pdfFiles?.length); if (hasMultimodalInput) { logger.debug(`[AmazonBedrockProvider] Detected multimodal input in generate(), using multimodal message builder`, { hasImages: !!input?.images?.length, imageCount: input?.images?.length || 0, hasContent: !!input?.content?.length, contentCount: input?.content?.length || 0, hasFiles: !!input?.files?.length, fileCount: input?.files?.length || 0, hasCSVFiles: !!input?.csvFiles?.length, csvFileCount: input?.csvFiles?.length || 0, hasPDFFiles: !!input?.pdfFiles?.length, pdfFileCount: input?.pdfFiles?.length || 0, }); // Cast options to StreamOptions for multimodal processing const streamOptions = options; const multimodalOptions = buildMultimodalOptions(streamOptions, this.providerName, this.modelName); const multimodalMessages = await buildMultimodalMessagesArray(multimodalOptions, this.providerName, this.modelName); // Convert to Bedrock format this.conversationHistory = this.convertToBedrockMessages(multimodalMessages); } else { logger.debug(`[AmazonBedrockProvider] Text-only input in generate(), using simple message builder`); // Add user message to conversation - simple text-only case const userMessage = { role: "user", content: [{ text: options.prompt }], }; this.conversationHistory.push(userMessage); } logger.debug(`[AmazonBedrockProvider] Starting conversation with ${this.conversationHistory.length} message(s)`); // Start conversation loop and return enhanced result let text; let usage; let finishReason; try { ({ text, usage, finishReason } = await this.conversationLoop(options)); } catch (error) { // Emit failure generation:end so Pipeline B records the failed generation const failEmitter = this.neurolink?.getEventEmitter(); if (failEmitter) { failEmitter.emit("generation:end", { provider: this.providerName, responseTime: Date.now() - generateStartTime, timestamp: Date.now(), result: { content: "", usage: { input: 0, output: 0, total: 0 }, model: this.modelName || this.getDefaultModel(), provider: this.providerName, finishReason: "error", }, success: false, error: error instanceof Error ? error.message : String(error), }); } throw error; } // Emit generation:end so Pipeline B (Langfuse) creates a GENERATION observation. // Bedrock bypasses the Vercel AI SDK so experimental_telemetry is never injected; // we emit the event manually to fill that gap. const generateEmitter = this.neurolink?.getEventEmitter(); if (generateEmitter) { generateEmitter.emit("generation:end", { provider: this.providerName, responseTime: Date.now() - generateStartTime, timestamp: Date.now(), result: { content: text, usage, model: this.modelName || this.getDefaultModel(), provider: this.providerName, finishReason, }, success: true, }); } return { content: text, // CLI expects 'content' not 'text' usage, model: this.modelName || this.getDefaultModel(), provider: this.getProviderName(), }; } async conversationLoop(options) { const maxIterations = 10; // Prevent infinite loops let iteration = 0; let totalInputTokens = 0; let totalOutputTokens = 0; let lastFinishReason; while (iteration < maxIterations) { iteration++; logger.debug(`[AmazonBedrockProvider] Conversation iteration ${iteration}`); try { logger.debug(`[AmazonBedrockProvider] About to call Bedrock API`); const response = await this.callBedrock(options); logger.debug(`[AmazonBedrockProvider] Received Bedrock response`, JSON.stringify(response, null, 2)); // Accumulate real token counts and capture the stop reason so // Pipeline B (Langfuse) gets correct usage and finishReason. totalInputTokens += response.usage?.inputTokens ?? 0; totalOutputTokens += response.usage?.outputTokens ?? 0; if (response.stopReason) { lastFinishReason = response.stopReason; } const result = await this.handleBedrockResponse(response); logger.debug(`[AmazonBedrockProvider] Handle response result:`, result); if (result.shouldContinue) { logger.debug(`[AmazonBedrockProvider] Continuing conversation loop...`); } else { logger.debug(`[AmazonBedrockProvider] Conversation completed with final text`); logger.debug(`[AmazonBedrockProvider] Returning final text: "${result.text}"`); return { text: result.text || "", usage: { input: totalInputTokens, output: totalOutputTokens, total: totalInputTokens + totalOutputTokens, }, finishReason: lastFinishReason, }; } } catch (error) { logger.error(`[AmazonBedrockProvider] Error in conversation loop:`, error); throw this.handleProviderError(error); } } throw new Error("Conversation loop exceeded maximum iterations"); } async callBedrock(options) { const startTime = Date.now(); return bedrockTracer.startActiveSpan("bedrock.generate", { kind: SpanKind.CLIENT, attributes: { "gen_ai.system": "aws.bedrock", "gen_ai.request.model": this.modelName || this.getDefaultModel(), "gen_ai.operation.name": "chat", }, }, async (generateSpan) => { logger.info(`[AmazonBedrockProvider] Starting Bedrock API call at ${new Date().toISOString()}`); try { // Pre-call validation and logging let region = "unknown"; try { region = typeof this.bedrockClient.config.region === "function" ? await this.bedrockClient.config.region() : (this.bedrockClient.config.region ?? "unknown"); } catch { // Region lookup failed — not critical, only used for logging } logger.info(`[AmazonBedrockProvider] Client region: ${region}`); logger.info(`[AmazonBedrockProvider] Model: ${this.modelName || this.getDefaultModel()}`); logger.info(`[AmazonBedrockProvider] Conversation history length: ${this.conversationHistory.length}`); // Get all available tools const aiTools = await this.getAllTools(); const allTools = this.convertAISDKToolsToToolDefinitions(aiTools); const toolConfig = this.formatToolsForBedrock(allTools); const commandInput = { modelId: this.modelName || this.getDefaultModel(), messages: this.convertToAWSMessages(this.conversationHistory), system: [ { text: options.systemPrompt || "You are a helpful assistant with access to external tools. Use tools when necessary to provide accurate information.", }, ], inferenceConfig: { maxTokens: options.maxTokens, // No default limit - unlimited unless specified temperature: options.temperature || 0.7, }, }; if (toolConfig) { commandInput.toolConfig = toolConfig; logger.info(`[AmazonBedrockProvider] Tools configured: ${toolConfig.tools?.length || 0}`); } // Log command details for debugging logger.info(`[AmazonBedrockProvider] Command input summary:`); logger.info(` - Model ID: ${commandInput.modelId}`); logger.info(` - Messages count: ${commandInput.messages?.length || 0}`); logger.info(` - System prompts: ${commandInput.system?.length || 0}`); logger.info(` - Max tokens: ${commandInput.inferenceConfig?.maxTokens}`); logger.info(` - Temperature: ${commandInput.inferenceConfig?.temperature}`); logger.debug(`[AmazonBedrockProvider] Calling Bedrock with ${this.conversationHistory.length} messages and ${toolConfig?.tools?.length || 0} tools`); // Create command and attempt API call const command = new ConverseCommand(commandInput); logger.debug("[Observability] Bedrock API request", { model: commandInput.modelId, region: region, messageCount: commandInput.messages?.length || 0, toolCount: commandInput.toolConfig?.tools?.length || 0, maxTokens: commandInput.inferenceConfig?.maxTokens, }); const apiCallStartTime = Date.now(); const response = await withTimeout(this.bedrockClient.send(command), 120_000, new Error("Bedrock API call timed out")); const apiCallDuration = Date.now() - apiCallStartTime; logger.debug("[Observability] Bedrock API response", { model: commandInput.modelId, durationMs: apiCallDuration, hasContent: !!response.output?.message?.content?.length, stopReason: response.stopReason, usage: response.usage ? { inputTokens: response.usage.inputTokens, outputTokens: response.usage.outputTokens, totalTokens: (response.usage.inputTokens || 0) + (response.usage.outputTokens || 0), } : undefined, }); logger.info(`[AmazonBedrockProvider] Bedrock API call successful`); logger.info(`[AmazonBedrockProvider] API call duration: ${apiCallDuration}ms`); const totalDuration = Date.now() - startTime; logger.info(`[AmazonBedrockProvider] Total callBedrock duration: ${totalDuration}ms`); generateSpan.setAttribute("gen_ai.response.stop_reason", response.stopReason ?? ""); generateSpan.setAttribute("gen_ai.usage.input_tokens", response.usage?.inputTokens ?? 0); generateSpan.setAttribute("gen_ai.usage.output_tokens", response.usage?.outputTokens ?? 0); const cost = calculateCost(this.providerName, this.modelName, { input: response.usage?.inputTokens ?? 0, output: response.usage?.outputTokens ?? 0, total: (response.usage?.inputTokens ?? 0) + (response.usage?.outputTokens ?? 0), }); if (cost && cost > 0) { generateSpan.setAttribute("neurolink.cost", cost); } generateSpan.setStatus({ code: SpanStatusCode.OK }); generateSpan.end(); return response; } catch (error) { const errorDuration = Date.now() - startTime; // Extract AWS metadata for structured logging const awsError = error && typeof error === "object" ? error : null; const metadata = awsError?.$metadata && typeof awsError.$metadata === "object" ? awsError.$metadata : null; logger.debug("[Observability] Bedrock API request failed", { model: this.modelName || this.getDefaultModel(), durationMs: errorDuration, error: error instanceof Error ? error.message : String(error), errorName: error instanceof Error ? error.name : undefined, httpStatus: metadata?.httpStatusCode, awsRequestId: metadata?.requestId, awsErrorCode: awsError?.Code, }); logger.error(`[AmazonBedrockProvider] Bedrock API call failed after ${errorDuration}ms`); if (error instanceof Error) { logger.error(`[AmazonBedrockProvider] Error: ${error.name} - ${error.message}`); } if (metadata) { logger.error(`[AmazonBedrockProvider] AWS SDK metadata`, { httpStatus: metadata.httpStatusCode, requestId: metadata.requestId, attempts: metadata.attempts, totalRetryDelay: metadata.totalRetryDelay, }); } generateSpan.setStatus({ code: SpanStatusCode.ERROR, message: error instanceof Error ? error.message : String(error), }); generateSpan.recordException(error instanceof Error ? error : new Error(String(error))); generateSpan.end(); throw error; } }); // end bedrockTracer.startActiveSpan('bedrock.generate') } async handleBedrockResponse(response) { logger.debug(`[AmazonBedrockProvider] Received response with stopReason: ${response.stopReason}`); if (!response.output || !response.output.message) { throw new Error("Invalid response structure from Bedrock API"); } const assistantMessage = response.output.message; const stopReason = response.stopReason; // Add assistant message to conversation history const bedrockAssistantMessage = { role: "assistant", content: (assistantMessage.content || []).map((item) => { const bedrockItem = {}; if ("text" in item && item.text) { bedrockItem.text = item.text; } if ("toolUse" in item && item.toolUse) { bedrockItem.toolUse = { toolUseId: item.toolUse.toolUseId || "", name: item.toolUse.name || "", input: item.toolUse.input || {}, }; } if ("toolResult" in item && item.toolResult) { bedrockItem.toolResult = { toolUseId: item.toolResult.toolUseId || "", content: (item.toolResult.content || []).map((c) => ({ text: typeof c === "object" && "text" in c ? c.text || "" : "", })), status: item.toolResult.status || "unknown", }; } return bedrockItem; }), }; this.conversationHistory.push(bedrockAssistantMessage); if (stopReason === "end_turn" || stopReason === "stop_sequence") { // Extract text from assistant message const textContent = bedrockAssistantMessage.content .filter((item) => item.text) .map((item) => item.text) .join(" "); return { shouldContinue: false, text: textContent }; } else if (stopReason === "tool_use") { logger.debug(`[AmazonBedrockProvider] Tool use detected - executing tools immediately`); // Execute all tool uses in the message const toolResults = []; for (const contentItem of bedrockAssistantMessage.content) { if (contentItem.toolUse) { logger.debug(`[AmazonBedrockProvider] Executing tool: ${contentItem.toolUse.name}`); try { // Execute tool using BaseProvider's tool execution logger.debug(`[AmazonBedrockProvider] Debug toolUse.input:`, JSON.stringify(contentItem.toolUse.input, null, 2)); const toolResult = await this.executeSingleTool(contentItem.toolUse.name, contentItem.toolUse.input || {}, contentItem.toolUse.toolUseId); logger.debug(`[AmazonBedrockProvider] Tool execution successful: ${contentItem.toolUse.name}`); toolResults.push({ toolResult: { toolUseId: contentItem.toolUse.toolUseId, content: [{ text: String(toolResult) }], status: "success", }, }); } catch (error) { logger.error(`[AmazonBedrockProvider] Tool execution failed: ${contentItem.toolUse.name}`, error); const errorMessage = error instanceof Error ? error.message : String(error); // Still create toolResult for failed tools to maintain 1:1 mapping with toolUse blocks toolResults.push({ toolResult: { toolUseId: contentItem.toolUse.toolUseId, content: [ { text: `Error executing tool ${contentItem.toolUse.name}: ${errorMessage}`, }, ], status: "error", }, }); } } } // Add tool results as user message if (toolResults.length > 0) { const userMessageWithToolResults = { role: "user", content: toolResults, }; this.conversationHistory.push(userMessageWithToolResults); logger.debug(`[AmazonBedrockProvider] Added ${toolResults.length} tool results to conversation`); } return { shouldContinue: true }; } else if (stopReason === "max_tokens") { // Max tokens reached — return what we have rather than continuing, // since the model hit the configured limit. const textContent = bedrockAssistantMessage.content .filter((item) => item.text) .map((item) => item.text) .join(" "); return { shouldContinue: false, text: textContent }; } else { logger.warn(`[AmazonBedrockProvider] Unrecognized stop reason "${stopReason}", ending conversation.`); return { shouldContinue: false, text: "" }; } } convertToAWSMessages(bedrockMessages) { return bedrockMessages.map((msg) => ({ role: msg.role, content: msg.content.map((item) => { if (item.text) { return { text: item.text, }; } if (item.image) { return { image: item.image, }; } if (item.document) { return { document: item.document, }; } if (item.toolUse) { return { toolUse: { toolUseId: item.toolUse.toolUseId, name: item.toolUse.name, input: item.toolUse.input, }, }; } if (item.toolResult) { return { toolResult: { toolUseId: item.toolResult.toolUseId, content: item.toolResult.content, status: item.toolResult.status, }, }; } return { text: "" }; }), })); } async executeSingleTool(toolName, args, _toolUseId) { return bedrockTracer.startActiveSpan("bedrock.tool.execute", { kind: SpanKind.CLIENT, attributes: { "gen_ai.tool.name": toolName, "gen_ai.system": "aws.bedrock", }, }, async (span) => { try { logger.debug(`[AmazonBedrockProvider] Executing single tool: ${toolName}`, { args, }); // Use BaseProvider's tool execution mechanism const aiTools = await this.getAllTools(); const tools = this.convertAISDKToolsToToolDefinitions(aiTools); if (!tools[toolName]) { throw new Error(`Tool not found: ${toolName}`); } const tool = tools[toolName]; if (!tool || !tool.execute) { throw new Error(`Tool ${toolName} does not have execute method`); } // Apply robust parameter handling like Bedrock-MCP-Connector // Bedrock toolUse.input already contains the correct parameter structure const toolInput = args || {}; // Add default parameters for common tools that Claude might call without required params if (toolName === "list_directory" && !toolInput.path) { toolInput.path = "."; logger.debug(`[AmazonBedrockProvider] Added default path '.' for list_directory tool`); } logger.debug(`[AmazonBedrockProvider] Tool input parameters:`, toolInput); // Convert Record<string, unknown> to ToolArgs by filtering out non-JsonValue types const toolArgs = {}; for (const [key, value] of Object.entries(toolInput)) { // Only include values that are JsonValue compatible if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean" || (typeof value === "object" && value !== null)) { toolArgs[key] = value; } } const result = await tool.execute(toolArgs); logger.debug(`[AmazonBedrockProvider] Tool execution result:`, { toolName, result, }); // Handle ToolResult type let finalResult; if (result && typeof result === "object" && "success" in result) { if (result.success && result.data !== undefined) { if (typeof result.data === "string") { finalResult = result.data; } else if (typeof result.data === "object") { finalResult = JSON.stringify(result.data, null, 2); } else { finalResult = String(result.data); } } else if (result.error) { const errorMessage = typeof result.error === "string" ? result.error : result.error.message || "Tool execution failed"; throw new Error(errorMessage); } else { finalResult = ""; } } else if (typeof result === "string") { // Fallback for non-ToolResult return types finalResult = result; } else if (typeof result === "object") { finalResult = JSON.stringify(result, null, 2); } else { finalResult = String(result); } span.setStatus({ code: SpanStatusCode.OK }); return finalResult; } catch (error) { logger.error(`[AmazonBedrockProvider] Tool execution error:`, { toolName, error, }); span.setStatus({ code: SpanStatusCode.ERROR, message: error.message, }); span.recordException(error); throw error; } finally { span.end(); } }); } convertAISDKToolsToToolDefinitions(aiTools) { const result = {}; for (const [name, tool] of Object.entries(aiTools)) { if ("description" in tool && tool.description) { // Extract schema from legacy `parameters` (AI SDK v3/v4) or current `inputSchema` (v6) const legacyTool = tool; const extractedParams = legacyTool.parameters ?? tool.inputSchema; result[name] = { description: tool.description, parameters: extractedParams, execute: async (params) => { if ("execute" in tool && tool.execute) { const result = await tool.execute(params, { toolCallId: `tool_${Date.now()}`, messages: [], }); return { success: true, data: result, }; } throw new Error(`Tool ${name} has no execute method`); }, }; } } return result; } formatToolsForBedrock(tools) { if (!tools || Object.keys(tools).length === 0) { return null; } const bedrockTools = Object.entries(tools).map(([name, tool]) => { // Handle Zod schema or plain object schema let schema; if (tool.parameters && typeof tool.parameters === "object") { // Check if it's a Zod schema if ("_def" in tool.parameters) { // It's a Zod schema, convert to JSON schema schema = convertZodToJsonSchema(tool.parameters); } else { // It's already a plain object schema schema = tool.parameters; } } else { schema = { type: "object", properties: {}, required: [], }; } // Ensure the schema always has type: "object" at the root level if (!schema.type || schema.type !== "object") { schema = { type: "object", properties: schema.properties || {}, required: schema.required || [], }; } const toolSpec = { name, description: tool.description, inputSchema: { json: schema, }, }; return { toolSpec, }; }); logger.debug(`[AmazonBedrockProvider] Formatted ${bedrockTools.length} tools for Bedrock`); return { tools: bedrockTools }; } // Convert multimodal messages to Bedrock format convertToBedrockMessages(messages) { return messages.map((msg) => { const bedrockMessage = { role: msg.role === "system" ? "user" : msg.role, content: [], }; if (typeof msg.content === "string") { bedrockMessage.content.push({ text: msg.content }); } else { msg.content.forEach((contentItem) => { if (contentItem.type === "text" && contentItem.text) { bedrockMessage.content.push({ text: contentItem.text }); } else if (contentItem.type === "image" && contentItem.image) { const imageData = typeof contentItem.image === "string" ? Buffer.from(contentItem.image.replace(/^data:image\/\w+;base64,/, ""), "base64") : contentItem.image; let format = contentItem.mimeType?.split("/")[1] || "png"; if (format === "jpg") { format = "jpeg"; } bedrockMessage.content.push({ image: { format: format === "jpeg" ? ImageFormat.JPEG : format === "png" ? ImageFormat.PNG : format === "gif" ? ImageFormat.GIF : ImageFormat.WEBP, source: { bytes: imageData, }, }, }); } else if (contentItem.type === "document" || contentItem.type === "pdf" || (contentItem.type === "file" && contentItem.mimeType?.toLowerCase().startsWith("application/pdf"))) { let docData; if (typeof contentItem.data === "string") { const pdfString = contentItem.data.replace(/^data:application\/pdf;base64,/i, ""); docData = Buffer.from(pdfString, "base64"); } else { docData = contentItem.data; } // Extract basename and sanitize for Bedrock's filename requirements // Bedrock only allows: alphanumeric, whitespace, hyphens, parentheses, brackets // NOTE: Periods (.) are NOT allowed, so we remove the extension let filename = typeof contentItem.name === "string" && contentItem.name ? path.basename(contentItem.name) : "document-pdf"; // Remove file extension filename = filename.replace(/\.[^.]+$/, ""); // Replace all disallowed characters with hyphens // Bedrock constraint: only alphanumeric, whitespace, hyphens, parentheses, brackets allowed filename = filename.replace(/[^a-zA-Z0-9\s\-()[\]]/g, "-"); // Clean up: remove multiple consecutive hyphens and trim filename = filename .replace(/-+/g, "-") .trim() .replace(/^-+|-+$/g, ""); // Fallback if filename becomes empty after sanitization filename = filename || "document"; bedrockMessage.content.push({ document: { format: "pdf", name: filename, source: { bytes: docData, }, }, }); } }); } return bedrockMessage; }); } // Bedrock-MCP-Connector compatibility getBedrockClient() { return this.bedrockClient; } async executeStream(options) { logger.debug("[TRACE] executeStream ENTRY - starting streaming attempt"); logger.info("[AmazonBedrockProvider] Attempting real streaming with ConverseStreamCommand"); return bedrockTracer.startActiveSpan("bedrock.stream", { kind: SpanKind.CLIENT, attributes: { "gen_ai.system": "aws.bedrock", "gen_ai.request.model": this.modelName || this.getDefaultModel(), "gen_ai.operation.name": "stream", }, }, async (streamSpan) => { try { logger.debug("[TRACE] executeStream TRY block - about to call streamingConversationLoop"); // Clear conversation history for new streaming session this.conversationHistory = []; // Check for multimodal input (images, PDFs, CSVs, files) const hasMultimodalInput = !!(options.input?.images?.length || options.input?.content?.length || options.input?.files?.length || options.input?.csvFiles?.length || options.input?.pdfFiles?.length); if (hasMultimodalInput) { logger.debug(`[AmazonBedrockProvider] Detected multimodal input, using multimodal message builder`, { hasImages: !!options.input?.images?.length, imageCount: options.input?.images?.length || 0, hasContent: !!options.input?.content?.length, contentCount: options.input?.content?.length || 0, hasFiles: !!options.input?.files?.length, fileCount: options.input?.files?.length || 0, hasCSVFiles: !!options.input?.csvFiles?.length, csvFileCount: options.input?.csvFiles?.length || 0, hasPDFFiles: !!options.input?.pdfFiles?.length, pdfFileCount: options.input?.pdfFiles?.length || 0, }); const multimodalOptions = buildMultimodalOptions(options, this.providerName, this.modelName); const multimodalMessages = await buildMultimodalMessagesArray(multimodalOptions, this.providerName, this.modelName); // Convert to Bedrock format this.conversationHistory = this.convertToBedrockMessages(multimodalMessages); } else { logger.debug(`[AmazonBedrockProvider] Text-only input, using simple message builder`); // Add user message to conversation - simple text-only case const userMessage = { role: "user", content: [{ text: options.input.text }], }; this.conversationHistory.push(userMessage); } logger.debug(`[AmazonBedrockProvider] Starting streaming conversation with ${this.conversationHistory.length} message(s)`); // Call the actual streaming implementation that already exists logger.debug("[TRACE] executeStream - calling streamingConversationLoop NOW"); const result = await this.streamingConversationLoop(options, streamSpan); logger.debug("[TRACE] executeStream - streamingConversationLoop SUCCESS, returning result"); streamSpan.setStatus({ code: SpanStatusCode.OK }); streamSpan.end(); return result; } catch (error) { logger.debug("[TRACE] executeStream CATCH - error caught from streamingConversationLoop"); const errorObj = error; // Check if error is related to streaming permissions const isPermissionError = errorObj?.name === "AccessDeniedException" || errorObj?.name === "UnauthorizedOperation" || errorObj?.message?.includes("bedrock:InvokeModelWithResponseStream") || errorObj?.message?.includes("streaming") || errorObj?.message?.includes("ConverseStream"); logger.debug("[TRACE] executeStream CATCH - checking if permission error"); logger.debug(`[TRACE] executeStream CATCH - isPermissionError=${isPermissionError}`); if (isPermissionError) { logger.debug("[TRACE] executeStream CATCH - PERMISSION ERROR DETECTED, starting fallback"); logger.warn(`[AmazonBedrockProvider] Streaming permissions not available, falling back to generate method: ${errorObj.message}`); streamSpan.addEvent("stream.fallback_to_generate", { reason: errorObj.message, }); // Fallback to generate method and convert to streaming format const generateResult = await this.generate({ prompt: options.input.text, input: options.input, maxTokens: options.maxTokens, temperature: options.temperature, systemPrompt: options.systemPrompt, }); if (!generateResult) { streamSpan.setStatus({ code: SpanStatusCode.ERROR, message: "Generate method returned null result", }); streamSpan.end(); // eslint-disable-next-line preserve-caught-error throw new Error("Generate method returned null result"); } streamSpan.setAttribute("gen_ai.response.stop_reason", "fallback_end_turn"); streamSpan.setStatus({ code: SpanStatusCode.OK }); streamSpan.end(); // Convert generate result to streaming format. // Use whitespace-preserving split (matches BaseProvider's // executeFakeStreaming) so newlines, tabs, indentation, code // blocks, and markdown tables aren't collapsed to single spaces. const stream = new ReadableStream({ start(controller) { const responseText = generateResult.content || ""; const tokens = responseText.split(/(\s+)/); let buffer = ""; for (let i = 0; i < tokens.length; i++) { buffer += tokens[i]; const shouldYield = i === tokens.length - 1 || buffer.length > 50 || /[.!?;,]\s*$/.test(buffer); if (shouldYield && buffer.length > 0) { controller.enqueue({ content: buffer }); buffer = ""; } } if (buffer.length > 0) { controller.enqueue({ content: buffer }); } controller.close(); }, }); // Convert ReadableStream to AsyncIterable like streamingConversationLoop does const asyncIterable = { async *[Symbol.asyncIterator]() { const reader = stream.getReader(); try { while (true) { const { done, value } = await reader.read(); if (done) { break; } yield value; } }