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