mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-19 00:01:19 +00:00
1604 lines
55 KiB
TypeScript
1604 lines
55 KiB
TypeScript
import {
|
||
BedrockRuntimeClient,
|
||
ConverseStreamCommand,
|
||
ConverseCommand,
|
||
BedrockRuntimeClientConfig,
|
||
ContentBlock,
|
||
Message,
|
||
SystemContentBlock,
|
||
Tool,
|
||
ToolConfiguration,
|
||
ToolChoice,
|
||
} from "@aws-sdk/client-bedrock-runtime"
|
||
import OpenAI from "openai"
|
||
import { fromIni } from "@aws-sdk/credential-providers"
|
||
import { Anthropic } from "@anthropic-ai/sdk"
|
||
|
||
import {
|
||
type ModelInfo,
|
||
type ProviderSettings,
|
||
type BedrockModelId,
|
||
type BedrockServiceTier,
|
||
bedrockDefaultModelId,
|
||
bedrockModels,
|
||
bedrockDefaultPromptRouterModelId,
|
||
BEDROCK_DEFAULT_TEMPERATURE,
|
||
BEDROCK_MAX_TOKENS,
|
||
BEDROCK_DEFAULT_CONTEXT,
|
||
AWS_INFERENCE_PROFILE_MAPPING,
|
||
BEDROCK_1M_CONTEXT_MODEL_IDS,
|
||
BEDROCK_GLOBAL_INFERENCE_MODEL_IDS,
|
||
BEDROCK_SERVICE_TIER_MODEL_IDS,
|
||
BEDROCK_SERVICE_TIER_PRICING,
|
||
ApiProviderError,
|
||
} from "@roo-code/types"
|
||
import { TelemetryService } from "@roo-code/telemetry"
|
||
|
||
import { ApiStream } from "../transform/stream"
|
||
import { BaseProvider } from "./base-provider"
|
||
import { logger } from "../../utils/logging"
|
||
import { Package } from "../../shared/package"
|
||
import { MultiPointStrategy } from "../transform/cache-strategy/multi-point-strategy"
|
||
import { ModelInfo as CacheModelInfo } from "../transform/cache-strategy/types"
|
||
import { convertToBedrockConverseMessages as sharedConverter } from "../transform/bedrock-converse-format"
|
||
import { getModelParams } from "../transform/model-params"
|
||
import { shouldUseReasoningBudget } from "../../shared/api"
|
||
import { normalizeToolSchema } from "../../utils/json-schema"
|
||
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
|
||
|
||
/************************************************************************************
|
||
*
|
||
* TYPES
|
||
*
|
||
*************************************************************************************/
|
||
|
||
// Define interface for Bedrock inference config
|
||
interface BedrockInferenceConfig {
|
||
maxTokens: number
|
||
temperature?: number
|
||
}
|
||
|
||
// Define interface for Bedrock additional model request fields
|
||
// This includes thinking configuration, 1M context beta, and other model-specific parameters
|
||
interface BedrockAdditionalModelFields {
|
||
thinking?: {
|
||
type: "enabled"
|
||
budget_tokens: number
|
||
}
|
||
anthropic_beta?: string[]
|
||
[key: string]: any // Add index signature to be compatible with DocumentType
|
||
}
|
||
|
||
// Define interface for Bedrock payload
|
||
interface BedrockPayload {
|
||
modelId: BedrockModelId | string
|
||
messages: Message[]
|
||
system?: SystemContentBlock[]
|
||
inferenceConfig: BedrockInferenceConfig
|
||
anthropic_version?: string
|
||
additionalModelRequestFields?: BedrockAdditionalModelFields
|
||
toolConfig?: ToolConfiguration
|
||
}
|
||
|
||
// Extended payload type that includes service_tier as a top-level parameter
|
||
// AWS Bedrock service tiers (STANDARD, FLEX, PRIORITY) are specified at the top level
|
||
// https://docs.aws.amazon.com/bedrock/latest/userguide/service-tiers-inference.html
|
||
type BedrockPayloadWithServiceTier = BedrockPayload & {
|
||
service_tier?: BedrockServiceTier
|
||
}
|
||
|
||
// Define specific types for content block events to avoid 'as any' usage
|
||
// These handle the multiple possible structures returned by AWS SDK
|
||
interface ContentBlockStartEvent {
|
||
start?: {
|
||
text?: string
|
||
thinking?: string
|
||
toolUse?: {
|
||
toolUseId?: string
|
||
name?: string
|
||
}
|
||
}
|
||
contentBlockIndex?: number
|
||
// Alternative structure used by some AWS SDK versions
|
||
content_block?: {
|
||
type?: string
|
||
thinking?: string
|
||
}
|
||
// Official AWS SDK structure for reasoning (as documented)
|
||
contentBlock?: {
|
||
type?: string
|
||
thinking?: string
|
||
reasoningContent?: {
|
||
text?: string
|
||
}
|
||
// Tool use block start
|
||
toolUse?: {
|
||
toolUseId?: string
|
||
name?: string
|
||
}
|
||
}
|
||
}
|
||
|
||
interface ContentBlockDeltaEvent {
|
||
delta?: {
|
||
text?: string
|
||
thinking?: string
|
||
type?: string
|
||
// AWS SDK structure for reasoning content deltas
|
||
reasoningContent?: {
|
||
text?: string
|
||
}
|
||
// Tool use input delta
|
||
toolUse?: {
|
||
input?: string
|
||
}
|
||
}
|
||
contentBlockIndex?: number
|
||
}
|
||
|
||
// Define types for stream events based on AWS SDK
|
||
export interface StreamEvent {
|
||
messageStart?: {
|
||
role?: string
|
||
}
|
||
messageStop?: {
|
||
stopReason?: "end_turn" | "tool_use" | "max_tokens" | "stop_sequence"
|
||
additionalModelResponseFields?: Record<string, unknown>
|
||
}
|
||
contentBlockStart?: ContentBlockStartEvent
|
||
contentBlockDelta?: ContentBlockDeltaEvent
|
||
metadata?: {
|
||
usage?: {
|
||
inputTokens: number
|
||
outputTokens: number
|
||
totalTokens?: number // Made optional since we don't use it
|
||
// New cache-related fields
|
||
cacheReadInputTokens?: number
|
||
cacheWriteInputTokens?: number
|
||
cacheReadInputTokenCount?: number
|
||
cacheWriteInputTokenCount?: number
|
||
}
|
||
metrics?: {
|
||
latencyMs: number
|
||
}
|
||
}
|
||
// New trace field for prompt router
|
||
trace?: {
|
||
promptRouter?: {
|
||
invokedModelId?: string
|
||
usage?: {
|
||
inputTokens: number
|
||
outputTokens: number
|
||
totalTokens?: number // Made optional since we don't use it
|
||
// New cache-related fields
|
||
cacheReadTokens?: number
|
||
cacheWriteTokens?: number
|
||
cacheReadInputTokenCount?: number
|
||
cacheWriteInputTokenCount?: number
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Type for usage information in stream events
|
||
export type UsageType = {
|
||
inputTokens?: number
|
||
outputTokens?: number
|
||
cacheReadInputTokens?: number
|
||
cacheWriteInputTokens?: number
|
||
cacheReadInputTokenCount?: number
|
||
cacheWriteInputTokenCount?: number
|
||
}
|
||
|
||
/************************************************************************************
|
||
*
|
||
* PROVIDER
|
||
*
|
||
*************************************************************************************/
|
||
|
||
export class AwsBedrockHandler extends BaseProvider implements SingleCompletionHandler {
|
||
protected options: ProviderSettings
|
||
private client: BedrockRuntimeClient
|
||
private arnInfo: any
|
||
private readonly providerName = "Bedrock"
|
||
|
||
constructor(options: ProviderSettings) {
|
||
super()
|
||
this.options = options
|
||
let region = this.options.awsRegion
|
||
|
||
// process the various user input options, be opinionated about the intent of the options
|
||
// and determine the model to use during inference and for cost calculations
|
||
// There are variations on ARN strings that can be entered making the conditional logic
|
||
// more involved than the non-ARN branch of logic
|
||
if (this.options.awsCustomArn) {
|
||
this.arnInfo = this.parseArn(this.options.awsCustomArn, region)
|
||
|
||
if (!this.arnInfo.isValid) {
|
||
logger.error("Invalid ARN format", {
|
||
ctx: "bedrock",
|
||
errorMessage: this.arnInfo.errorMessage,
|
||
})
|
||
|
||
// Throw a consistent error with a prefix that can be detected by callers
|
||
const errorMessage =
|
||
this.arnInfo.errorMessage ||
|
||
"Invalid ARN format. ARN should follow the pattern: arn:aws:bedrock:region:account-id:resource-type/resource-name"
|
||
throw new Error("INVALID_ARN_FORMAT:" + errorMessage)
|
||
}
|
||
|
||
if (this.arnInfo.region && this.arnInfo.region !== this.options.awsRegion) {
|
||
// Log if there's a region mismatch between the ARN and the region selected by the user
|
||
// We will use the ARNs region, so execution can continue, but log an info statement.
|
||
// Log a warning if there's a region mismatch between the ARN and the region selected by the user
|
||
// We will use the ARNs region, so execution can continue, but log an info statement.
|
||
logger.info(this.arnInfo.errorMessage, {
|
||
ctx: "bedrock",
|
||
selectedRegion: this.options.awsRegion,
|
||
arnRegion: this.arnInfo.region,
|
||
})
|
||
|
||
this.options.awsRegion = this.arnInfo.region
|
||
}
|
||
|
||
this.options.apiModelId = this.arnInfo.modelId
|
||
if (this.arnInfo.awsUseCrossRegionInference) this.options.awsUseCrossRegionInference = true
|
||
}
|
||
|
||
if (!this.options.modelTemperature) {
|
||
this.options.modelTemperature = BEDROCK_DEFAULT_TEMPERATURE
|
||
}
|
||
|
||
this.costModelConfig = this.getModel()
|
||
|
||
const clientConfig: BedrockRuntimeClientConfig = {
|
||
userAgentAppId: `RooCode#${Package.version}`,
|
||
region: this.options.awsRegion,
|
||
// Add the endpoint configuration when specified and enabled
|
||
...(this.options.awsBedrockEndpoint &&
|
||
this.options.awsBedrockEndpointEnabled && { endpoint: this.options.awsBedrockEndpoint }),
|
||
}
|
||
|
||
if (this.options.awsUseApiKey && this.options.awsApiKey) {
|
||
// Use API key/token-based authentication if enabled and API key is set
|
||
clientConfig.token = { token: this.options.awsApiKey }
|
||
clientConfig.authSchemePreference = ["httpBearerAuth"] // Otherwise there's no end of credential problems.
|
||
clientConfig.requestHandler = {
|
||
// This should be the default anyway, but without setting something
|
||
// this provider fails to work with LiteLLM passthrough.
|
||
requestTimeout: 0,
|
||
}
|
||
} else if (this.options.awsUseProfile && this.options.awsProfile) {
|
||
// Use profile-based credentials if enabled and profile is set
|
||
clientConfig.credentials = fromIni({
|
||
profile: this.options.awsProfile,
|
||
ignoreCache: true,
|
||
})
|
||
} else if (this.options.awsAccessKey && this.options.awsSecretKey) {
|
||
// Use direct credentials if provided
|
||
clientConfig.credentials = {
|
||
accessKeyId: this.options.awsAccessKey,
|
||
secretAccessKey: this.options.awsSecretKey,
|
||
...(this.options.awsSessionToken ? { sessionToken: this.options.awsSessionToken } : {}),
|
||
}
|
||
}
|
||
|
||
this.client = new BedrockRuntimeClient(clientConfig)
|
||
}
|
||
|
||
// Helper to guess model info from custom modelId string if not in bedrockModels
|
||
private guessModelInfoFromId(modelId: string): Partial<ModelInfo> {
|
||
// Define a mapping for model ID patterns and their configurations
|
||
const modelConfigMap: Record<string, Partial<ModelInfo>> = {
|
||
"claude-4": {
|
||
maxTokens: 8192,
|
||
contextWindow: 200_000,
|
||
supportsImages: true,
|
||
supportsPromptCache: true,
|
||
cachableFields: ["system", "messages", "tools"],
|
||
},
|
||
"claude-3-7": {
|
||
maxTokens: 8192,
|
||
contextWindow: 200_000,
|
||
supportsImages: true,
|
||
supportsPromptCache: true,
|
||
cachableFields: ["system", "messages", "tools"],
|
||
},
|
||
"claude-3-5": {
|
||
maxTokens: 8192,
|
||
contextWindow: 200_000,
|
||
supportsImages: true,
|
||
supportsPromptCache: true,
|
||
cachableFields: ["system", "messages", "tools"],
|
||
},
|
||
"claude-4-opus": {
|
||
maxTokens: 4096,
|
||
contextWindow: 200_000,
|
||
supportsImages: true,
|
||
supportsPromptCache: true,
|
||
cachableFields: ["system", "messages", "tools"],
|
||
},
|
||
"claude-3-opus": {
|
||
maxTokens: 4096,
|
||
contextWindow: 200_000,
|
||
supportsImages: true,
|
||
supportsPromptCache: true,
|
||
cachableFields: ["system", "messages", "tools"],
|
||
},
|
||
"claude-3-haiku": {
|
||
maxTokens: 4096,
|
||
contextWindow: 200_000,
|
||
supportsImages: true,
|
||
supportsPromptCache: true,
|
||
cachableFields: ["system", "messages", "tools"],
|
||
},
|
||
}
|
||
|
||
// Match the model ID to a configuration
|
||
const id = modelId.toLowerCase()
|
||
for (const [pattern, config] of Object.entries(modelConfigMap)) {
|
||
if (id.includes(pattern)) {
|
||
return config
|
||
}
|
||
}
|
||
|
||
// Default fallback
|
||
return {
|
||
maxTokens: BEDROCK_MAX_TOKENS,
|
||
contextWindow: BEDROCK_DEFAULT_CONTEXT,
|
||
supportsImages: false,
|
||
supportsPromptCache: false,
|
||
}
|
||
}
|
||
|
||
override async *createMessage(
|
||
systemPrompt: string,
|
||
messages: Anthropic.Messages.MessageParam[],
|
||
metadata?: ApiHandlerCreateMessageMetadata & {
|
||
thinking?: {
|
||
enabled: boolean
|
||
maxTokens?: number
|
||
maxThinkingTokens?: number
|
||
}
|
||
},
|
||
): ApiStream {
|
||
const modelConfig = this.getModel()
|
||
const usePromptCache = Boolean(
|
||
(this.options.awsUsePromptCache ?? true) && this.supportsAwsPromptCache(modelConfig),
|
||
)
|
||
|
||
const conversationId =
|
||
messages.length > 0
|
||
? `conv_${messages[0].role}_${
|
||
typeof messages[0].content === "string"
|
||
? messages[0].content.substring(0, 20)
|
||
: "complex_content"
|
||
}`
|
||
: "default_conversation"
|
||
|
||
const formatted = this.convertToBedrockConverseMessages(
|
||
messages,
|
||
systemPrompt,
|
||
usePromptCache,
|
||
modelConfig.info,
|
||
conversationId,
|
||
)
|
||
|
||
let additionalModelRequestFields: BedrockAdditionalModelFields | undefined
|
||
let thinkingEnabled = false
|
||
|
||
// Determine if thinking should be enabled
|
||
// metadata?.thinking?.enabled: Explicitly enabled through API metadata (direct request)
|
||
// shouldUseReasoningBudget(): Enabled through user settings (enableReasoningEffort = true)
|
||
const isThinkingExplicitlyEnabled = metadata?.thinking?.enabled
|
||
const isThinkingEnabledBySettings =
|
||
shouldUseReasoningBudget({ model: modelConfig.info, settings: this.options }) &&
|
||
modelConfig.reasoning &&
|
||
modelConfig.reasoningBudget
|
||
|
||
if ((isThinkingExplicitlyEnabled || isThinkingEnabledBySettings) && modelConfig.info.supportsReasoningBudget) {
|
||
thinkingEnabled = true
|
||
additionalModelRequestFields = {
|
||
thinking: {
|
||
type: "enabled",
|
||
budget_tokens: metadata?.thinking?.maxThinkingTokens || modelConfig.reasoningBudget || 4096,
|
||
},
|
||
}
|
||
logger.info("Extended thinking enabled for Bedrock request", {
|
||
ctx: "bedrock",
|
||
modelId: modelConfig.id,
|
||
thinking: additionalModelRequestFields.thinking,
|
||
})
|
||
}
|
||
|
||
const inferenceConfig: BedrockInferenceConfig = {
|
||
maxTokens: modelConfig.maxTokens || (modelConfig.info.maxTokens as number),
|
||
temperature: modelConfig.temperature ?? (this.options.modelTemperature as number),
|
||
}
|
||
|
||
// Check if 1M context is enabled for supported Claude 4 models
|
||
// Use parseBaseModelId to handle cross-region inference prefixes
|
||
const baseModelId = this.parseBaseModelId(modelConfig.id)
|
||
const is1MContextEnabled =
|
||
BEDROCK_1M_CONTEXT_MODEL_IDS.includes(baseModelId as any) && this.options.awsBedrock1MContext
|
||
|
||
// Determine if service tier should be applied (checked later when building payload)
|
||
const useServiceTier =
|
||
this.options.awsBedrockServiceTier && BEDROCK_SERVICE_TIER_MODEL_IDS.includes(baseModelId as any)
|
||
if (useServiceTier) {
|
||
logger.info("Service tier specified for Bedrock request", {
|
||
ctx: "bedrock",
|
||
modelId: modelConfig.id,
|
||
serviceTier: this.options.awsBedrockServiceTier,
|
||
})
|
||
}
|
||
|
||
// Add anthropic_beta headers for various features
|
||
// Start with an empty array and add betas as needed
|
||
const anthropicBetas: string[] = []
|
||
|
||
// Add 1M context beta if enabled
|
||
if (is1MContextEnabled) {
|
||
anthropicBetas.push("context-1m-2025-08-07")
|
||
}
|
||
|
||
// Add fine-grained tool streaming beta for Claude models
|
||
// This enables proper tool use streaming for Anthropic models on Bedrock
|
||
if (baseModelId.includes("claude")) {
|
||
anthropicBetas.push("fine-grained-tool-streaming-2025-05-14")
|
||
}
|
||
|
||
// Apply anthropic_beta to additionalModelRequestFields if any betas are needed
|
||
if (anthropicBetas.length > 0) {
|
||
if (!additionalModelRequestFields) {
|
||
additionalModelRequestFields = {} as BedrockAdditionalModelFields
|
||
}
|
||
additionalModelRequestFields.anthropic_beta = anthropicBetas
|
||
}
|
||
|
||
const toolConfig: ToolConfiguration = {
|
||
tools: this.convertToolsForBedrock(metadata?.tools ?? []),
|
||
toolChoice: this.convertToolChoiceForBedrock(metadata?.tool_choice),
|
||
}
|
||
|
||
// Build payload with optional service_tier at top level
|
||
// Service tier is a top-level parameter per AWS documentation, NOT inside additionalModelRequestFields
|
||
// https://docs.aws.amazon.com/bedrock/latest/userguide/service-tiers-inference.html
|
||
const payload: BedrockPayloadWithServiceTier = {
|
||
modelId: modelConfig.id,
|
||
messages: formatted.messages,
|
||
system: formatted.system,
|
||
inferenceConfig,
|
||
...(additionalModelRequestFields && { additionalModelRequestFields }),
|
||
// Add anthropic_version at top level when using thinking features
|
||
...(thinkingEnabled && { anthropic_version: "bedrock-2023-05-31" }),
|
||
toolConfig,
|
||
// Add service_tier as a top-level parameter (not inside additionalModelRequestFields)
|
||
...(useServiceTier && { service_tier: this.options.awsBedrockServiceTier }),
|
||
}
|
||
|
||
// Create AbortController with 10 minute timeout
|
||
const controller = new AbortController()
|
||
let timeoutId: NodeJS.Timeout | undefined
|
||
|
||
try {
|
||
timeoutId = setTimeout(
|
||
() => {
|
||
controller.abort()
|
||
},
|
||
10 * 60 * 1000,
|
||
)
|
||
|
||
const command = new ConverseStreamCommand(payload)
|
||
const response = await this.client.send(command, {
|
||
abortSignal: controller.signal,
|
||
})
|
||
|
||
if (!response.stream) {
|
||
clearTimeout(timeoutId)
|
||
throw new Error("No stream available in the response")
|
||
}
|
||
|
||
for await (const chunk of response.stream) {
|
||
// Parse the chunk as JSON if it's a string (for tests)
|
||
let streamEvent: StreamEvent
|
||
try {
|
||
streamEvent = typeof chunk === "string" ? JSON.parse(chunk) : (chunk as unknown as StreamEvent)
|
||
} catch (e) {
|
||
logger.error("Failed to parse stream event", {
|
||
ctx: "bedrock",
|
||
error: e instanceof Error ? e : String(e),
|
||
chunk: typeof chunk === "string" ? chunk : "binary data",
|
||
})
|
||
continue
|
||
}
|
||
|
||
// Handle metadata events first
|
||
if (streamEvent.metadata?.usage) {
|
||
const usage = (streamEvent.metadata?.usage || {}) as UsageType
|
||
|
||
// Check both field naming conventions for cache tokens
|
||
const cacheReadTokens = usage.cacheReadInputTokens || usage.cacheReadInputTokenCount || 0
|
||
const cacheWriteTokens = usage.cacheWriteInputTokens || usage.cacheWriteInputTokenCount || 0
|
||
|
||
// Always include all available token information
|
||
yield {
|
||
type: "usage",
|
||
inputTokens: usage.inputTokens || 0,
|
||
outputTokens: usage.outputTokens || 0,
|
||
cacheReadTokens: cacheReadTokens,
|
||
cacheWriteTokens: cacheWriteTokens,
|
||
}
|
||
continue
|
||
}
|
||
|
||
if (streamEvent?.trace?.promptRouter?.invokedModelId) {
|
||
try {
|
||
//update the in-use model info to be based on the invoked Model Id for the router
|
||
//so that pricing, context window, caching etc have values that can be used
|
||
//However, we want to keep the id of the model to be the ID for the router for
|
||
//subsequent requests so they are sent back through the router
|
||
let invokedArnInfo = this.parseArn(streamEvent.trace.promptRouter.invokedModelId)
|
||
let invokedModel = this.getModelById(invokedArnInfo.modelId as string, invokedArnInfo.modelType)
|
||
if (invokedModel) {
|
||
invokedModel.id = modelConfig.id
|
||
this.costModelConfig = invokedModel
|
||
}
|
||
|
||
// Handle metadata events for the promptRouter.
|
||
if (streamEvent?.trace?.promptRouter?.usage) {
|
||
const routerUsage = streamEvent.trace.promptRouter.usage
|
||
|
||
// Check both field naming conventions for cache tokens
|
||
const cacheReadTokens =
|
||
routerUsage.cacheReadTokens || routerUsage.cacheReadInputTokenCount || 0
|
||
const cacheWriteTokens =
|
||
routerUsage.cacheWriteTokens || routerUsage.cacheWriteInputTokenCount || 0
|
||
|
||
yield {
|
||
type: "usage",
|
||
inputTokens: routerUsage.inputTokens || 0,
|
||
outputTokens: routerUsage.outputTokens || 0,
|
||
cacheReadTokens: cacheReadTokens,
|
||
cacheWriteTokens: cacheWriteTokens,
|
||
}
|
||
}
|
||
} catch (error) {
|
||
logger.error("Error handling Bedrock invokedModelId", {
|
||
ctx: "bedrock",
|
||
error: error instanceof Error ? error : String(error),
|
||
})
|
||
} finally {
|
||
// eslint-disable-next-line no-unsafe-finally
|
||
continue
|
||
}
|
||
}
|
||
|
||
// Handle message start
|
||
if (streamEvent.messageStart) {
|
||
continue
|
||
}
|
||
|
||
// Handle content blocks
|
||
if (streamEvent.contentBlockStart) {
|
||
const cbStart = streamEvent.contentBlockStart
|
||
|
||
// Check if this is a reasoning block (AWS SDK structure)
|
||
if (cbStart.contentBlock?.reasoningContent) {
|
||
if (cbStart.contentBlockIndex && cbStart.contentBlockIndex > 0) {
|
||
yield { type: "reasoning", text: "\n" }
|
||
}
|
||
yield {
|
||
type: "reasoning",
|
||
text: cbStart.contentBlock.reasoningContent.text || "",
|
||
}
|
||
}
|
||
// Check for thinking block - handle both possible AWS SDK structures
|
||
// cbStart.contentBlock: newer structure
|
||
// cbStart.content_block: alternative structure seen in some AWS SDK versions
|
||
else if (cbStart.contentBlock?.type === "thinking" || cbStart.content_block?.type === "thinking") {
|
||
const contentBlock = cbStart.contentBlock || cbStart.content_block
|
||
if (cbStart.contentBlockIndex && cbStart.contentBlockIndex > 0) {
|
||
yield { type: "reasoning", text: "\n" }
|
||
}
|
||
if (contentBlock?.thinking) {
|
||
yield {
|
||
type: "reasoning",
|
||
text: contentBlock.thinking,
|
||
}
|
||
}
|
||
}
|
||
// Handle tool use block start
|
||
else if (cbStart.start?.toolUse || cbStart.contentBlock?.toolUse) {
|
||
const toolUse = cbStart.start?.toolUse || cbStart.contentBlock?.toolUse
|
||
if (toolUse) {
|
||
yield {
|
||
type: "tool_call_partial",
|
||
index: cbStart.contentBlockIndex ?? 0,
|
||
id: toolUse.toolUseId,
|
||
name: toolUse.name,
|
||
arguments: undefined,
|
||
}
|
||
}
|
||
} else if (cbStart.start?.text) {
|
||
yield {
|
||
type: "text",
|
||
text: cbStart.start.text,
|
||
}
|
||
}
|
||
continue
|
||
}
|
||
|
||
// Handle content deltas
|
||
if (streamEvent.contentBlockDelta) {
|
||
const cbDelta = streamEvent.contentBlockDelta
|
||
const delta = cbDelta.delta
|
||
|
||
// Process reasoning and text content deltas
|
||
// Multiple structures are supported for AWS SDK compatibility:
|
||
// - delta.reasoningContent.text: AWS docs structure for reasoning
|
||
// - delta.thinking: alternative structure for thinking content
|
||
// - delta.text: standard text content
|
||
// - delta.toolUse.input: tool input arguments
|
||
if (delta) {
|
||
// Check for reasoningContent property (AWS SDK structure)
|
||
if (delta.reasoningContent?.text) {
|
||
yield {
|
||
type: "reasoning",
|
||
text: delta.reasoningContent.text,
|
||
}
|
||
continue
|
||
}
|
||
|
||
// Handle tool use input delta
|
||
if (delta.toolUse?.input) {
|
||
yield {
|
||
type: "tool_call_partial",
|
||
index: cbDelta.contentBlockIndex ?? 0,
|
||
id: undefined,
|
||
name: undefined,
|
||
arguments: delta.toolUse.input,
|
||
}
|
||
continue
|
||
}
|
||
|
||
// Handle alternative thinking structure (fallback for older SDK versions)
|
||
if (delta.type === "thinking_delta" && delta.thinking) {
|
||
yield {
|
||
type: "reasoning",
|
||
text: delta.thinking,
|
||
}
|
||
} else if (delta.text) {
|
||
yield {
|
||
type: "text",
|
||
text: delta.text,
|
||
}
|
||
}
|
||
}
|
||
continue
|
||
}
|
||
// Handle message stop
|
||
if (streamEvent.messageStop) {
|
||
continue
|
||
}
|
||
}
|
||
// Clear timeout after stream completes
|
||
clearTimeout(timeoutId)
|
||
} catch (error: unknown) {
|
||
// Clear timeout on error
|
||
clearTimeout(timeoutId)
|
||
|
||
// Capture error in telemetry before processing
|
||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||
const apiError = new ApiProviderError(errorMessage, this.providerName, modelConfig.id, "createMessage")
|
||
TelemetryService.instance.captureException(apiError)
|
||
|
||
// Check if this is a throttling error that should trigger retry logic
|
||
const errorType = this.getErrorType(error)
|
||
|
||
// For throttling errors, throw immediately without yielding chunks
|
||
// This allows the retry mechanism in attemptApiRequest() to catch and handle it
|
||
// The retry logic in Task.ts (around line 1817) expects errors to be thrown
|
||
// on the first chunk for proper exponential backoff behavior
|
||
if (errorType === "THROTTLING") {
|
||
if (error instanceof Error) {
|
||
throw error
|
||
} else {
|
||
throw new Error("Throttling error occurred")
|
||
}
|
||
}
|
||
|
||
// For non-throttling errors, use the standard error handling with chunks
|
||
const errorChunks = this.handleBedrockError(error, true) // true for streaming context
|
||
// Yield each chunk individually to ensure type compatibility
|
||
for (const chunk of errorChunks) {
|
||
yield chunk as any // Cast to any to bypass type checking since we know the structure is correct
|
||
}
|
||
|
||
// Re-throw with enhanced error message for retry system
|
||
const enhancedErrorMessage = this.formatErrorMessage(error, this.getErrorType(error), true)
|
||
if (error instanceof Error) {
|
||
const enhancedError = new Error(enhancedErrorMessage)
|
||
// Preserve important properties from the original error
|
||
enhancedError.name = error.name
|
||
// Validate and preserve status property
|
||
if ("status" in error && typeof (error as any).status === "number") {
|
||
;(enhancedError as any).status = (error as any).status
|
||
}
|
||
// Validate and preserve $metadata property
|
||
if (
|
||
"$metadata" in error &&
|
||
typeof (error as any).$metadata === "object" &&
|
||
(error as any).$metadata !== null
|
||
) {
|
||
;(enhancedError as any).$metadata = (error as any).$metadata
|
||
}
|
||
throw enhancedError
|
||
} else {
|
||
throw new Error("An unknown error occurred")
|
||
}
|
||
}
|
||
}
|
||
|
||
async completePrompt(prompt: string): Promise<string> {
|
||
try {
|
||
const modelConfig = this.getModel()
|
||
|
||
// For completePrompt, thinking is typically not used, but we should still check
|
||
// if thinking was somehow enabled in the model config
|
||
const thinkingEnabled =
|
||
shouldUseReasoningBudget({ model: modelConfig.info, settings: this.options }) &&
|
||
modelConfig.reasoning &&
|
||
modelConfig.reasoningBudget
|
||
|
||
const inferenceConfig: BedrockInferenceConfig = {
|
||
maxTokens: modelConfig.maxTokens || (modelConfig.info.maxTokens as number),
|
||
temperature: modelConfig.temperature ?? (this.options.modelTemperature as number),
|
||
}
|
||
|
||
// For completePrompt, use a unique conversation ID based on the prompt
|
||
const conversationId = `prompt_${prompt.substring(0, 20)}`
|
||
|
||
const payload = {
|
||
modelId: modelConfig.id,
|
||
messages: this.convertToBedrockConverseMessages(
|
||
[
|
||
{
|
||
role: "user",
|
||
content: prompt,
|
||
},
|
||
],
|
||
undefined,
|
||
false,
|
||
modelConfig.info,
|
||
conversationId,
|
||
).messages,
|
||
inferenceConfig,
|
||
}
|
||
|
||
const command = new ConverseCommand(payload)
|
||
const response = await this.client.send(command)
|
||
|
||
if (
|
||
response?.output?.message?.content &&
|
||
response.output.message.content.length > 0 &&
|
||
response.output.message.content[0].text &&
|
||
response.output.message.content[0].text.trim().length > 0
|
||
) {
|
||
try {
|
||
return response.output.message.content[0].text
|
||
} catch (parseError) {
|
||
logger.error("Failed to parse Bedrock response", {
|
||
ctx: "bedrock",
|
||
error: parseError instanceof Error ? parseError : String(parseError),
|
||
})
|
||
}
|
||
}
|
||
return ""
|
||
} catch (error) {
|
||
// Capture error in telemetry
|
||
const model = this.getModel()
|
||
const telemetryErrorMessage = error instanceof Error ? error.message : String(error)
|
||
const apiError = new ApiProviderError(telemetryErrorMessage, this.providerName, model.id, "completePrompt")
|
||
TelemetryService.instance.captureException(apiError)
|
||
|
||
// Use the extracted error handling method for all errors
|
||
const errorResult = this.handleBedrockError(error, false) // false for non-streaming context
|
||
// Since we're in a non-streaming context, we know the result is a string
|
||
const errorMessage = errorResult as string
|
||
|
||
// Create enhanced error for retry system
|
||
const enhancedError = new Error(errorMessage)
|
||
if (error instanceof Error) {
|
||
// Preserve important properties from the original error
|
||
enhancedError.name = error.name
|
||
// Validate and preserve status property
|
||
if ("status" in error && typeof (error as any).status === "number") {
|
||
;(enhancedError as any).status = (error as any).status
|
||
}
|
||
// Validate and preserve $metadata property
|
||
if (
|
||
"$metadata" in error &&
|
||
typeof (error as any).$metadata === "object" &&
|
||
(error as any).$metadata !== null
|
||
) {
|
||
;(enhancedError as any).$metadata = (error as any).$metadata
|
||
}
|
||
}
|
||
throw enhancedError
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Convert Anthropic messages to Bedrock Converse format
|
||
*/
|
||
private convertToBedrockConverseMessages(
|
||
anthropicMessages: Anthropic.Messages.MessageParam[] | { role: string; content: string }[],
|
||
systemMessage?: string,
|
||
usePromptCache: boolean = false,
|
||
modelInfo?: any,
|
||
conversationId?: string, // Optional conversation ID to track cache points across messages
|
||
): { system: SystemContentBlock[]; messages: Message[] } {
|
||
// First convert messages using shared converter for proper image handling
|
||
const convertedMessages = sharedConverter(anthropicMessages as Anthropic.Messages.MessageParam[])
|
||
|
||
// If prompt caching is disabled, return the converted messages directly
|
||
if (!usePromptCache) {
|
||
return {
|
||
system: systemMessage ? [{ text: systemMessage } as SystemContentBlock] : [],
|
||
messages: convertedMessages,
|
||
}
|
||
}
|
||
|
||
// Convert model info to expected format for cache strategy
|
||
const cacheModelInfo: CacheModelInfo = {
|
||
maxTokens: modelInfo?.maxTokens || 8192,
|
||
contextWindow: modelInfo?.contextWindow || 200_000,
|
||
supportsPromptCache: modelInfo?.supportsPromptCache || false,
|
||
maxCachePoints: modelInfo?.maxCachePoints || 0,
|
||
minTokensPerCachePoint: modelInfo?.minTokensPerCachePoint || 50,
|
||
cachableFields: modelInfo?.cachableFields || [],
|
||
}
|
||
|
||
// Get previous cache point placements for this conversation if available
|
||
const previousPlacements =
|
||
conversationId && this.previousCachePointPlacements[conversationId]
|
||
? this.previousCachePointPlacements[conversationId]
|
||
: undefined
|
||
|
||
// Create config for cache strategy
|
||
const config = {
|
||
modelInfo: cacheModelInfo,
|
||
systemPrompt: systemMessage,
|
||
messages: anthropicMessages as Anthropic.Messages.MessageParam[],
|
||
usePromptCache,
|
||
previousCachePointPlacements: previousPlacements,
|
||
}
|
||
|
||
// Get cache point placements
|
||
let strategy = new MultiPointStrategy(config)
|
||
const cacheResult = strategy.determineOptimalCachePoints()
|
||
|
||
// Store cache point placements for future use if conversation ID is provided
|
||
if (conversationId && cacheResult.messageCachePointPlacements) {
|
||
this.previousCachePointPlacements[conversationId] = cacheResult.messageCachePointPlacements
|
||
}
|
||
|
||
// Apply cache points to the properly converted messages
|
||
const messagesWithCache = convertedMessages.map((msg, index) => {
|
||
const placement = cacheResult.messageCachePointPlacements?.find((p) => p.index === index)
|
||
if (placement) {
|
||
return {
|
||
...msg,
|
||
content: [...(msg.content || []), { cachePoint: { type: "default" } } as ContentBlock],
|
||
}
|
||
}
|
||
return msg
|
||
})
|
||
|
||
return {
|
||
system: cacheResult.system,
|
||
messages: messagesWithCache,
|
||
}
|
||
}
|
||
|
||
/************************************************************************************
|
||
*
|
||
* MODEL IDENTIFICATION
|
||
*
|
||
*************************************************************************************/
|
||
|
||
private costModelConfig: { id: BedrockModelId | string; info: ModelInfo } = {
|
||
id: "",
|
||
info: { maxTokens: 0, contextWindow: 0, supportsPromptCache: false, supportsImages: false },
|
||
}
|
||
|
||
private parseArn(arn: string, region?: string) {
|
||
/*
|
||
* VIA Roo analysis: platform-independent Regex. It's designed to parse Amazon Bedrock ARNs and doesn't rely on any platform-specific features
|
||
* like file path separators, line endings, or case sensitivity behaviors. The forward slashes in the regex are properly escaped and
|
||
* represent literal characters in the AWS ARN format, not filesystem paths. This regex will function consistently across Windows,
|
||
* macOS, Linux, and any other operating system where JavaScript runs.
|
||
*
|
||
* Supports any AWS partition (aws, aws-us-gov, aws-cn, or future partitions).
|
||
* The partition is not captured since we don't need to use it.
|
||
*
|
||
* This matches ARNs like:
|
||
* - Foundation Model: arn:aws:bedrock:us-west-2::foundation-model/anthropic.claude-v2
|
||
* - GovCloud Inference Profile: arn:aws-us-gov:bedrock:us-gov-west-1:123456789012:inference-profile/us-gov.anthropic.claude-sonnet-4-5-20250929-v1:0
|
||
* - Prompt Router: arn:aws:bedrock:us-west-2:123456789012:prompt-router/anthropic-claude
|
||
* - Inference Profile: arn:aws:bedrock:us-west-2:123456789012:inference-profile/anthropic.claude-v2
|
||
* - Cross Region Inference Profile: arn:aws:bedrock:us-west-2:123456789012:inference-profile/us.anthropic.claude-3-5-sonnet-20241022-v2:0
|
||
* - Custom Model (Provisioned Throughput): arn:aws:bedrock:us-west-2:123456789012:provisioned-model/my-custom-model
|
||
* - Imported Model: arn:aws:bedrock:us-west-2:123456789012:imported-model/my-imported-model
|
||
*
|
||
* match[0] - The entire matched string
|
||
* match[1] - The region (e.g., "us-east-1", "us-gov-west-1")
|
||
* match[2] - The account ID (can be empty string for AWS-managed resources)
|
||
* match[3] - The resource type (e.g., "foundation-model")
|
||
* match[4] - The resource ID (e.g., "anthropic.claude-3-sonnet-20240229-v1:0")
|
||
*/
|
||
|
||
const arnRegex = /^arn:[^:]+:(?:bedrock|sagemaker):([^:]+):([^:]*):(?:([^\/]+)\/([\w\.\-:]+)|([^\/]+))$/
|
||
let match = arn.match(arnRegex)
|
||
|
||
if (match && match[1] && match[3] && match[4]) {
|
||
// Create the result object
|
||
const result: {
|
||
isValid: boolean
|
||
region?: string
|
||
modelType?: string
|
||
modelId?: string
|
||
errorMessage?: string
|
||
crossRegionInference: boolean
|
||
} = {
|
||
isValid: true,
|
||
crossRegionInference: false, // Default to false
|
||
}
|
||
|
||
result.modelType = match[3]
|
||
const originalModelId = match[4]
|
||
result.modelId = this.parseBaseModelId(originalModelId)
|
||
|
||
// Extract the region from the first capture group
|
||
const arnRegion = match[1]
|
||
result.region = arnRegion
|
||
|
||
// Check if the original model ID had a region prefix
|
||
if (originalModelId && result.modelId !== originalModelId) {
|
||
// If the model ID changed after parsing, it had a region prefix
|
||
let prefix = originalModelId.replace(result.modelId, "")
|
||
result.crossRegionInference = AwsBedrockHandler.isSystemInferenceProfile(prefix)
|
||
}
|
||
|
||
// Check if region in ARN matches provided region (if specified)
|
||
if (region && arnRegion !== region) {
|
||
result.errorMessage = `Region mismatch: The region in your ARN (${arnRegion}) does not match your selected region (${region}). This may cause access issues. The provider will use the region from the ARN.`
|
||
result.region = arnRegion
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
// If we get here, the regex didn't match
|
||
return {
|
||
isValid: false,
|
||
region: undefined,
|
||
modelType: undefined,
|
||
modelId: undefined,
|
||
errorMessage: "Invalid ARN format. ARN should follow the Amazon Bedrock ARN pattern.",
|
||
crossRegionInference: false,
|
||
}
|
||
}
|
||
|
||
//This strips any region prefix that used on cross-region model inference ARNs
|
||
private parseBaseModelId(modelId: string): string {
|
||
if (!modelId) {
|
||
return modelId
|
||
}
|
||
|
||
// Remove AWS cross-region inference profile prefixes
|
||
// as defined in AWS_INFERENCE_PROFILE_MAPPING
|
||
for (const [_, inferenceProfile] of AWS_INFERENCE_PROFILE_MAPPING) {
|
||
if (modelId.startsWith(inferenceProfile)) {
|
||
// Remove the inference profile prefix from the model ID
|
||
return modelId.substring(inferenceProfile.length)
|
||
}
|
||
}
|
||
|
||
// Also strip Global Inference profile prefix if present
|
||
if (modelId.startsWith("global.")) {
|
||
return modelId.substring("global.".length)
|
||
}
|
||
|
||
// Return the model ID as-is for all other cases
|
||
return modelId
|
||
}
|
||
|
||
//Prompt Router responses come back in a different sequence and the model used is in the response and must be fetched by name
|
||
getModelById(modelId: string, modelType?: string): { id: BedrockModelId | string; info: ModelInfo } {
|
||
// Try to find the model in bedrockModels
|
||
const baseModelId = this.parseBaseModelId(modelId) as BedrockModelId
|
||
|
||
let model
|
||
if (baseModelId in bedrockModels) {
|
||
//Do a deep copy of the model info so that later in the code the model id and maxTokens can be set.
|
||
// The bedrockModels array is a constant and updating the model ID from the returned invokedModelID value
|
||
// in a prompt router response isn't possible on the constant.
|
||
model = { id: baseModelId, info: JSON.parse(JSON.stringify(bedrockModels[baseModelId])) }
|
||
} else if (modelType && modelType.includes("router")) {
|
||
model = {
|
||
id: bedrockDefaultPromptRouterModelId,
|
||
info: JSON.parse(JSON.stringify(bedrockModels[bedrockDefaultPromptRouterModelId])),
|
||
}
|
||
} else {
|
||
// Use heuristics for model info, then allow overrides from ProviderSettings
|
||
const guessed = this.guessModelInfoFromId(modelId)
|
||
model = {
|
||
id: bedrockDefaultModelId,
|
||
info: {
|
||
...JSON.parse(JSON.stringify(bedrockModels[bedrockDefaultModelId])),
|
||
...guessed,
|
||
},
|
||
}
|
||
}
|
||
|
||
// Always allow user to override detected/guessed maxTokens and contextWindow
|
||
if (this.options.modelMaxTokens && this.options.modelMaxTokens > 0) {
|
||
model.info.maxTokens = this.options.modelMaxTokens
|
||
}
|
||
if (this.options.awsModelContextWindow && this.options.awsModelContextWindow > 0) {
|
||
model.info.contextWindow = this.options.awsModelContextWindow
|
||
}
|
||
|
||
return model
|
||
}
|
||
|
||
override getModel(): {
|
||
id: BedrockModelId | string
|
||
info: ModelInfo
|
||
maxTokens?: number
|
||
temperature?: number
|
||
reasoning?: any
|
||
reasoningBudget?: number
|
||
} {
|
||
if (this.costModelConfig?.id?.trim().length > 0) {
|
||
// Get model params for cost model config
|
||
const params = getModelParams({
|
||
format: "anthropic",
|
||
modelId: this.costModelConfig.id,
|
||
model: this.costModelConfig.info,
|
||
settings: this.options,
|
||
defaultTemperature: BEDROCK_DEFAULT_TEMPERATURE,
|
||
})
|
||
return { ...this.costModelConfig, ...params }
|
||
}
|
||
|
||
let modelConfig = undefined
|
||
|
||
// If custom ARN is provided, use it
|
||
if (this.options.awsCustomArn) {
|
||
modelConfig = this.getModelById(this.arnInfo.modelId, this.arnInfo.modelType)
|
||
|
||
//If the user entered an ARN for a foundation-model they've done the same thing as picking from our list of options.
|
||
//We leave the model data matching the same as if a drop-down input method was used by not overwriting the model ID with the user input ARN
|
||
//Otherwise the ARN is not a foundation-model resource type that ARN should be used as the identifier in Bedrock interactions
|
||
if (this.arnInfo.modelType !== "foundation-model") modelConfig.id = this.options.awsCustomArn
|
||
} else {
|
||
//a model was selected from the drop down
|
||
modelConfig = this.getModelById(this.options.apiModelId as string)
|
||
|
||
// Apply Global Inference prefix if enabled and supported (takes precedence over cross-region)
|
||
const baseIdForGlobal = this.parseBaseModelId(modelConfig.id)
|
||
if (
|
||
this.options.awsUseGlobalInference &&
|
||
BEDROCK_GLOBAL_INFERENCE_MODEL_IDS.includes(baseIdForGlobal as any)
|
||
) {
|
||
modelConfig.id = `global.${baseIdForGlobal}`
|
||
}
|
||
// Otherwise, add cross-region inference prefix if enabled
|
||
else if (this.options.awsUseCrossRegionInference && this.options.awsRegion) {
|
||
const prefix = AwsBedrockHandler.getPrefixForRegion(this.options.awsRegion)
|
||
if (prefix) {
|
||
modelConfig.id = `${prefix}${modelConfig.id}`
|
||
}
|
||
}
|
||
}
|
||
|
||
// Check if 1M context is enabled for supported Claude 4 models
|
||
// Use parseBaseModelId to handle cross-region inference prefixes
|
||
const baseModelId = this.parseBaseModelId(modelConfig.id)
|
||
if (BEDROCK_1M_CONTEXT_MODEL_IDS.includes(baseModelId as any) && this.options.awsBedrock1MContext) {
|
||
// Update context window and pricing to 1M tier when 1M context beta is enabled
|
||
const tier = modelConfig.info.tiers?.[0]
|
||
modelConfig.info = {
|
||
...modelConfig.info,
|
||
contextWindow: tier?.contextWindow ?? 1_000_000,
|
||
inputPrice: tier?.inputPrice ?? modelConfig.info.inputPrice,
|
||
outputPrice: tier?.outputPrice ?? modelConfig.info.outputPrice,
|
||
cacheWritesPrice: tier?.cacheWritesPrice ?? modelConfig.info.cacheWritesPrice,
|
||
cacheReadsPrice: tier?.cacheReadsPrice ?? modelConfig.info.cacheReadsPrice,
|
||
}
|
||
}
|
||
|
||
// Get model params including reasoning configuration
|
||
const params = getModelParams({
|
||
format: "anthropic",
|
||
modelId: modelConfig.id,
|
||
model: modelConfig.info,
|
||
settings: this.options,
|
||
defaultTemperature: BEDROCK_DEFAULT_TEMPERATURE,
|
||
})
|
||
|
||
// Apply service tier pricing if specified and model supports it
|
||
const baseModelIdForTier = this.parseBaseModelId(modelConfig.id)
|
||
if (this.options.awsBedrockServiceTier && BEDROCK_SERVICE_TIER_MODEL_IDS.includes(baseModelIdForTier as any)) {
|
||
const pricingMultiplier = BEDROCK_SERVICE_TIER_PRICING[this.options.awsBedrockServiceTier]
|
||
if (pricingMultiplier && pricingMultiplier !== 1.0) {
|
||
// Apply pricing multiplier to all price fields
|
||
modelConfig.info = {
|
||
...modelConfig.info,
|
||
inputPrice: modelConfig.info.inputPrice
|
||
? modelConfig.info.inputPrice * pricingMultiplier
|
||
: undefined,
|
||
outputPrice: modelConfig.info.outputPrice
|
||
? modelConfig.info.outputPrice * pricingMultiplier
|
||
: undefined,
|
||
cacheWritesPrice: modelConfig.info.cacheWritesPrice
|
||
? modelConfig.info.cacheWritesPrice * pricingMultiplier
|
||
: undefined,
|
||
cacheReadsPrice: modelConfig.info.cacheReadsPrice
|
||
? modelConfig.info.cacheReadsPrice * pricingMultiplier
|
||
: undefined,
|
||
}
|
||
}
|
||
}
|
||
|
||
// Don't override maxTokens/contextWindow here; handled in getModelById (and includes user overrides)
|
||
return { ...modelConfig, ...params } as {
|
||
id: BedrockModelId | string
|
||
info: ModelInfo
|
||
maxTokens?: number
|
||
temperature?: number
|
||
reasoning?: any
|
||
reasoningBudget?: number
|
||
}
|
||
}
|
||
|
||
/************************************************************************************
|
||
*
|
||
* CACHE
|
||
*
|
||
*************************************************************************************/
|
||
|
||
// Store previous cache point placements for maintaining consistency across consecutive messages
|
||
private previousCachePointPlacements: { [conversationId: string]: any[] } = {}
|
||
|
||
private supportsAwsPromptCache(modelConfig: { id: BedrockModelId | string; info: ModelInfo }): boolean | undefined {
|
||
// Check if the model supports prompt cache
|
||
const hasCachableFields = modelConfig?.info?.cachableFields && modelConfig.info.cachableFields.length > 0
|
||
|
||
if (modelConfig?.info?.supportsPromptCache && hasCachableFields) {
|
||
return true
|
||
}
|
||
|
||
// When using a custom ARN and the user has enabled prompt caching (or left it
|
||
// at the default), respect their intent even if the model info is incomplete.
|
||
// The model info may lack cachableFields or supportsPromptCache when the model
|
||
// ID extracted from the ARN doesn't match a known model in bedrockModels.
|
||
// In this case, inject defaults so the downstream caching logic works correctly.
|
||
// If the underlying model truly does not support caching, the Bedrock API
|
||
// simply ignores cache points without erroring.
|
||
if (this.options.awsCustomArn && this.options.awsUsePromptCache !== false) {
|
||
if (!hasCachableFields) {
|
||
modelConfig.info.cachableFields = ["system", "messages", "tools"]
|
||
}
|
||
modelConfig.info.supportsPromptCache = true
|
||
return true
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
/**
|
||
* Removes any existing cachePoint nodes from content blocks
|
||
*/
|
||
private removeCachePoints(content: any): any {
|
||
if (Array.isArray(content)) {
|
||
return content.map((block) => {
|
||
// Use destructuring to remove cachePoint property
|
||
const { cachePoint: _, ...rest } = block
|
||
return rest
|
||
})
|
||
}
|
||
|
||
return content
|
||
}
|
||
|
||
/************************************************************************************
|
||
*
|
||
* NATIVE TOOLS
|
||
*
|
||
*************************************************************************************/
|
||
|
||
/**
|
||
* Convert OpenAI tool definitions to Bedrock Converse format
|
||
* Transforms JSON Schema to draft 2020-12 compliant format required by Claude models.
|
||
* @param tools Array of OpenAI ChatCompletionTool definitions
|
||
* @returns Array of Bedrock Tool definitions
|
||
*/
|
||
private convertToolsForBedrock(tools: OpenAI.Chat.ChatCompletionTool[]): Tool[] {
|
||
return tools
|
||
.filter((tool) => tool.type === "function")
|
||
.map(
|
||
(tool) =>
|
||
({
|
||
toolSpec: {
|
||
name: tool.function.name,
|
||
description: tool.function.description,
|
||
inputSchema: {
|
||
// Normalize schema to JSON Schema draft 2020-12 compliant format
|
||
// This converts type: ["T", "null"] to anyOf: [{type: "T"}, {type: "null"}]
|
||
json: normalizeToolSchema(tool.function.parameters as Record<string, unknown>),
|
||
},
|
||
},
|
||
}) as Tool,
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Convert OpenAI tool_choice to Bedrock ToolChoice format
|
||
* @param toolChoice OpenAI tool_choice parameter
|
||
* @returns Bedrock ToolChoice configuration
|
||
*/
|
||
private convertToolChoiceForBedrock(
|
||
toolChoice: OpenAI.Chat.ChatCompletionCreateParams["tool_choice"],
|
||
): ToolChoice | undefined {
|
||
if (!toolChoice) {
|
||
// Default to auto - model decides whether to use tools
|
||
return { auto: {} } as ToolChoice
|
||
}
|
||
|
||
if (typeof toolChoice === "string") {
|
||
switch (toolChoice) {
|
||
case "none":
|
||
return undefined // Bedrock doesn't have "none", just omit tools
|
||
case "auto":
|
||
return { auto: {} } as ToolChoice
|
||
case "required":
|
||
return { any: {} } as ToolChoice // Model must use at least one tool
|
||
default:
|
||
return { auto: {} } as ToolChoice
|
||
}
|
||
}
|
||
|
||
// Handle object form { type: "function", function: { name: string } }
|
||
if (typeof toolChoice === "object" && "function" in toolChoice) {
|
||
return {
|
||
tool: {
|
||
name: toolChoice.function.name,
|
||
},
|
||
} as ToolChoice
|
||
}
|
||
|
||
return { auto: {} } as ToolChoice
|
||
}
|
||
|
||
/************************************************************************************
|
||
*
|
||
* AMAZON REGIONS
|
||
*
|
||
*************************************************************************************/
|
||
|
||
private static getPrefixForRegion(region: string): string | undefined {
|
||
// Use AWS recommended inference profile prefixes
|
||
// Array is pre-sorted by pattern length (descending) to ensure more specific patterns match first
|
||
for (const [regionPattern, inferenceProfile] of AWS_INFERENCE_PROFILE_MAPPING) {
|
||
if (region.startsWith(regionPattern)) {
|
||
return inferenceProfile
|
||
}
|
||
}
|
||
|
||
return undefined
|
||
}
|
||
|
||
private static isSystemInferenceProfile(prefix: string): boolean {
|
||
// Check if the prefix is defined in AWS_INFERENCE_PROFILE_MAPPING
|
||
for (const [_, inferenceProfile] of AWS_INFERENCE_PROFILE_MAPPING) {
|
||
if (prefix === inferenceProfile) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
/************************************************************************************
|
||
*
|
||
* ERROR HANDLING
|
||
*
|
||
*************************************************************************************/
|
||
|
||
/**
|
||
* Error type definitions for Bedrock API errors
|
||
*/
|
||
private static readonly ERROR_TYPES: Record<
|
||
string,
|
||
{
|
||
patterns: string[] // Strings to match in lowercase error message or name
|
||
messageTemplate: string // Template with placeholders like {region}, {modelId}, etc.
|
||
logLevel: "error" | "warn" | "info" // Log level for this error type
|
||
}
|
||
> = {
|
||
ACCESS_DENIED: {
|
||
patterns: ["access", "denied", "permission"],
|
||
messageTemplate: `You don't have access to the model specified.
|
||
|
||
Please verify:
|
||
1. Try cross-region inference if you're using a foundation model
|
||
2. If using an ARN, verify the ARN is correct and points to a valid model
|
||
3. Your AWS credentials have permission to access this model (check IAM policies)
|
||
4. The region in the ARN matches the region where the model is deployed
|
||
5. If using a provisioned model, ensure it's active and not in a failed state`,
|
||
logLevel: "error",
|
||
},
|
||
NOT_FOUND: {
|
||
patterns: ["not found", "does not exist"],
|
||
messageTemplate: `The specified ARN does not exist or is invalid. Please check:
|
||
|
||
1. The ARN format is correct (arn:aws:bedrock:region:account-id:resource-type/resource-name)
|
||
2. The model exists in the specified region
|
||
3. The account ID in the ARN is correct`,
|
||
logLevel: "error",
|
||
},
|
||
THROTTLING: {
|
||
patterns: [
|
||
"throttl",
|
||
"rate",
|
||
"limit",
|
||
"bedrock is unable to process your request", // Amazon Bedrock specific throttling message
|
||
"please wait",
|
||
"quota exceeded",
|
||
"service unavailable",
|
||
"busy",
|
||
"overloaded",
|
||
"too many requests",
|
||
"request limit",
|
||
"concurrent requests",
|
||
],
|
||
messageTemplate: `Request was throttled or rate limited. Please try:
|
||
1. Reducing the frequency of requests
|
||
2. If using a provisioned model, check its throughput settings
|
||
3. Contact AWS support to request a quota increase if needed
|
||
|
||
`,
|
||
logLevel: "error",
|
||
},
|
||
TOO_MANY_TOKENS: {
|
||
patterns: ["too many tokens", "token limit exceeded", "context length", "maximum context length"],
|
||
messageTemplate: `"Too many tokens" error detected.
|
||
Possible Causes:
|
||
1. Input exceeds model's context window limit
|
||
2. Rate limiting (too many tokens per minute)
|
||
3. Quota exceeded for token usage
|
||
4. Other token-related service limitations
|
||
|
||
Suggestions:
|
||
1. Reduce the size of your input
|
||
2. Split your request into smaller chunks
|
||
3. Use a model with a larger context window
|
||
4. If rate limited, reduce request frequency
|
||
5. Check your Amazon Bedrock quotas and limits
|
||
|
||
`,
|
||
logLevel: "error",
|
||
},
|
||
SERVICE_QUOTA_EXCEEDED: {
|
||
patterns: ["service quota exceeded", "service quota", "quota exceeded for model"],
|
||
messageTemplate: `Service quota exceeded. This error indicates you've reached AWS service limits.
|
||
|
||
Please try:
|
||
1. Contact AWS support to request a quota increase
|
||
2. Reduce request frequency temporarily
|
||
3. Check your Amazon Bedrock quotas in the AWS console
|
||
4. Consider using a different model or region with available capacity
|
||
|
||
`,
|
||
logLevel: "error",
|
||
},
|
||
MODEL_NOT_READY: {
|
||
patterns: ["model not ready", "model is not ready", "provisioned throughput not ready", "model loading"],
|
||
messageTemplate: `Model is not ready or still loading. This can happen with:
|
||
1. Provisioned throughput models that are still initializing
|
||
2. Custom models that are being loaded
|
||
3. Models that are temporarily unavailable
|
||
|
||
Please try:
|
||
1. Wait a few minutes and retry
|
||
2. Check the model status in Amazon Bedrock console
|
||
3. Verify the model is properly provisioned
|
||
|
||
`,
|
||
logLevel: "error",
|
||
},
|
||
INTERNAL_SERVER_ERROR: {
|
||
patterns: ["internal server error", "internal error", "server error", "service error"],
|
||
messageTemplate: `Amazon Bedrock internal server error. This is a temporary service issue.
|
||
|
||
Please try:
|
||
1. Retry the request after a brief delay
|
||
2. If the error persists, check AWS service health
|
||
3. Contact AWS support if the issue continues
|
||
|
||
`,
|
||
logLevel: "error",
|
||
},
|
||
ON_DEMAND_NOT_SUPPORTED: {
|
||
patterns: ["with on-demand throughput isn’t supported."],
|
||
messageTemplate: `
|
||
1. Try enabling cross-region inference in settings.
|
||
2. Or, create an inference profile and then leverage the "Use custom ARN..." option of the model selector in settings.`,
|
||
logLevel: "error",
|
||
},
|
||
ABORT: {
|
||
patterns: ["aborterror"], // This will match error.name.toLowerCase() for AbortError
|
||
messageTemplate: `Request was aborted: The operation timed out or was manually cancelled. Please try again or check your network connection.`,
|
||
logLevel: "info",
|
||
},
|
||
INVALID_ARN_FORMAT: {
|
||
patterns: ["invalid_arn_format:", "invalid arn format"],
|
||
messageTemplate: `Invalid ARN format. ARN should follow the pattern: arn:aws:bedrock:region:account-id:resource-type/resource-name`,
|
||
logLevel: "error",
|
||
},
|
||
VALIDATION_ERROR: {
|
||
patterns: [
|
||
"input tag",
|
||
"does not match any of the expected tags",
|
||
"field required",
|
||
"validation",
|
||
"invalid parameter",
|
||
],
|
||
messageTemplate: `Parameter validation error: {errorMessage}
|
||
|
||
This error indicates that the request parameters don't match Amazon Bedrock's expected format.
|
||
|
||
Common causes:
|
||
1. Extended thinking parameter format is incorrect
|
||
2. Model-specific parameters are not supported by this model
|
||
3. API parameter structure has changed
|
||
|
||
Please check:
|
||
- Model supports the requested features (extended thinking, etc.)
|
||
- Parameter format matches Amazon Bedrock specification
|
||
- Model ID is correct for the requested features`,
|
||
logLevel: "error",
|
||
},
|
||
// Default/generic error
|
||
GENERIC: {
|
||
patterns: [], // Empty patterns array means this is the default
|
||
messageTemplate: `Unknown Error: {errorMessage}`,
|
||
logLevel: "error",
|
||
},
|
||
}
|
||
|
||
/**
|
||
* Determines the error type based on the error message or name
|
||
*/
|
||
private getErrorType(error: unknown): string {
|
||
if (!(error instanceof Error)) {
|
||
return "GENERIC"
|
||
}
|
||
|
||
// Check for HTTP 429 status code (Too Many Requests)
|
||
if ((error as any).status === 429 || (error as any).$metadata?.httpStatusCode === 429) {
|
||
return "THROTTLING"
|
||
}
|
||
|
||
// Check for Amazon Bedrock specific throttling exception names
|
||
if ((error as any).name === "ThrottlingException" || (error as any).__type === "ThrottlingException") {
|
||
return "THROTTLING"
|
||
}
|
||
|
||
const errorMessage = error.message.toLowerCase()
|
||
const errorName = error.name.toLowerCase()
|
||
|
||
// Check each error type's patterns in order of specificity (most specific first)
|
||
const errorTypeOrder = [
|
||
"SERVICE_QUOTA_EXCEEDED", // Most specific - check before THROTTLING
|
||
"MODEL_NOT_READY",
|
||
"TOO_MANY_TOKENS",
|
||
"INTERNAL_SERVER_ERROR",
|
||
"ON_DEMAND_NOT_SUPPORTED",
|
||
"NOT_FOUND",
|
||
"ACCESS_DENIED",
|
||
"THROTTLING", // Less specific - check after more specific patterns
|
||
]
|
||
|
||
for (const errorType of errorTypeOrder) {
|
||
const definition = AwsBedrockHandler.ERROR_TYPES[errorType]
|
||
if (!definition) continue
|
||
|
||
// If any pattern matches in either message or name, return this error type
|
||
if (definition.patterns.some((pattern) => errorMessage.includes(pattern) || errorName.includes(pattern))) {
|
||
return errorType
|
||
}
|
||
}
|
||
|
||
// Default to generic error
|
||
return "GENERIC"
|
||
}
|
||
|
||
/**
|
||
* Formats an error message based on the error type and context
|
||
*/
|
||
private formatErrorMessage(error: unknown, errorType: string, _isStreamContext: boolean): string {
|
||
const definition = AwsBedrockHandler.ERROR_TYPES[errorType] || AwsBedrockHandler.ERROR_TYPES.GENERIC
|
||
let template = definition.messageTemplate
|
||
|
||
// Prepare template variables
|
||
const templateVars: Record<string, string> = {}
|
||
|
||
if (error instanceof Error) {
|
||
templateVars.errorMessage = error.message
|
||
templateVars.errorName = error.name
|
||
|
||
const modelConfig = this.getModel()
|
||
templateVars.modelId = modelConfig.id
|
||
templateVars.contextWindow = String(modelConfig.info.contextWindow || "unknown")
|
||
}
|
||
|
||
// Add context-specific template variables
|
||
const region =
|
||
typeof this?.client?.config?.region === "function"
|
||
? this?.client?.config?.region()
|
||
: this?.client?.config?.region
|
||
templateVars.regionInfo = `(${region})`
|
||
|
||
// Replace template variables
|
||
for (const [key, value] of Object.entries(templateVars)) {
|
||
template = template.replace(new RegExp(`{${key}}`, "g"), value || "")
|
||
}
|
||
|
||
return template
|
||
}
|
||
|
||
/**
|
||
* Handles Bedrock API errors and generates appropriate error messages
|
||
* @param error The error that occurred
|
||
* @param isStreamContext Whether the error occurred in a streaming context (true) or not (false)
|
||
* @returns Error message string for non-streaming context or array of stream chunks for streaming context
|
||
*/
|
||
private handleBedrockError(
|
||
error: unknown,
|
||
isStreamContext: boolean,
|
||
): string | Array<{ type: string; text?: string; inputTokens?: number; outputTokens?: number }> {
|
||
// Determine error type
|
||
const errorType = this.getErrorType(error)
|
||
|
||
// Format error message
|
||
const errorMessage = this.formatErrorMessage(error, errorType, isStreamContext)
|
||
|
||
// Log the error
|
||
const definition = AwsBedrockHandler.ERROR_TYPES[errorType]
|
||
const logMethod = definition.logLevel
|
||
const contextName = isStreamContext ? "createMessage" : "completePrompt"
|
||
logger[logMethod](`${errorType} error in ${contextName}`, {
|
||
ctx: "bedrock",
|
||
customArn: this.options.awsCustomArn,
|
||
errorType,
|
||
errorMessage: error instanceof Error ? error.message : String(error),
|
||
...(error instanceof Error && error.stack ? { errorStack: error.stack } : {}),
|
||
...(this.client?.config?.region ? { clientRegion: this.client.config.region } : {}),
|
||
})
|
||
|
||
// Return appropriate response based on isStreamContext
|
||
if (isStreamContext) {
|
||
return [
|
||
{ type: "text", text: `Error: ${errorMessage}` },
|
||
{ type: "usage", inputTokens: 0, outputTokens: 0 },
|
||
]
|
||
} else {
|
||
// For non-streaming context, add the expected prefix
|
||
return `Bedrock completion error: ${errorMessage}`
|
||
}
|
||
}
|
||
}
|