@juspay/neurolink
Version:
The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002
1,687 lines • 88.1 kB
JavaScript
import { BedrockRuntimeClient, ImageFormat, InvokeModelCommand, } 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 { runAgenticLoop } from "../../core/loopEngine.js";
import { resolveToolExecutionRecords } from "../../core/toolExecutionRecorder.js";
import { transformToolExecutions } from "../../utils/transformationUtils.js";
import { composeAbortSignals } from "../../utils/timeout.js";
import { createBedrockLoopAdapter } from "./loopAdapter.js";
import { bedrockChunksToV3Stream, bedrockConversationFromV3Prompt, bedrockV3StreamToChunks, buildV3PromptFromBedrock, isTextOnlyBedrockConversation, unsentBedrockMiddlewareOptions, unwrapBedrockToolInputSchema, } from "./modelBridge.js";
import { AuthenticationError, ProviderError, RateLimitError, } from "../../types/index.js";
import { classifyProviderError } from "../../utils/errorClassifier.js";
import { isAbortError, withTimeout } from "../../utils/errorHandling.js";
import { ImageProcessor } from "../../utils/imageProcessor.js";
import { logger } from "../../utils/logger.js";
import { resolveSamplingParams } from "../../models/modelRegistry.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 { bedrockTracer } from "./constants.js";
import { loadBedrockControl } from "./utils.js";
/**
* The only image formats Nova Multimodal Embeddings accepts.
*
* An allowlist rather than a derivation from the mimetype, because every
* shortcut here produces a value Nova rejects for some ordinary input:
* splitting on "/" yields `svg+xml` for an SVG and `jpeg; charset=binary`
* when the mimetype carries parameters, and normalising `jpeg` to `jpg` —
* the natural-looking direction — turns the single most common input into
* the one spelling that is not on this list.
*/
const NOVA_IMAGE_FORMATS = {
"image/png": "png",
"image/jpeg": "jpeg",
"image/jpg": "jpeg",
"image/gif": "gif",
"image/webp": "webp",
};
/**
* Nova's format name for `mimeType`, or undefined when Nova has no name for it.
*
* Deliberately not defaulting to png. The RAG image path accepts bmp, tiff and
* avif, none of which Nova names, so a default would hand AWS a BMP labelled as
* a PNG — bytes and declared format disagreeing, with no error and no way for
* the caller to tell it happened. An unnamed format is surfaced to the caller
* instead; see the throw at the call site.
*/
function novaImageFormat(mimeType) {
const normalized = (mimeType ?? "").split(";").at(0)?.trim().toLowerCase();
return NOVA_IMAGE_FORMATS[normalized ?? ""];
}
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)
// Narrow to the StreamOptions input shape 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.
// The public generate contract accepts either `prompt` or `input.text`
// (see BaseProvider); reading only `prompt` here sent an empty user
// message whenever a caller used the `input.text` shape.
//
// `||`, not `??`, to match the canonical resolution in
// `Utilities.normalizeTextOptions`. This provider overrides `generate()`
// outright, so normalization never runs and the caller's raw options
// land here — an empty `prompt` must fall through to `input.text`
// rather than win by virtue of not being nullish.
const userMessage = {
role: "user",
content: [{ text: options.prompt || input?.text || "" }],
};
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;
let rawFinishReason;
let toolExecutions;
try {
({ text, usage, finishReason, rawFinishReason, toolExecutions } =
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),
});
// #1741: the SDK emits its own `generation:end` unless the native
// path marks that it already did. The failure branch has no result
// to carry the mark, so it goes on the error — the same shape the
// AI Studio path uses. Without it one failed Bedrock generate is
// counted twice in analytics and as two Pipeline B spans.
if (error && typeof error === "object") {
error._generationEndEmitted =
true;
}
}
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,
});
}
// Tool telemetry, mirroring the other `runAgenticLoop` consumers
// (anthropic, vertex, google-ai). Bedrock forwarded none of it, so
// `baseProvider` fell back to `result.toolsUsed || []` and every turn
// reported zero tools while running them correctly — leaving analytics,
// cost attribution and audit blind on this path.
const generateResult = {
content: text, // CLI expects 'content' not 'text'
usage,
model: this.modelName || this.getDefaultModel(),
provider: this.getProviderName(),
...(finishReason !== undefined && { finishReason }),
...(rawFinishReason !== undefined && { rawFinishReason }),
// `toolsUsed` means RAN, not "was asked for". `runAgenticLoop` records
// every dispatch it performed, including the ones that never reached a
// tool: a name the model invented (TOOL_NOT_FOUND), one the breaker had
// already given up on (TOOL_PERMANENTLY_FAILED), and one that threw.
// Exactly those carry `error`; a call that reached the tool and got a
// value back does not. That is the same line `nativeGenerateLoop` draws
// — its `toolsUsed.push(name)` sits inside the try, after the guarded
// call returns — so filtering here makes Bedrock's generate path report
// what every other generate path reports, instead of inflating
// analytics, cost attribution and audit with calls that never ran.
//
// `toolCalls` and `toolExecutions` stay complete on purpose: they are
// the record of what the turn ATTEMPTED, and a failed attempt is
// precisely what a caller debugging a turn needs to see.
toolsUsed: toolExecutions
.filter((execution) => execution.error === undefined)
.map((execution) => execution.name),
toolCalls: toolExecutions.map((execution) => ({
toolCallId: execution.id,
toolName: execution.name,
args: execution.input,
})),
toolExecutions: resolveToolExecutionRecords(options, transformToolExecutions(toolExecutions)),
enhancedWithTools: toolExecutions.some((execution) => execution.error === undefined),
};
// #1741: the SDK's `finalizeGenerateRequestResult` emits its own
// `generation:end` unless the result carries this mark, so every
// SUCCESSFUL Bedrock generate was counted twice — an extra Langfuse
// `model.generation` observation and an extra analytics record per call,
// which inflates Bedrock's cost against every other provider. Vertex and
// AI Studio have always set it; Bedrock emitted without marking. Set
// after construction, like those two, because the field is deliberately
// not part of the public `EnhancedGenerateResult` surface.
generateResult._generationEndEmitted = true;
return generateResult;
}
async conversationLoop(options) {
// The step cap is now the same `maxSteps || DEFAULT_MAX_STEPS` the
// streaming path has always used. It used to be a hardcoded 10 that
// ignored the caller's maxSteps entirely, and reaching it threw — which
// then propagated into NeuroLink's provider-level retry and ran the whole
// turn twice more, so a runaway tool loop cost thirty billed calls. The
// engine stops at the cap and returns what the turn produced.
const maxSteps = options.maxSteps || DEFAULT_MAX_STEPS;
const tools = await this.resolveTurnTools(options.tools, options.disableTools);
const toolConfig = this.formatToolsForBedrock(tools);
const sampling = resolveSamplingParams(this.providerName, this.modelName || this.getDefaultModel(), { temperature: options.temperature ?? 0.7 }, "bedrock.converse");
const systemPromptText = options.systemPrompt ||
"You are a helpful assistant with access to external tools. Use tools when necessary to provide accurate information.";
// Builds a fresh adapter per turn — the generate model's `doGenerate` may
// run this more than once is never true today (one call per turn), but a
// fresh adapter keeps `buildCommandInput` closed over the exact
// (possibly middleware-transformed) system/conversation/sampling for
// that call, rather than the outer ones.
const runTurn = (conversation, system, maxTokens, temperature, signal) => {
const adapter = createBedrockLoopAdapter({
client: this.bedrockClient,
streaming: false,
region: this.region,
maxSteps,
buildCommandInput: (conv) => ({
modelId: this.modelName || this.getDefaultModel(),
messages: this.convertToAWSMessages(conv),
// Converse rejects a blank block; transformParams may empty this.
...(system ? { system: [{ text: system }] } : {}),
inferenceConfig: {
maxTokens,
...(temperature !== undefined && { temperature }),
},
...(toolConfig ? { toolConfig } : {}),
}),
});
const { resultPromise } = runAgenticLoop(adapter, conversation, {
tools: this.toEngineTools(tools),
abortSignal: signal,
// `toEngineTools` adds no deadline of its own, so this is the only
// per-tool bound on the turn. The engine defaults when it is absent.
...(options.toolTimeoutMs !== undefined
? { toolTimeoutMs: options.toolTimeoutMs }
: {}),
});
return resultPromise;
};
try {
const initialConversation = this.conversationHistory;
let text;
let finishReason;
let rawFinishReason;
let usage;
let toolExecutions;
let conversationOut;
if (isTextOnlyBedrockConversation(initialConversation)) {
// Model middleware (transformParams/wrapGenerate) applies only to a
// text-only starting turn — see modelBridge.ts's header comment.
const v3Tools = toolConfig?.tools
?.map((t) => t.toolSpec)
.filter((spec) => !!spec)
.map((spec) => ({
type: "function",
name: spec.name ?? "",
description: spec.description,
inputSchema: unwrapBedrockToolInputSchema(spec.inputSchema),
}));
// Set only if `doGenerate` actually ran the loop — a guardrails-style
// wrapGenerate can answer from its own precall without ever calling
// it, and the richer loop result (toolExecutions/conversation) is
// then unavailable, tolerated below via `loopResult?.`.
let capturedLoopResult;
const generateModel = {
specificationVersion: "v3",
provider: this.providerName,
modelId: this.modelName || this.getDefaultModel(),
supportedUrls: {},
doGenerate: async (params) => {
if (params.tools !== undefined && params.tools !== v3Tools) {
logger.warn("[AmazonBedrockProvider] middleware rewrote 'tools' on the generate path; the rewrite was ignored and the caller's original tool configuration was sent");
}
this.warnOnUnsentMiddlewareOptions("generate", params);
// Read AFTER transformParams: by AI SDK contract, `params` here
// is already the transformed version, so a prompt rewrite
// reaches the wire.
const { system, conversation } = bedrockConversationFromV3Prompt(params.prompt);
const loopResult = runTurn(conversation, system, params.maxOutputTokens ?? options.maxTokens, params.temperature ?? sampling.temperature,
// Composed, not `??`: a middleware's signal must not displace
// the caller's.
composeAbortSignals(options.abortSignal, params.abortSignal));
capturedLoopResult = loopResult;
const resolved = await loopResult;
return {
content: resolved.text
? [{ type: "text", text: resolved.text }]
: [],
finishReason: {
unified: resolved.finishReason,
raw: resolved.rawStopReason,
},
usage: {
inputTokens: {
// `inputTokens` is the fresh portion only; AWS reports cache
// reads/writes beside it, so `total` sums all three.
total: resolved.usage.inputTokens +
(resolved.usage.cacheReadTokens ?? 0) +
(resolved.usage.cacheWriteTokens ?? 0),
noCache: resolved.usage.inputTokens,
cacheRead: resolved.usage.cacheReadTokens,
cacheWrite: resolved.usage.cacheWriteTokens,
},
outputTokens: { total: resolved.usage.outputTokens },
},
};
},
// This model exists only to be driven through doGenerate.
doStream: () => {
throw new Error("AmazonBedrockProvider's generate model does not implement doStream");
},
};
const wrappedModel = await this.applyMiddlewareToModel(generateModel, options);
if (typeof wrappedModel === "string") {
throw new Error("AmazonBedrockProvider: middleware returned a model id string, not a native model handle");
}
const v3Result = await wrappedModel.doGenerate({
prompt: buildV3PromptFromBedrock(systemPromptText, initialConversation),
maxOutputTokens: options.maxTokens,
temperature: sampling.temperature,
...(v3Tools ? { tools: v3Tools } : {}),
// Seed the signal so a middleware can compose with it, not replace it.
abortSignal: options.abortSignal,
});
// Settled, not re-thrown: a wrapGenerate may have caught the loop's
// rejection and answered itself, and a bare `await` would re-throw it
// over that recovered result.
const loopResult = await capturedLoopResult?.catch(() => undefined);
text = v3Result.content
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("");
finishReason =
loopResult?.finishReason ?? v3Result.finishReason.unified;
rawFinishReason =
loopResult?.rawStopReason ?? v3Result.finishReason.raw;
usage = loopResult?.usage ?? {
// `AgenticLoopUsage.inputTokens` is the fresh count, so read
// `noCache`; `total` would double-count the cache tokens below.
inputTokens: v3Result.usage.inputTokens.noCache ??
v3Result.usage.inputTokens.total ??
0,
outputTokens: v3Result.usage.outputTokens.total ?? 0,
...(v3Result.usage.inputTokens.cacheRead !== undefined && {
cacheReadTokens: v3Result.usage.inputTokens.cacheRead,
}),
...(v3Result.usage.inputTokens.cacheWrite !== undefined && {
cacheWriteTokens: v3Result.usage.inputTokens.cacheWrite,
}),
};
toolExecutions = loopResult?.toolExecutions ?? [];
conversationOut = loopResult?.conversation ?? initialConversation;
}
else {
this.logMultimodalMiddlewareSkip(options);
const loopResult = await runTurn(initialConversation, systemPromptText, options.maxTokens, sampling.temperature, options.abortSignal);
text = loopResult.text || "";
finishReason = loopResult.finishReason;
rawFinishReason = loopResult.rawStopReason;
usage = loopResult.usage;
toolExecutions = loopResult.toolExecutions;
conversationOut = loopResult.conversation;
}
this.conversationHistory = conversationOut;
const input = usage.inputTokens;
const output = usage.outputTokens;
const cacheRead = usage.cacheReadTokens ?? 0;
const cacheWrite = usage.cacheWriteTokens ?? 0;
return {
text: text || "",
usage: {
input,
output,
// Cache reads/writes are billed tokens reported separately from
// inputTokens — the total must include them.
total: input + cacheRead + cacheWrite + output,
...(cacheRead > 0 && { cacheReadTokens: cacheRead }),
...(cacheWrite > 0 && { cacheCreationTokens: cacheWrite }),
},
// The MAPPED reason, matching the streaming path and every AI-SDK
// backed provider. This path previously surfaced Bedrock's raw value,
// so a turn stopped at the step cap reported "tool_use" here while
// the same turn reported "tool-calls" when streamed. The raw value is
// kept alongside rather than dropped.
finishReason,
rawFinishReason,
toolExecutions,
};
}
catch (error) {
logger.error(`[AmazonBedrockProvider] Error in conversation loop:`, error);
throw this.handleProviderError(error);
}
}
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: "" };
}),
}));
}
/**
* `tools` is passed in rather than re-resolved from `getAllTools()`. That
* call returns only the provider's own registry, so resolving here meant a
* tool the caller passed to generate/stream could never execute — the
* streaming path advertised it to the model and then failed every call to
* it with "Tool not found", and the generate path never advertised it at
* all. The turn's full merged tool set is resolved once by the caller and
* handed down.
*/
async executeSingleTool(tools, 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,
});
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();
}
});
}
/**
* Resolve the turn's tools once: whatever the caller passed, else the
* provider's own registry. `BaseProvider.stream()` has already merged base
* tools into `options.tools` by the time it reaches the streaming path;
* the generate path has no such pre-merge, so it falls back here.
*/
async resolveTurnTools(optionTools, disableTools) {
// `generate()` reaches conversationLoop() without going through
// BaseProvider's tool preparation, so `options.tools` is undefined there
// and the getAllTools() fallback would hand the model the whole registry
// even when the caller asked for no tools at all.
if (disableTools) {
return {};
}
const aiTools = optionTools ??
(await this.getAllTools());
return this.convertAISDKToolsToToolDefinitions(aiTools);
}
/**
* Present the resolved tools in the shape `runAgenticLoop` dispatches
* through. Execution still goes through `executeSingleTool`, so the tool
* span, the parameter defaults and the ToolResult unwrapping are unchanged
* — only which tools are reachable changes.
*/
toEngineTools(tools) {
const engineTools = {};
for (const name of Object.keys(tools)) {
engineTools[name] = {
execute: (args) => this.executeSingleTool(tools, name, args),
};
}
return engineTools;
}
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. Gated on the
// real AWS SDK service-exception shape ($metadata) first: a
// middleware's wrapStream/transformParams can throw an arbitrary
// Error whose message happens to contain "streaming" or whose name
// happens to collide with an AWS exception name, and without this
// guard that error was silently swallowed and retried as if AWS
// itself had rejected the streaming call (see
// BedrockRuntimeServiceException in the AWS SDK, which carries
// $fault/$metadata on every genuine service exception).
const hasAwsServiceExceptionShape = typeof errorObj === "object" &&
errorObj !== null &&
"$metadata" in errorObj;
const isPermissionError = hasAwsServiceExceptionShape &&
(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;
}
}
finally {
reader.releaseLock();
}
},
};
return {
stream: asyncIterable,
// The generate() fallback already computed real usage — a
// hardcoded zero object here threw it away.
usage: generateResult.usage,
model: this.modelName || this.getDefaultModel(),
provider: this.getProviderName(),
metadata: {
fallback: true,
},
};
}
// Re-throw non-permission errors
streamSpan.setStatus({
code: SpanStatusCode.ERROR,
message: errorObj instanceof Error ? errorObj.message : String(errorObj),
});
streamSpan.recordException(errorObj instanceof Error ? errorObj : new Error(String(errorObj)));
streamSpan.end();
throw error;
}
});
}
/**
* A multimodal turn skips model middleware (see modelBridge.ts's header).
* WARN when middleware was configured, per request or on the provider,
* since it silently did not run; multimodal turns alone are unremarkable,
* so stay at debug otherwise.
*/
logMultimodalMiddlewareSkip(options) {
if (options.middleware ?? this.middlewareOptions) {
logger.warn("[AmazonBedrockProvider] multimodal turn — configured middleware was NOT applied (text-only turns only)");
return;
}
logger.debug("[AmazonBedrockProvider] multimodal turn — skipping model middleware wrapping (text-only turns only)");
}
/**
* `inferenceConfig` carries `maxTokens` and `temperature` only, so any other
* sampling option a middleware sets would be dropped without a trace.
* `tools` is reported where it is compared, at each call site.
*/
warnOnUnsentMiddlewareOptions(path, params) {
const unsent = unsentBedrockMiddlewareOptions(params);
if (unsent.length > 0) {
logger.warn(`[AmazonBedrockProvider] middleware set options the native Bedrock path does not send on the ${path} path; ignored: ${unsent.map((name) => `'${name}'`).join(", ")}`);
}
}
/**
* Runs the turn behind `streamingConversationLoop`: the model-middleware
* path for a text-only starting turn, or the direct fallback for a
* multimodal one. Extracted only to keep `streamingConversationLoop`
* readable — the branching logic itself is unchanged from what it
* replaced.
*/
async buildBedrockStreamTurn(params) {
const { initialConversation, systemPromptText, options, sampling, toolConfig, turnAbort, runTurn, raceFirstStep, } = params;
if (!isTextOnlyBedrockConversation(initialConversation)) {
this.logMultimodalMiddlewareSkip(options);
const turn = runTurn(initialConversation, systemPromptText, options.maxTokens, sampling.temperature, turnAbort.signal);
await raceFirstStep(turn.resultPromise);
return { stream: turn.stream, capturedLoopResult: turn.resultPromise };
}
// Model middleware (transformParams/wrapStream) applies only to a
// text-only starting turn — see modelBridge.ts's header comment.
const v3Tools = toolConfig?.tools
?.map((t) => t.toolSpec)
.filter((spec) => !!spec)
.map((spec) => ({
type: "function",
name: spec.name ?? "",
description: spec.description,
inputSchema: unwrapBedrockToolInputSchema(spec.inputSchema),
}));
// Set only once the loop actually starts — a guardrails-style wrapStream
// can answer from its own precall without ever calling `doStream`
// (correction 3), and the richer loop result (toolExecutions/
// conversation/mapped finishReason) is then unavailable. The caller
// tolerates `undefined`.
let capturedLoopResult;
const streamModel = {
specificationVersion: "v3",
provider: this.providerName,
modelId: this.modelName || this.getDefaultModel(),
supportedUrls: {},
// This model exists only to be driven through doStream.
doGenerate: () => {
throw new Error("AmazonBedrockProvider's stream model does not implement doGenerate");
},
doStream: async (params) => {
if (params.tools !== undefined && params.tools !== v3Tools) {
logger.warn("[AmazonBedrockProvider] middleware rewrote 'tools' on the stream path; the rewrite was ignored and the caller's original tool configuration was sent");
}
this.warnOnUnsentMiddlewareOptions("stream", params);
// Read AFTER transformParams: by AI SDK contract, `params` here is
// already the transformed version, so a prompt rewrite reaches the
// wire (correction 1).
const { system, conversation } = bedrockConversationFromV3Prompt(params.prompt);
const turn = runTurn(conversation, system, params.maxOutputTokens ?? options.maxTokens, params.temperature ?? sampling.temperature,
// Composed, not `??`: turnAbort.signal already carries the
// caller's abort, BaseProvider's teardown signal, and the stream
// consumer's own cancel() (wired below) — a middleware's
// transformParams-set abortSignal must add to that, never replace
// it outright.
composeAbortSignals(turnAbort.signal, params.abortSignal));
capturedLoopResult = turn.resultPromise;
await raceFirstStep(turn.resultPromise);
const finish = turn.resultPromise.then((result) => ({
type: "finish",
finishReason: {
unified: result.finishReason,
raw: result.rawStopReason,
},
usage: {
inputTokens: {
// Same fresh-vs-total split as the generate path above —
// `result.usage.inputTokens` excludes cache reads/writes.
total: result.usage.inputTokens +
(result.usage.cacheReadTokens ?? 0) +
(result.usage.cacheWriteTokens ?? 0),
noCache: result.usage.inputTokens,
cacheRead: result.usage.cacheReadTokens,
cacheWrite: result.usage.cacheWriteTokens,
},
outputTokens: { total: result.usage.outputTokens },
},
}));
// The turn can reject before anything reads this terminal event.
void finish.catch(() => undefined);
return {
stream: bedrockChunksToV3Stream(turn.stream, finish, () => turnAbort.abort()),
};
},
};
const wrappedModel = await this.applyMiddlewareToModel(streamModel, options);
if (typeof wrappedModel === "string") {
throw new Error("AmazonBedrockProvider: middleware returned a model id string, not a native model handle");
}
const v3StreamResult = await wrappedModel.doStream({
prompt: buildV3PromptFromBedrock(systemPromptText, initialConversation),
maxOutputTokens: options.maxTokens,
temperature: sampling.temperature,
...(v3Tools ? { tools: v3Tools } : {}),
abortSignal: turnAbort.signal,
});
return {
stream: bedrockV3StreamToChunks(v3StreamResult.stream),
capturedLoopResult,
};
}
/**
* Turns a settled loop outcome's usage into the shape both the analytics
* resolution and the stream's own `finally` block report. Shared so the
* two call sites in `streamingConversationLoop` can't drift.
*/
summarizeBedrockStreamUsage(usage) {
const input = usage?.inputTokens ?? 0;
const output = usage?.outputTokens ?? 0;
const cacheRead = usage?.cacheReadTokens ?? 0;
const cacheWrite = usage?.cacheWriteTokens ?? 0;
return {
input,
output,
// Cache reads/writes are billed tokens reported separately from
// inputTokens — the total must include them.
total: input + cacheRead + cacheWrite + output,
...(cacheRead > 0 && { cacheReadTokens: cacheRead }),
...(cacheWrite > 0 && { cacheCreationTokens: cacheWrite }),
};
}
async streamingConversationLoop(options, streamSpan) {
const startTime = Date.now();
const maxSteps = options.maxSteps || DEFAULT_MAX_STEPS;
// Resolved once for the whole turn. Bedrock has no mid-turn tool
// discovery, so nothing here would need re-resolving per step.
const tools = await this.resolveTurnTools(options.tools, options.disableTools);
const toolConfig = this.formatToolsForBedrock(tools);
const sampling = resolveSamplingParams(this.providerName, this.modelName || this.getDefaultModel(), { temperature: options.temperature ?? 0.7 }, "bedrock.converseStream");
const systemPromptText = options.systemPrompt ||
"You are a helpful assistant with access to external tools. Use tools when necessary to provide accurate information.";
// executeStream() falls back to non-streaming generate() when the first
// call fails on permissions, and that only works if the failure reaches it
// synchronously. The engine runs the turn in the background, so the first
// successful send reports separately.
//
// Success is the ONLY thing signalled here. The engine wraps every
// executeStep in withProviderRetry, so rejecting on a failed attempt
// would settle this promise on attempt one and never un-settle it: a
// retryable 429 would be reported as a dead turn even though the engine's
// own retry went on to succeed, and this method would throw while that
// retried — and billed — turn kept running in the background with its
// output discarded, with the fallback generate() billed on top. A
// terminal failure is taken from the turn's settled outcome instead,
// which by definition is reached only after the engine has stopped
// retrying.
let firstStepSucceeded = false;
let firstStepSent;
const firstStep = new Promise((resolve) => {
firstStepSent = () => {
firstStepSucceeded = true;
resolve();
};
});
// Shared by both branches below: whichever turn runs awaits this right
// after starting, so executeStream's permission fallback still sees a
// synchronous first-call failure.
const raceFirstStep = async (resultPromise) => {
const settledHere = resultPromise.then((result) => ({ result, error: undefined }), (error) => ({ result: undefined, error }));
// A turn that ends without ever completing a step — an abort before
// the first request, or a failure the engine gave up retrying — would
// leave `firstStep` pending forever, so the settled outcome releases
// the wait too.
await Promise.race([firstStep, settledHere]);
if (!firstStepSucceeded) {
const outcome = await settledHere;
if (outcome.error) {
throw outcome.error;
}
}
};
const runTurn = (conversation, system, maxTokens, temperature, signal) => {
const baseAdapter = createBedrockLoopAdapter({
client: this.bedrockClient,
streaming: true,
region: this.region,
maxSteps,
buildCommandInput: (conv) => ({
modelId: this.modelName || this.getDefaultModel(),
messages: this.convertToAWSMessages(conv),
// Converse rejects a blank block; transformParams may empty this.
...(system ? { system: [{ text: system }] } : {}),
inferenceConfig: {
maxTokens,
...(temperature !== undefined && { temperature }),
},
...(toolConfig ? { toolConfig } : {}),
}),
});
const adapter = {
...baseAdapter,
executeStep: async (request, channel, signal) => {
const stepResult = await baseAdapter.executeStep(request, channel, signal);
firstStepSent();
return stepResult;
},
};
streamSpan.addEvent("stream.api_call", {
"bedrock.tool_count": toolConfig?.tools?.length ?? 0,
});
return runAgenticLoop(adapter, conversation, {
tools: this.toEngineTools(tools),
abortSignal: signal,
// `toEngineTools` adds no deadline of its own, so this is the only
// per-tool bound on the turn. The engine defaults when it is absent.
...(options.toolTimeoutMs !== undefined
? { toolTimeoutMs: options.toolTimeoutMs }
: {}),
});
};
const initialConversation = this.conversationHistory;
// Turn-scoped abort: a V3 stream's `cancel()` (consumer broke out early)
// must tear this turn's request down even when the caller passed no
// signal. Chained so a caller abort still cascades.
const turnAbort = new AbortController();
if (options.abortSignal) {
if (options.abortSignal.aborted) {
turnAbort.abort();
}
else {
options.abortSignal.addEventListener("abort", () => turnAbort.abort(), {
once: true,
});
}
}
// The request is already sent when this can throw; abort it so it is not
// orphaned once the caller (e.g. the permission fallback) moves on.
const { stream, capturedLoopResult } = await this.buildBedrockStreamTurn({
initialConversation,
systemPromptText,
options,
sampling,
toolConfig,
turnAbort,
runTurn,
raceFirstStep,
}).catch((error) => {
turnAbort.abort();
throw error;
});
// The stream surfaces the same failure, so this settled view exists only
// so the turn's outcome can be read without a second unhandled rejection.
// `capturedLoopResult` is undefined only when middleware blocked the turn
// before `doStream` ran (correction 3) — every reader below tolerates it.
const settled = (capturedLoopResult ?? Promise.resolve(undefined)).then((result) => ({ result, error: undefined }), (error) => ({ result: undefined, error }));
const streamEmitter = this.neurolink?.getEventEmitter();
const self = this;
const metadata = {
startTime,
streamId: `bedrock-${Date.now()}`,
};
let resolveAnalytics;
const analyticsPromise = new Promise((resolve) => {
resolveAnalytics = resolve;
});
// Bind analytics to the turn ending, not to the consumer draining the
// stream. A caller that awaits `result.analytics` without iterating
// `result.stream` would otherwise wait forever, because the generator
// body — and its finally block — never runs. Resolving twice is
// harmless; the first call wins.
void settled.then((outcome) => {
resolveAnalytics(createAnalytics(this.providerName, this.modelName || this.getDefaultModel(), {
usage: this.summarizeBedrockStreamUsage(outcome.result?.usage),
// createAnalytics derives toolCallCount from these. Passing usage
// alone left every streamed Bedrock turn reporting no tool calls,
// however many it made.
toolExecutions: outcome.result?.toolExecutions ?? [],
// Same "ran, not asked for" rule as the generate path above.
// `toolCallCount` still derives from the unfiltered
// `toolExecutions`, which is correct — a dispatch that failed was
// still a call — so this list is a fallback today rather than an
// observable surface. It is filtered anyway so that the moment
// anything does surface it, it is not surfacing the same lie.
toolsUsed: (outcome.result?.toolExecutions ?? [])
.filter((execution) => execution.error === undefined)
.map((execution) => execution.name),
}, Date.now() - startTime, {
requestId: `bedrock-stream-${Date.now()}`,
streamingMode: true,
}));
});
const wrappedStreamIterable = {
async *[Symbol.asyncIterator]() {
let streamErrored = false;
try {
yield* stream;
}
catch (error) {
streamErrored = true;
throw error;
}
finally {
const outcome = await settled;
if (outcome.error) {
streamErrored = true;
}
const aggregatedUsage = self.summarizeBedrockStreamUsage(outcome.result?.usage);
if (outcome.result) {
self.conversationHistory = outcome.result.conversation;
metadata.finishReason = outcome.result.finishReason;
metadata.rawFinishReason = outcome.result.rawStopReason;
streamSpan.setAttribute("gen_ai.response.stop_reason", outcome.result.rawStopReason ?? "unknown");
}
// Analytics is resolved off `settled` above, so it lands whether or
// not anyone drains the stream.
// Bedrock bypasses the Vercel AI SDK, so experimental_telemetry is
// never injected and generation:end is emitted by hand for
// Pipeline B (Langfuse).
if (streamEmitter) {
// #1741: tell the orchestration's finally block this stream's
// `generation:end` has already been emitted here, exactly as the
// other native stream paths do. The context object is attached to
// the caller's options by `NeuroLink.stream()`; BaseProvider
// spreads the options but carries the same context reference, so
// mutating it is what the orchestration reads back.
const dedupContext = options._streamDedupContext;
if (dedupContext) {
dedupContext.providerEmitted = true;
}
streamEmitter.emit("generation:end", {
provider: self.providerName,
responseTime: Date.now() - startTime,
timestamp: Date.now(),
result: {
content: "",
usage: aggregatedUsage,
model: self.modelName || self.getDefaultModel(),
provider: self.providerName,
finishReason: streamErrored
? "error"
: outcome.result?.rawStopReason,
},
success: !streamErrored,
});
}
}
},
};
return {
stream: wrappedStreamIterable,
// No usage key here on purpose: the real aggregate resolves through
// `analytics` after the stream drains. A literal zero object is truthy
// and would block every downstream usage fallback.
model: this.modelName || this.getDefaultModel(),
provider: this.getProviderName(),
analytics: analyticsPromise,
metadata,
};
}
/**
* Health check for Amazon Bedrock service
* Uses ListFoundationModels API to validate connectivity and permissions
*/
async checkBedrockHealth() {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
// Create a separate BedrockClient for health checks (not BedrockRuntimeClient)
// Use simple configuration like working example - no custom proxy handler
const { BedrockClient, ListFoundationModelsCommand } = await loadBedrockControl();
const healthCheckClient = new BedrockClient({
region: process.env.AWS_REGION || "us-east-1",
});
try {
logger.debug("[AmazonBedrockProvider] Starting health check...");
const command = new ListFoundationModelsCommand({});
const response = await healthCheckClient.send(command, {
abortSignal: controller.signal,
});
const models = response.modelSummaries || [];
const activeModels = models.filter((model) => model.modelLifecycle?.status === "ACTIVE");
logger.debug(`[AmazonBedrockProvider] Health check passed - Found ${activeModels.length} active models out of ${models.length} total models`);
if (activeModels.length === 0) {
throw new Error("No active foundation models available in the region");
}
}
catch (error) {
clearTimeout(timeoutId);
const errorObj = error;
if (isAbortError(error)) {
throw new Error("Bedrock health check timed out after 10 seconds", {
cause: error,
});
}
const errorMessage = typeof errorObj.message === "string" ? errorObj.message : "";
if (errorMessage.includes("UnauthorizedOperation") ||
errorMessage.includes("AccessDenied")) {
throw new Error("Bedrock access denied. Check your AWS credentials and IAM permissions for bedrock:ListFoundationModels", { cause: error });
}
if (errorObj.code === "ECONNREFUSED" || errorObj.code === "ENOTFOUND") {
throw new Error("Unable to connect to Bedrock service. Check your network connectivity and AWS region configuration", { cause: error });
}
logger.error("[AmazonBedrockProvider] Health check failed:", error);
throw new Error(`Bedrock health check failed: ${errorMessage || "Unknown error"}`, { cause: error });
}
finally {
clearTimeout(timeoutId);
try {
healthCheckClient.destroy();
}
catch {
// Ignore destroy errors during cleanup
}
}
}
formatProviderError(error) {
const rules = [
{
match: (ctx) => /AccessDeniedException/i.test(ctx.message),
errorClass: AuthenticationError,
message: "AWS Bedrock access denied. Check your credentials and permissions.",
},
{
// Checked before the generic ValidationException fallback — a
// throttling error can be shaped like a validation error in some
// AWS SDK error message templates, but .name/.code are authoritative.
match: (ctx) => ctx.errorName === "ThrottlingException" ||
ctx.errorCode === "ThrottlingException",
errorClass: RateLimitError,
message: (ctx) => `Bedrock rate limit (throttled): ${ctx.message}`,
},
{
match: (ctx) => /ValidationException/i.test(ctx.message),
errorClass: ProviderError,
message: (ctx) => `Validation error: ${ctx.message}`,
},
{
match: () => true,
errorClass: ProviderError,
message: (ctx) => `AWS Bedrock error: ${ctx.message}`,
},
];
// this.providerName resolves to the literal "bedrock" (getProviderName()
// returns it directly, and the constructor only overrides providerName
// when an explicit providerName is passed in) — verified matches the
// pre-migration hardcoded "bedrock" literal in the throttling branch, so
// a single classifyProviderError call using this.providerName for every
// rule (rather than a per-rule literal) is not a behavior change.
return classifyProviderError(error, rules, this.providerName, this.modelName);
}
/**
* Generate embeddings for text or multi-modal input using Amazon Bedrock.
* Supports both text-only models (Titan Embed Text) and multi-modal models
* (Titan Embed Image, Nova Multimodal Embeddings).
*
* @param input - Text string or EmbedInput with text/image/mimeType
* @param modelName - The embedding model to use (default: amazon.titan-embed-text-v2:0)
* @returns Promise resolving to the embedding vector
*/
async embed(input, modelName) {
// Normalize input to EmbedInput shape
const embedInput = typeof input === "string" ? { text: input } : input;
const embeddingModelName = modelName || this.getDefaultEmbeddingModel();
const isNovaModel = embeddingModelName.includes("nova") &&
embeddingModelName.includes("multimodal");
const isMultiModalModel = embeddingModelName.includes("image") ||
embeddingModelName.includes("multimodal") ||
embeddingModelName === "amazon.titan-embed-image-v1" ||
isNovaModel;
logger.debug("Generating embedding", {
provider: this.providerName,
model: embeddingModelName,
hasText: typeof embedInput.text === "string",
hasImage: embedInput.image !== undefined,
isMultiModalModel,
});
try {
// Build request body based on model type
let requestBody;
if (embedInput.image && !isMultiModalModel) {
throw new ProviderError(`${this.providerName} model ${embeddingModelName} does not support image embeddings`, this.providerName);
}
if (isNovaModel) {
// Nova Multimodal Embeddings uses SINGLE_EMBEDDING + singleEmbeddingParams
// for BOTH text-only and image requests. Nova requires exactly one
// modality per request — combined image+text is rejected explicitly
// rather than silently dropping one of them.
if (embedInput.image && embedInput.text) {
throw new ProviderError(`${this.providerName} model ${embeddingModelName} does not support combined image+text embeddings; provide exactly one of image or text`, this.providerName);
}
const singleEmbeddingParams = {
embeddingPurpose: "GENERIC_RETRIEVAL",
};
if (embedInput.image) {
const imageBuffer = Buffer.isBuffer(embedInput.image)
? embedInput.image
: Buffer.from(embedInput.image, "base64");
const imageFormat = novaImageFormat(embedInput.mimeType);
if (imageFormat === undefined) {
throw new ProviderError(`${this.providerName} model ${embeddingModelName} does not support image format ` +
`'${embedInput.mimeType ?? "unknown"}'; supported formats are ` +
`${Object.keys(NOVA_IMAGE_FORMATS).join(", ")}`, this.providerName);
}
singleEmbeddingParams.image = {
detailLevel: "STANDARD_IMAGE",
format: imageFormat,
source: {
// Bounded on purpose. `Buffer.toString("base64")` allocates ~4/3
// the buffer in one go, so an oversized image exhausts memory
// before the request is ever built. The chat-image path already
// routes through this guard (messageBuilder.ts); the embed paths
// never adopted it, even though ImageLoader enforces the same
// ceiling for images it loads itself.
bytes: ImageProcessor.safeBase64Convert(imageBuffer, "bedrock nova embed image"),
},
};
}
if (embedInput.text) {
singleEmbeddingParams.text = {
value: embedInput.text,
truncationMode: "NONE",
};
}
requestBody = JSON.stringify({
taskType: "SINGLE_EMBEDDING",
singleEmbeddingParams,
});
}
else if (embedInput.image) {
// Titan Multimodal Embeddings expects inputImage as a base64 string
const imageBuffer = Buffer.isBuffer(embedInput.image)
? embedInput.image
: Buffer.from(embedInput.image, "base64");
requestBody = JSON.stringify({
inputText: embedInput.text ?? "",
// Same ceiling as the Nova branch above.
inputImage: ImageProcessor.safeBase64Convert(imageBuffer, "bedrock titan embed image"),
});
}
else {
// Text-only embedding (Titan Embed Text)
requestBody = JSON.stringify({
inputText: embedInput.text ?? "",
});
}
const command = new InvokeModelCommand({
modelId: embeddingModelName,
contentType: "application/json",
accept: "application/json",
body: requestBody,
});
const response = await withTimeout(this.bedrockClient.send(command), 60_000, new Error("Bedrock embedding API call timed out"));
// Parse the response — Titan returns `embedding`, Nova returns `embeddings[0].embedding`
const responseBody = JSON.parse(new TextDecoder().decode(response.body));
const embedding = isNovaModel
? responseBody.embeddings?.at(0)?.embedding
: responseBody.embedding;
if (!Array.isArray(embedding)) {
throw new Error("Invalid embedding response from Bedrock");
}
logger.debug("Embedding generated successfully", {
provider: this.providerName,
model: embeddingModelName,
embeddingDimension: embedding.length,
});
return embedding;
}
catch (error) {
logger.error("Embedding generation failed", {
error: error instanceof Error ? error.message : String(error),
model: embeddingModelName,
});
throw this.handleProviderError(error);
}
}
/**
* Generate embeddings for multiple texts in a single batch
* @param texts - The texts to embed
* @param modelName - The embedding model to use (default: amazon.titan-embed-text-v2:0)
* @returns Promise resolving to an array of embedding vectors
*/
async embedMany(texts, modelName) {
const embeddingModelName = modelName || this.getDefaultEmbeddingModel();
logger.debug("Generating batch embeddings", {
provider: this.providerName,
model: embeddingModelName,
count: texts.length,
});
try {
const embeddings = await Promise.all(texts.map((text) => this.embed(text, embeddingModelName)));
logger.debug("Batch embeddings generated successfully", {
provider: this.providerName,
model: embeddingModelName,
count: embeddings.length,
embeddingDimension: embeddings[0]?.length,
});
return embeddings;
}
catch (error) {
logger.error("Batch embedding generation failed", {
error: error instanceof Error ? error.message : String(error),
model: embeddingModelName,
count: texts.length,
});
throw this.handleProviderError(error);
}
}
}