feature/extend AWS Bedrock node with full model catalog and custom model support (#6309)

* feat(bedrock): extend AWS Bedrock node with full model catalog and custom model support

* Update packages/components/models.json

fixed typo

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* fixed typo

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update packages/components/nodes/chatmodels/AWSBedrock/FlowiseAWSChatBedrockImported.ts

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* fix

* lint fix

* remove redundant readme implementation file

---------

Co-authored-by: Ankit <[email protected]>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Henry <[email protected]>
This commit is contained in:
Ankit5467
2026-05-06 15:58:09 +01:00
committed by GitHub
co-authored by gemini-code-assist[bot] Ankit Henry
parent aee37e16b1
commit 1449e80d34
15 changed files with 4766 additions and 1835 deletions
+2 -1
View File
@@ -118,4 +118,5 @@ apps/*/
# Claude - session/user specific files
.claude/plans/
.claude/settings.local.json
.claude/agent-memory/*
.claude/agent-memory/*
+270 -270
View File
@@ -1,270 +1,270 @@
# @flowiseai/agentflow
[![Version](https://img.shields.io/npm/v/@flowiseai/agentflow)](https://www.npmjs.com/package/@flowiseai/agentflow)
[![License](https://img.shields.io/badge/license-Apache--2.0-blue)](https://github.com/FlowiseAI/Flowise/blob/main/LICENSE.md)
> Embeddable React component for building and visualizing AI agent workflows
## Status: Dev
**Current version: `0.0.0-dev.13`**
This package is functional and has comprehensive test coverage, but the public API may still change before a stable release. It is suitable for early integration and testing but not yet recommended for production use.
**What works today:**
- 13 node types with full editing support (Loop and Human Input nodes are present in the palette but not yet fully verified end-to-end)
- Connection validation, flow validation, and export
- Async option loading (models, credentials, tools)
- Variable picker and field visibility conditions
- AI flow generation from natural language
- Dark/light mode theming
- Read-only mode and custom rendering via render props
## Overview
`@flowiseai/agentflow` is a React-based flow editor for creating AI agent workflows. It provides a visual canvas built on ReactFlow for connecting AI agents, LLMs, tools, and logic nodes.
## Features
- **Visual Canvas** — Drag-and-drop flow editor built on ReactFlow with zoom, pan, minimap, and fit-to-view controls
- **13 Built-in Node Types** — Start, Agent, LLM, Condition, Condition Agent, Direct Reply, Custom Function, Tool, Retriever, Sticky Note, HTTP, Iteration, and Execute Flow
- **Node Editor Dialog** — Modal for editing node parameters with dynamic input types (text, number, boolean, dropdown, arrays, JSON, code, variable selector, async options)
- **Credential Management** — Create and edit credentials inline from the node editor
- **Rich Text Editor** — TipTap-based editor with syntax highlighting for JavaScript, TypeScript, Python, and JSON (lazy-loaded)
- **Specialized Input Components** — Condition builder, messages input (role + content), and structured output schema builder
- **AI Flow Generator** — Generate flows from natural language descriptions with model selection
- **Flow Validation** — Detects empty flows, missing start nodes, disconnected nodes, cycles, hanging edges, and per-node input errors with visual feedback
- **Dark Mode** — Full light/dark theme support via design tokens and CSS variables
- **Read-Only Mode** — Disable editing for view-only embedding
- **Custom Rendering** — Replace the default header and node palette with your own components via render props
- **Imperative API** — Programmatic control via ref (`getFlow`, `validate`, `fitView`, `clear`, `addNode`, `toJSON`)
- **Request Interceptor** — Customize outgoing API requests (headers, credentials) via an Axios interceptor callback
- **Keyboard Shortcuts** — Cmd/Ctrl+S to save
## Installation
```bash
pnpm add @flowiseai/agentflow
```
**Peer Dependencies:**
```bash
pnpm add react react-dom @mui/material @mui/icons-material @emotion/react @emotion/styled reactflow
```
## Basic Usage
```tsx
import { Agentflow } from '@flowiseai/agentflow'
import '@flowiseai/agentflow/flowise.css'
export default function App() {
return (
<div style={{ width: '100vw', height: '100vh' }}>
<Agentflow apiBaseUrl='http://localhost:3000' token='your-api-key' />
</div>
)
}
```
### With Initial Flow Data and Callbacks
```tsx
import { useRef } from 'react'
import { Agentflow, type AgentFlowInstance, type FlowData } from '@flowiseai/agentflow'
import '@flowiseai/agentflow/flowise.css'
export default function App() {
const ref = useRef<AgentFlowInstance>(null)
const initialFlow: FlowData = {
nodes: [
{
id: 'startAgentflow_0',
type: 'agentflowNode',
position: { x: 100, y: 100 },
data: {
id: 'startAgentflow_0',
name: 'startAgentflow',
label: 'Start',
color: '#7EE787',
hideInput: true,
outputAnchors: [{ id: 'startAgentflow_0-output-0', name: 'start', label: 'Start', type: 'start' }]
}
}
],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 }
}
return (
<div style={{ width: '100vw', height: '100vh' }}>
<Agentflow
ref={ref}
apiBaseUrl='http://localhost:3000'
token='your-api-key'
initialFlow={initialFlow}
onFlowChange={(flow) => console.log('Flow changed:', flow)}
onSave={(flow) => console.log('Flow saved:', flow)}
/>
</div>
)
}
```
### More Examples
The [examples app](./examples/README.md) includes working demos for:
- **Variable usage** — `{{variable}}` syntax, variable picker, available sources
- **Async options** — Loading models, tools, and credentials from the API
- **Status indicators** — Node execution states (running, finished, error, stopped)
- **Field visibility** — Show/hide conditions on node inputs
- **State management** — Dirty tracking, flow change callbacks
- **Dark mode** — Light/dark theme toggle
- **Custom UI** — Custom header and node palette via render props
- **Filtered components** — Restricting available node types with presets
- **Validation actions** — Validation button, error display, and custom `canvasActions` alongside built-in controls
Run `cd examples && npm install && npm run dev` to try them locally.
## Props
<!-- prettier-ignore -->
| Prop | Type | Default | Description |
| -------------------- | ------------------------------------------ | -------------- | --------------------------------------------------------------- |
| `apiBaseUrl` | `string` | **(required)** | Flowise API server endpoint |
| `token` | `string` | — | Authentication token for API calls |
| `requestInterceptor` | `(config: InternalAxiosRequestConfig) => InternalAxiosRequestConfig` | — | Customize outgoing API requests (e.g., set `withCredentials`, add headers). The callback receives the full Axios request config — only modify what you need. See [Security: requestInterceptor](#security-requestinterceptor) below. |
| `initialFlow` | `FlowData` | — | Initial flow data to render (uncontrolled — only used on mount) |
| `flowId` | `string` | — | Flow identifier (reserved for future use) |
| `components` | `string[]` | — | Restrict which node types appear in the palette |
| `onFlowChange` | `(flow: FlowData) => void` | — | Called when the flow changes (node/edge add, remove, move) |
| `onSave` | `(flow: FlowData) => void` | — | Called when the user triggers a save |
| `onFlowGenerated` | `(flow: FlowData) => void` | — | Called when a flow is generated via AI |
| `isDarkMode` | `boolean` | `false` | Use dark mode theme |
| `readOnly` | `boolean` | `false` | Disable editing (nodes not draggable/connectable) |
| `showDefaultHeader` | `boolean` | `true` | Show built-in header (ignored if `renderHeader` provided) |
| `showDefaultPalette` | `boolean` | `true` | Show built-in node palette |
| `enableGenerator` | `boolean` | `true` | Show the AI flow generator button |
| `canvasActions` | `ReactNode` | — | Additional content rendered in the top-right canvas overlay, to the right of the built-in validation FAB. Hidden when `readOnly` is true. |
| `renderHeader` | `(props: HeaderRenderProps) => ReactNode` | — | Custom header renderer |
| `renderNodePalette` | `(props: PaletteRenderProps) => ReactNode` | — | Custom node palette renderer |
### Imperative Methods (via `ref`)
| Method | Return Type | Description |
| ------------------------ | ------------------------- | ------------------------------------- |
| `getFlow()` | `FlowData` | Get current flow data |
| `toJSON()` | `string` | Export flow as JSON string |
| `validate()` | `ValidationResult` | Validate the current flow |
| `fitView()` | `void` | Fit all nodes into view |
| `clear()` | `void` | Remove all nodes and edges |
| `addNode(nodeData)` | `void` | Add a node (`Partial<FlowNode>`) |
| `getReactFlowInstance()` | `ReactFlowInstance\|null` | Get the underlying ReactFlow instance |
### Security: `requestInterceptor`
The `requestInterceptor` callback runs inside the Axios request pipeline and has access to the full request configuration, including authentication headers. This is the same trust model as any other callback prop (e.g., `onSave`, `renderHeader`) — the host application developer supplies the function and is responsible for its behavior.
**Guidelines for consumers:**
- Only pass **trusted, developer-authored** functions. Never use dynamically evaluated code (`eval`, `new Function`, etc.) or user-generated input as the interceptor.
- Follow the **principle of least privilege** — only read or modify the specific config properties you need (e.g., `withCredentials`, custom headers).
- If the interceptor throws, the error is caught, logged, and the **original unmodified config** is used so the request still proceeds safely.
### Node Types
The following node types are available in the palette by default. Use the `components` prop to restrict which types are shown.
<!-- prettier-ignore -->
| Node Type | Description |
| -------------------------- | ------------------------------------ |
| `startAgentflow` | Entry point (required, always shown) |
| `agentAgentflow` | AI agent execution |
| `llmAgentflow` | LLM / language model call |
| `conditionAgentflow` | Conditional branching |
| `conditionAgentAgentflow` | Agent-level conditional branching |
| `directReplyAgentflow` | Direct response to user |
| `customFunctionAgentflow` | Custom JavaScript function |
| `toolAgentflow` | Tool integration |
| `retrieverAgentflow` | Data retrieval |
| `stickyNoteAgentflow` | Canvas annotation (not connectable) |
| `httpAgentflow` | HTTP request |
| `iterationAgentflow` | Iteration / map-reduce container |
| `executeFlowAgentflow` | Execute a sub-flow |
### Design Note
`<Agentflow>` is an **uncontrolled component**. The `initialFlow` prop seeds the canvas state on mount, but the component owns its own state afterward. Use the `ref` for imperative access and `onFlowChange` to observe changes.
## Development
```bash
# Install dependencies
pnpm install
# Build the package
pnpm build
# Run tests
pnpm test
# Run examples
cd examples && pnpm install && pnpm dev
```
Visit the [examples](./examples) directory for more usage patterns. See [TESTS.md](./TESTS.md) for the full test plan and coverage status.
## Troubleshooting
### API Connection Issues
- **CORS errors** — Ensure the Flowise server allows requests from your app's origin. Check the Flowise CORS configuration.
- **401 Unauthorized** — Use an API Key (Settings > API Keys), not a JWT user token. Verify the key is passed via the `token` prop.
- **Wrong `apiBaseUrl`** — The URL must point to the Flowise API root (e.g., `http://localhost:3000`), not a subpath.
### Validation Errors
- **"Flow is empty"** — Add at least one node to the canvas.
- **"Missing start node"** — Every flow requires a `startAgentflow` node. Add one from the palette.
- **"Disconnected nodes"** — All non-sticky-note nodes must be reachable from the start node. Connect any orphaned nodes.
- **"Cycle detected"** — Flows must be acyclic (DAGs). Remove the edge that creates the cycle.
### Theme Issues
- **Dark mode not applying** — Pass `isDarkMode={true}` as a prop. The component uses its own design tokens and does not inherit from the host app's theme.
- **Style conflicts** — Ensure `@flowiseai/agentflow/flowise.css` is imported. The component's CSS variables are scoped to avoid collisions.
### Variables Not Resolving
- **Variable not in picker** — Variables are sourced from upstream nodes. Ensure the source node is connected and upstream of the current node.
- **Incorrect path** — Variable syntax is `{{nodeName.outputKey}}`. Check that the node name and output key match exactly.
### Async Options Not Loading
- **Empty dropdowns for models/tools/credentials** — These load from the Flowise API. Verify `apiBaseUrl` and `token` are correct and the server is running.
- **Network errors in console** — Check browser DevTools for failed requests. The API client logs errors to the console.
## Documentation
- [ARCHITECTURE.md](./ARCHITECTURE.md) - Internal architecture and design patterns
- [TESTS.md](./TESTS.md) - Test plan, coverage tiers, and configuration
- [Examples](./examples/README.md) - Usage examples and demos
## Contributing
This package follows a feature-based architecture with clear separation of concerns. See [ARCHITECTURE.md](./ARCHITECTURE.md) for details on the project structure and development guidelines.
## License
Apache-2.0 — see the repository root [LICENSE.md](https://github.com/FlowiseAI/Flowise/blob/main/LICENSE.md) for details.
---
Part of the [Flowise](https://github.com/FlowiseAI/Flowise) ecosystem
# @flowiseai/agentflow
[![Version](https://img.shields.io/npm/v/@flowiseai/agentflow)](https://www.npmjs.com/package/@flowiseai/agentflow)
[![License](https://img.shields.io/badge/license-Apache--2.0-blue)](https://github.com/FlowiseAI/Flowise/blob/main/LICENSE.md)
> Embeddable React component for building and visualizing AI agent workflows
## Status: Dev
**Current version: `0.0.0-dev.13`**
This package is functional and has comprehensive test coverage, but the public API may still change before a stable release. It is suitable for early integration and testing but not yet recommended for production use.
**What works today:**
- 13 node types with full editing support (Loop and Human Input nodes are present in the palette but not yet fully verified end-to-end)
- Connection validation, flow validation, and export
- Async option loading (models, credentials, tools)
- Variable picker and field visibility conditions
- AI flow generation from natural language
- Dark/light mode theming
- Read-only mode and custom rendering via render props
## Overview
`@flowiseai/agentflow` is a React-based flow editor for creating AI agent workflows. It provides a visual canvas built on ReactFlow for connecting AI agents, LLMs, tools, and logic nodes.
## Features
- **Visual Canvas** — Drag-and-drop flow editor built on ReactFlow with zoom, pan, minimap, and fit-to-view controls
- **13 Built-in Node Types** — Start, Agent, LLM, Condition, Condition Agent, Direct Reply, Custom Function, Tool, Retriever, Sticky Note, HTTP, Iteration, and Execute Flow
- **Node Editor Dialog** — Modal for editing node parameters with dynamic input types (text, number, boolean, dropdown, arrays, JSON, code, variable selector, async options)
- **Credential Management** — Create and edit credentials inline from the node editor
- **Rich Text Editor** — TipTap-based editor with syntax highlighting for JavaScript, TypeScript, Python, and JSON (lazy-loaded)
- **Specialized Input Components** — Condition builder, messages input (role + content), and structured output schema builder
- **AI Flow Generator** — Generate flows from natural language descriptions with model selection
- **Flow Validation** — Detects empty flows, missing start nodes, disconnected nodes, cycles, hanging edges, and per-node input errors with visual feedback
- **Dark Mode** — Full light/dark theme support via design tokens and CSS variables
- **Read-Only Mode** — Disable editing for view-only embedding
- **Custom Rendering** — Replace the default header and node palette with your own components via render props
- **Imperative API** — Programmatic control via ref (`getFlow`, `validate`, `fitView`, `clear`, `addNode`, `toJSON`)
- **Request Interceptor** — Customize outgoing API requests (headers, credentials) via an Axios interceptor callback
- **Keyboard Shortcuts** — Cmd/Ctrl+S to save
## Installation
```bash
pnpm add @flowiseai/agentflow
```
**Peer Dependencies:**
```bash
pnpm add react react-dom @mui/material @mui/icons-material @emotion/react @emotion/styled reactflow
```
## Basic Usage
```tsx
import { Agentflow } from '@flowiseai/agentflow'
import '@flowiseai/agentflow/flowise.css'
export default function App() {
return (
<div style={{ width: '100vw', height: '100vh' }}>
<Agentflow apiBaseUrl='http://localhost:3000' token='your-api-key' />
</div>
)
}␍
```
### With Initial Flow Data and Callbacks
```tsx
import { useRef } from 'react'
import { Agentflow, type AgentFlowInstance, type FlowData } from '@flowiseai/agentflow'
import '@flowiseai/agentflow/flowise.css'
export default function App() {
const ref = useRef<AgentFlowInstance>(null)
const initialFlow: FlowData = {
nodes: [
{
id: 'startAgentflow_0',
type: 'agentflowNode',
position: { x: 100, y: 100 },
data: {
id: 'startAgentflow_0',
name: 'startAgentflow',
label: 'Start',
color: '#7EE787',
hideInput: true,
outputAnchors: [{ id: 'startAgentflow_0-output-0', name: 'start', label: 'Start', type: 'start' }]
}
}
],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 }
}
return (
<div style={{ width: '100vw', height: '100vh' }}>
<Agentflow
ref={ref}
apiBaseUrl='http://localhost:3000'
token='your-api-key'
initialFlow={initialFlow}
onFlowChange={(flow) => console.log('Flow changed:', flow)}
onSave={(flow) => console.log('Flow saved:', flow)}
/>
</div>
)
}␍
```
### More Examples
The [examples app](./examples/README.md) includes working demos for:
- **Variable usage** — `{{variable}}` syntax, variable picker, available sources
- **Async options** — Loading models, tools, and credentials from the API
- **Status indicators** — Node execution states (running, finished, error, stopped)
- **Field visibility** — Show/hide conditions on node inputs
- **State management** — Dirty tracking, flow change callbacks
- **Dark mode** — Light/dark theme toggle
- **Custom UI** — Custom header and node palette via render props
- **Filtered components** — Restricting available node types with presets
- **Validation actions** — Validation button, error display, and custom `canvasActions` alongside built-in controls
Run `cd examples && npm install && npm run dev` to try them locally.
## Props
<!-- prettier-ignore -->
| Prop | Type | Default | Description |
| -------------------- | ------------------------------------------ | -------------- | --------------------------------------------------------------- |
| `apiBaseUrl` | `string` | **(required)** | Flowise API server endpoint |
| `token` | `string` | — | Authentication token for API calls |
| `requestInterceptor` | `(config: InternalAxiosRequestConfig) => InternalAxiosRequestConfig` | — | Customize outgoing API requests (e.g., set `withCredentials`, add headers). The callback receives the full Axios request config — only modify what you need. See [Security: requestInterceptor](#security-requestinterceptor) below. |
| `initialFlow` | `FlowData` | — | Initial flow data to render (uncontrolled — only used on mount) |
| `flowId` | `string` | — | Flow identifier (reserved for future use) |
| `components` | `string[]` | — | Restrict which node types appear in the palette |
| `onFlowChange` | `(flow: FlowData) => void` | — | Called when the flow changes (node/edge add, remove, move) |
| `onSave` | `(flow: FlowData) => void` | — | Called when the user triggers a save |
| `onFlowGenerated` | `(flow: FlowData) => void` | — | Called when a flow is generated via AI |
| `isDarkMode` | `boolean` | `false` | Use dark mode theme |
| `readOnly` | `boolean` | `false` | Disable editing (nodes not draggable/connectable) |
| `showDefaultHeader` | `boolean` | `true` | Show built-in header (ignored if `renderHeader` provided) |
| `showDefaultPalette` | `boolean` | `true` | Show built-in node palette |
| `enableGenerator` | `boolean` | `true` | Show the AI flow generator button |
| `canvasActions` | `ReactNode` | — | Additional content rendered in the top-right canvas overlay, to the right of the built-in validation FAB. Hidden when `readOnly` is true. |
| `renderHeader` | `(props: HeaderRenderProps) => ReactNode` | — | Custom header renderer |
| `renderNodePalette` | `(props: PaletteRenderProps) => ReactNode` | — | Custom node palette renderer |
### Imperative Methods (via `ref`)
| Method | Return Type | Description |
| ------------------------ | ------------------------- | ------------------------------------- |
| `getFlow()` | `FlowData` | Get current flow data |
| `toJSON()` | `string` | Export flow as JSON string |
| `validate()` | `ValidationResult` | Validate the current flow |
| `fitView()` | `void` | Fit all nodes into view |
| `clear()` | `void` | Remove all nodes and edges |
| `addNode(nodeData)` | `void` | Add a node (`Partial<FlowNode>`) |
| `getReactFlowInstance()` | `ReactFlowInstance\|null` | Get the underlying ReactFlow instance |
### Security: `requestInterceptor`
The `requestInterceptor` callback runs inside the Axios request pipeline and has access to the full request configuration, including authentication headers. This is the same trust model as any other callback prop (e.g., `onSave`, `renderHeader`) — the host application developer supplies the function and is responsible for its behavior.
**Guidelines for consumers:**
- Only pass **trusted, developer-authored** functions. Never use dynamically evaluated code (`eval`, `new Function`, etc.) or user-generated input as the interceptor.
- Follow the **principle of least privilege** — only read or modify the specific config properties you need (e.g., `withCredentials`, custom headers).
- If the interceptor throws, the error is caught, logged, and the **original unmodified config** is used so the request still proceeds safely.
### Node Types
The following node types are available in the palette by default. Use the `components` prop to restrict which types are shown.
<!-- prettier-ignore -->
| Node Type | Description |
| -------------------------- | ------------------------------------ |
| `startAgentflow` | Entry point (required, always shown) |
| `agentAgentflow` | AI agent execution |
| `llmAgentflow` | LLM / language model call |
| `conditionAgentflow` | Conditional branching |
| `conditionAgentAgentflow` | Agent-level conditional branching |
| `directReplyAgentflow` | Direct response to user |
| `customFunctionAgentflow` | Custom JavaScript function |
| `toolAgentflow` | Tool integration |
| `retrieverAgentflow` | Data retrieval |
| `stickyNoteAgentflow` | Canvas annotation (not connectable) |
| `httpAgentflow` | HTTP request |
| `iterationAgentflow` | Iteration / map-reduce container |
| `executeFlowAgentflow` | Execute a sub-flow |
### Design Note
`<Agentflow>` is an **uncontrolled component**. The `initialFlow` prop seeds the canvas state on mount, but the component owns its own state afterward. Use the `ref` for imperative access and `onFlowChange` to observe changes.
## Development
```bash
# Install dependencies
pnpm install
# Build the package
pnpm build
# Run tests
pnpm test
# Run examples
cd examples && pnpm install && pnpm dev
```
Visit the [examples](./examples) directory for more usage patterns. See [TESTS.md](./TESTS.md) for the full test plan and coverage status.
## Troubleshooting
### API Connection Issues
- **CORS errors** — Ensure the Flowise server allows requests from your app's origin. Check the Flowise CORS configuration.
- **401 Unauthorized** — Use an API Key (Settings > API Keys), not a JWT user token. Verify the key is passed via the `token` prop.
- **Wrong `apiBaseUrl`** — The URL must point to the Flowise API root (e.g., `http://localhost:3000`), not a subpath.
### Validation Errors
- **"Flow is empty"** — Add at least one node to the canvas.
- **"Missing start node"** — Every flow requires a `startAgentflow` node. Add one from the palette.
- **"Disconnected nodes"** — All non-sticky-note nodes must be reachable from the start node. Connect any orphaned nodes.
- **"Cycle detected"** — Flows must be acyclic (DAGs). Remove the edge that creates the cycle.
### Theme Issues
- **Dark mode not applying** — Pass `isDarkMode={true}` as a prop. The component uses its own design tokens and does not inherit from the host app's theme.
- **Style conflicts** — Ensure `@flowiseai/agentflow/flowise.css` is imported. The component's CSS variables are scoped to avoid collisions.
### Variables Not Resolving
- **Variable not in picker** — Variables are sourced from upstream nodes. Ensure the source node is connected and upstream of the current node.
- **Incorrect path** — Variable syntax is `{{nodeName.outputKey}}`. Check that the node name and output key match exactly.
### Async Options Not Loading
- **Empty dropdowns for models/tools/credentials** — These load from the Flowise API. Verify `apiBaseUrl` and `token` are correct and the server is running.
- **Network errors in console** — Check browser DevTools for failed requests. The API client logs errors to the console.
## Documentation
- [ARCHITECTURE.md](./ARCHITECTURE.md) - Internal architecture and design patterns
- [TESTS.md](./TESTS.md) - Test plan, coverage tiers, and configuration
- [Examples](./examples/README.md) - Usage examples and demos
## Contributing
This package follows a feature-based architecture with clear separation of concerns. See [ARCHITECTURE.md](./ARCHITECTURE.md) for details on the project structure and development guidelines.
## License
Apache-2.0 — see the repository root [LICENSE.md](https://github.com/FlowiseAI/Flowise/blob/main/LICENSE.md) for details.
---
Part of the [Flowise](https://github.com/FlowiseAI/Flowise) ecosystem
@@ -6,7 +6,7 @@ import { useApiContext, useConfigContext } from '@/infrastructure/store'
interface ModelConfig {
model?: string
config?: { modelName?: string; model?: string }
config?: { modelName?: string; model?: string; customModel?: string }
}
export interface NodeModelConfigsProps {
@@ -54,7 +54,11 @@ function NodeModelConfigsComponent({ inputs }: NodeModelConfigsProps) {
src={`${apiBaseUrl}/api/v1/node-icon/${item.model}`}
alt={item.model as string}
/>
<Typography sx={{ fontSize: '0.7rem', ml: 0.5 }}>{item.config?.modelName || item.config?.model}</Typography>
<Typography sx={{ fontSize: '0.7rem', ml: 0.5 }}>
{item.config?.customModel
? item.config.customModel.replace(/^arn:aws:bedrock:[^:]+:[^:]+:/, '')
: item.config?.modelName || item.config?.model}
</Typography>
</Box>
</Box>
))}
@@ -66,7 +66,7 @@ export class EvaluationRunTracer extends RunCollectorCallbackHandler {
onLLMEnd?(run: Run): void | Promise<void> {
if (run.name) {
let provider = run.name
if (provider === 'BedrockChat') {
if (provider === 'BedrockChat' || provider === 'bedrock-imported') {
provider = 'awsChatBedrock'
}
EvaluationRunner.addMetrics(
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,111 @@
import * as fs from 'fs'
import * as path from 'path'
describe('AWS Bedrock model catalog (models.json)', () => {
let bedrockChat: any
beforeAll(() => {
const modelsPath = path.join(__dirname, '..', '..', '..', 'models.json')
const raw = JSON.parse(fs.readFileSync(modelsPath, 'utf8'))
bedrockChat = raw.chat.find((c: any) => c.name === 'awsChatBedrock')
})
it('awsChatBedrock section exists', () => {
expect(bedrockChat).toBeDefined()
})
it('has a non-empty models array', () => {
expect(bedrockChat.models.length).toBeGreaterThan(0)
})
it('has a non-empty regions array', () => {
expect(bedrockChat.regions.length).toBeGreaterThan(0)
})
it('every model has label and name', () => {
for (const m of bedrockChat.models) {
expect(m.label).toBeTruthy()
expect(m.name).toBeTruthy()
}
})
it('does not contain TwelveLabs Pegasus (not a chat model)', () => {
const pegasus = bedrockChat.models.find((m: any) => m.name.includes('twelvelabs.pegasus'))
expect(pegasus).toBeUndefined()
})
it('contains models added from AWS API', () => {
const names = bedrockChat.models.map((m: any) => m.name)
expect(names).toContain('minimax.minimax-m2.5')
expect(names).toContain('nvidia.nemotron-super-3-120b')
expect(names).toContain('zai.glm-5')
expect(names).toContain('writer.palmyra-vision-7b')
})
it('legacy models are present with (Legacy) label', () => {
const legacyModels = bedrockChat.models.filter((m: any) => m.label.includes('(Legacy)'))
expect(legacyModels.length).toBeGreaterThan(0)
const legacyNames = legacyModels.map((m: any) => m.name)
expect(legacyNames).toContain('anthropic.claude-3-haiku-20240307-v1:0')
expect(legacyNames).toContain('meta.llama3-2-1b-instruct-v1:0')
expect(legacyNames).toContain('cohere.command-r-v1:0')
for (const m of legacyModels) {
expect(m.label).toContain('(Legacy)')
expect(m.description).toMatch(/Legacy|deprecated/i)
}
})
it('does not contain stale/wrong model IDs', () => {
const names = bedrockChat.models.map((m: any) => m.name)
expect(names).not.toContain('deepseek.v3-v1:0')
expect(names).not.toContain('meta.llama3-1-405b-instruct-v1:0')
expect(names).not.toContain('mistral.mistral-large-2407-v1:0')
expect(names).not.toContain('nvidia.nemotron-3-super-120b-a12b')
expect(names).not.toContain('qwen.qwen3-235b-a22b-2507-v1:0')
expect(names).not.toContain('qwen.qwen3-coder-480b-a35b-v1:0')
})
it('contains Claude 4.6 Sonnet (auto-converted via code)', () => {
const claude46 = bedrockChat.models.find((m: any) => m.name === 'anthropic.claude-sonnet-4-6')
expect(claude46).toBeDefined()
})
it('model names do not have leading/trailing whitespace', () => {
for (const m of bedrockChat.models) {
expect(m.name).toBe(m.name.trim())
}
})
it('marks correct models with stop_sequences: false', () => {
const noStopSeq = bedrockChat.models.filter((m: any) => m.stop_sequences === false).map((m: any) => m.name)
expect(noStopSeq).toContain('deepseek.v3.2')
expect(noStopSeq).toContain('deepseek.r1-v1:0')
expect(noStopSeq).toContain('openai.gpt-oss-20b-1:0')
expect(noStopSeq).toContain('openai.gpt-oss-120b-1:0')
expect(noStopSeq).toContain('openai.gpt-oss-safeguard-20b')
expect(noStopSeq).toContain('openai.gpt-oss-safeguard-120b')
expect(noStopSeq).toHaveLength(6)
})
it('does not mark other models with stop_sequences: false', () => {
const withStopSeq = bedrockChat.models.filter((m: any) => m.stop_sequences === false)
const names = withStopSeq.map((m: any) => m.name)
expect(names).not.toContain('anthropic.claude-sonnet-4-6')
expect(names).not.toContain('amazon.nova-pro-v1:0')
})
// --- Pricing ---
it('every model has input_cost and output_cost', () => {
for (const m of bedrockChat.models) {
expect(m.input_cost).toBeDefined()
expect(m.output_cost).toBeDefined()
expect(typeof m.input_cost).toBe('number')
expect(typeof m.output_cost).toBe('number')
expect(m.input_cost).toBeGreaterThan(0)
expect(m.output_cost).toBeGreaterThan(0)
}
})
})
@@ -0,0 +1,817 @@
import {
validateEndpointHost,
resolveBedrockModel,
normalizeBedrockError,
regionToGeoCandidates,
getStopSeqUnsupportedModels,
_resetInferenceProfileCache,
_resetStopSeqCache
} from './utils'
import { detectFormat } from './FlowiseAWSChatBedrockImported'
// Mock the model loader to return models with inference_profile_geos and stop_sequences
jest.mock('../../../src/modelLoader', () => ({
MODEL_TYPE: { CHAT: 'chat' },
getModels: jest
.fn()
.mockResolvedValue([
{ name: 'anthropic.claude-sonnet-4-6', inference_profile_geos: ['us', 'eu', 'jp', 'au', 'global'] },
{ name: 'anthropic.claude-opus-4-5-20251101-v1:0', inference_profile_geos: ['us', 'eu', 'global'] },
{ name: 'amazon.nova-2-lite-v1:0', inference_profile_geos: ['us', 'eu', 'jp', 'global'] },
{ name: 'amazon.nova-lite-v1:0', inference_profile_geos: ['us', 'eu', 'apac', 'ca'] },
{ name: 'amazon.nova-pro-v1:0', inference_profile_geos: ['us', 'eu', 'apac'] },
{ name: 'meta.llama4-scout-17b-instruct-v1:0', inference_profile_geos: ['us'] },
{ name: 'deepseek.v3.2', stop_sequences: false },
{ name: 'deepseek.r1-v1:0', inference_profile_geos: ['us'], stop_sequences: false },
{ name: 'openai.gpt-oss-20b-1:0', stop_sequences: false },
{ name: 'openai.gpt-oss-safeguard-20b', stop_sequences: false },
{ name: 'writer.palmyra-x5-v1:0', inference_profile_geos: ['us'] },
{ name: 'minimax.minimax-m2' },
{ name: 'nvidia.nemotron-nano-9b-v2' },
{ name: 'qwen.qwen3-32b-v1:0' },
{ name: 'google.gemma-3-4b-it' },
{ name: 'anthropic.claude-3-haiku-20240307-v1:0' }
])
}))
beforeEach(() => {
_resetInferenceProfileCache()
_resetStopSeqCache()
})
// ---------------------------------------------------------------------------
// validateEndpointHost
// ---------------------------------------------------------------------------
describe('validateEndpointHost', () => {
it('accepts a bare hostname', () => {
const result = validateEndpointHost('bedrock-runtime.us-east-1.amazonaws.com')
expect(result.hostname).toBe('bedrock-runtime.us-east-1.amazonaws.com')
expect(result.migratedArn).toBeUndefined()
})
it('accepts a hostname with port', () => {
const result = validateEndpointHost('bedrock-runtime.us-east-1.amazonaws.com:443')
expect(result.hostname).toBe('bedrock-runtime.us-east-1.amazonaws.com:443')
})
it('auto-migrates an ARN to migratedArn', () => {
const arn = 'arn:aws:bedrock:us-east-1:123456789012:provisioned-model/abc'
const result = validateEndpointHost(arn)
expect(result.hostname).toBeUndefined()
expect(result.migratedArn).toBe(arn)
})
it('extracts hostname from a URL with scheme', () => {
const result = validateEndpointHost('https://bedrock-runtime.us-east-1.amazonaws.com')
expect(result.hostname).toBe('bedrock-runtime.us-east-1.amazonaws.com')
expect(result.migratedArn).toBeUndefined()
})
it('extracts hostname before path separator', () => {
const result = validateEndpointHost('bedrock-runtime.us-east-1.amazonaws.com/model/invoke')
expect(result.hostname).toBe('bedrock-runtime.us-east-1.amazonaws.com')
})
})
// ---------------------------------------------------------------------------
// resolveBedrockModel
// ---------------------------------------------------------------------------
describe('regionToGeoCandidates', () => {
it('maps us- regions to [us]', () => expect(regionToGeoCandidates('us-east-1')).toEqual(['us']))
it('maps us-west-2 to [us]', () => expect(regionToGeoCandidates('us-west-2')).toEqual(['us']))
it('maps eu- regions to [eu]', () => expect(regionToGeoCandidates('eu-west-1')).toEqual(['eu']))
it('maps eu-central-1 to [eu]', () => expect(regionToGeoCandidates('eu-central-1')).toEqual(['eu']))
it('maps ap-southeast-1 to [apac]', () => expect(regionToGeoCandidates('ap-southeast-1')).toEqual(['apac']))
it('maps ap-northeast-1 (Tokyo) to [jp, apac]', () => expect(regionToGeoCandidates('ap-northeast-1')).toEqual(['jp', 'apac']))
it('maps ap-northeast-3 (Osaka) to [jp, apac]', () => expect(regionToGeoCandidates('ap-northeast-3')).toEqual(['jp', 'apac']))
it('maps ap-southeast-2 (Sydney) to [au, apac]', () => expect(regionToGeoCandidates('ap-southeast-2')).toEqual(['au', 'apac']))
it('maps ap-northeast-2 (Seoul) to [apac]', () => expect(regionToGeoCandidates('ap-northeast-2')).toEqual(['apac']))
it('maps ap-south-1 to [apac]', () => expect(regionToGeoCandidates('ap-south-1')).toEqual(['apac']))
it('maps ca-central-1 to [ca, us]', () => expect(regionToGeoCandidates('ca-central-1')).toEqual(['ca', 'us']))
it('maps sa-east-1 to [] (global fallback only)', () => expect(regionToGeoCandidates('sa-east-1')).toEqual([]))
it('defaults to [us] for unknown regions', () => expect(regionToGeoCandidates('me-south-1')).toEqual(['us']))
})
describe('resolveBedrockModel', () => {
const dropdown = 'anthropic.claude-3-haiku-20240307-v1:0'
// --- basic resolution ---
it('falls back to dropdown when customModel is empty (model not in map)', async () => {
const result = await resolveBedrockModel('', dropdown, 'us-east-1')
expect(result.modelId).toBe(dropdown)
expect(result.applicationInferenceProfile).toBeUndefined()
})
it('falls back to dropdown when customModel is undefined', async () => {
const result = await resolveBedrockModel(undefined, dropdown, 'us-east-1')
expect(result.modelId).toBe(dropdown)
expect(result.applicationInferenceProfile).toBeUndefined()
})
it('model not in map gets no profile', async () => {
const result = await resolveBedrockModel('google.gemma-3-4b-it', dropdown, 'us-east-1')
expect(result.modelId).toBe('google.gemma-3-4b-it')
expect(result.applicationInferenceProfile).toBeUndefined()
})
// --- explicit user-supplied profiles (passthrough) ---
it('detects explicit us. profile in customModel', async () => {
const result = await resolveBedrockModel('us.anthropic.claude-sonnet-4-6', dropdown, 'us-east-1')
expect(result.modelId).toBe('anthropic.claude-sonnet-4-6')
expect(result.applicationInferenceProfile).toBe('us.anthropic.claude-sonnet-4-6')
})
it('detects explicit eu. profile in customModel', async () => {
const result = await resolveBedrockModel('eu.anthropic.claude-sonnet-4-6', dropdown, 'eu-west-1')
expect(result.applicationInferenceProfile).toBe('eu.anthropic.claude-sonnet-4-6')
})
it('detects explicit apac. profile in customModel', async () => {
const result = await resolveBedrockModel('apac.amazon.nova-pro-v1:0', dropdown, 'ap-southeast-1')
expect(result.modelId).toBe('amazon.nova-pro-v1:0')
expect(result.applicationInferenceProfile).toBe('apac.amazon.nova-pro-v1:0')
})
it('detects explicit jp. profile in customModel', async () => {
const result = await resolveBedrockModel('jp.anthropic.claude-sonnet-4-6', dropdown, 'ap-northeast-1')
expect(result.modelId).toBe('anthropic.claude-sonnet-4-6')
expect(result.applicationInferenceProfile).toBe('jp.anthropic.claude-sonnet-4-6')
})
it('detects explicit au. profile in customModel', async () => {
const result = await resolveBedrockModel('au.anthropic.claude-sonnet-4-6', dropdown, 'ap-southeast-2')
expect(result.modelId).toBe('anthropic.claude-sonnet-4-6')
expect(result.applicationInferenceProfile).toBe('au.anthropic.claude-sonnet-4-6')
})
it('detects explicit ca. profile in customModel', async () => {
const result = await resolveBedrockModel('ca.amazon.nova-lite-v1:0', dropdown, 'ca-central-1')
expect(result.modelId).toBe('amazon.nova-lite-v1:0')
expect(result.applicationInferenceProfile).toBe('ca.amazon.nova-lite-v1:0')
})
it('detects explicit global. profile in customModel', async () => {
const result = await resolveBedrockModel('global.anthropic.claude-opus-4-5-20251101-v1:0', dropdown, 'us-east-1')
expect(result.modelId).toBe('anthropic.claude-opus-4-5-20251101-v1:0')
expect(result.applicationInferenceProfile).toBe('global.anthropic.claude-opus-4-5-20251101-v1:0')
})
it('detects a full ARN', async () => {
const arn = 'arn:aws:bedrock:us-east-1:123456789012:inference-profile/abc'
const result = await resolveBedrockModel(arn, dropdown, 'us-east-1')
expect(result.modelId).toBe(dropdown)
expect(result.applicationInferenceProfile).toBe(arn)
})
// --- auto-apply: US region ---
it('auto-applies us. profile in us-east-1', async () => {
const result = await resolveBedrockModel('', 'anthropic.claude-sonnet-4-6', 'us-east-1')
expect(result.applicationInferenceProfile).toBe('us.anthropic.claude-sonnet-4-6')
})
it('auto-applies us. for Nova in us-west-2', async () => {
const result = await resolveBedrockModel('', 'amazon.nova-pro-v1:0', 'us-west-2')
expect(result.applicationInferenceProfile).toBe('us.amazon.nova-pro-v1:0')
})
// --- auto-apply: EU region ---
it('auto-applies eu. profile when in eu-west-1 and model has eu', async () => {
const result = await resolveBedrockModel('', 'anthropic.claude-sonnet-4-6', 'eu-west-1')
expect(result.applicationInferenceProfile).toBe('eu.anthropic.claude-sonnet-4-6')
})
it('auto-applies eu. for Nova Pro in eu-central-1', async () => {
const result = await resolveBedrockModel('', 'amazon.nova-pro-v1:0', 'eu-central-1')
expect(result.applicationInferenceProfile).toBe('eu.amazon.nova-pro-v1:0')
})
// --- auto-apply: APAC region (ap-southeast-1 → apac) ---
it('auto-applies apac. profile when in ap-southeast-1 and model has apac', async () => {
const result = await resolveBedrockModel('', 'amazon.nova-pro-v1:0', 'ap-southeast-1')
expect(result.applicationInferenceProfile).toBe('apac.amazon.nova-pro-v1:0')
})
// --- auto-apply: Tokyo (ap-northeast-1 → jp preferred, apac fallback) ---
it('auto-applies jp. in ap-northeast-1 when model has jp', async () => {
// claude-sonnet-4-6 has ['us','eu','jp','global'] → jp matches first
const result = await resolveBedrockModel('', 'anthropic.claude-sonnet-4-6', 'ap-northeast-1')
expect(result.applicationInferenceProfile).toBe('jp.anthropic.claude-sonnet-4-6')
})
it('falls back to apac. in ap-northeast-1 when model has apac but not jp', async () => {
// nova-pro has ['us','eu','apac'] → jp not found, apac matches
const result = await resolveBedrockModel('', 'amazon.nova-pro-v1:0', 'ap-northeast-1')
expect(result.applicationInferenceProfile).toBe('apac.amazon.nova-pro-v1:0')
})
// --- auto-apply: Osaka (ap-northeast-3 → jp, same as Tokyo) ---
it('auto-applies jp. in ap-northeast-3 (Osaka) same as Tokyo', async () => {
const result = await resolveBedrockModel('', 'anthropic.claude-sonnet-4-6', 'ap-northeast-3')
expect(result.applicationInferenceProfile).toBe('jp.anthropic.claude-sonnet-4-6')
})
// --- auto-apply: Sydney (ap-southeast-2 → au preferred, apac fallback) ---
it('auto-applies au. in ap-southeast-2 when model has au', async () => {
const result = await resolveBedrockModel('', 'anthropic.claude-sonnet-4-6', 'ap-southeast-2')
expect(result.applicationInferenceProfile).toBe('au.anthropic.claude-sonnet-4-6')
})
it('falls back to apac. in ap-southeast-2 when model has apac but not au', async () => {
// nova-pro has ['us','eu','apac'] → au not found, apac matches
const result = await resolveBedrockModel('', 'amazon.nova-pro-v1:0', 'ap-southeast-2')
expect(result.applicationInferenceProfile).toBe('apac.amazon.nova-pro-v1:0')
})
// --- fallback chain ---
it('falls back to global. when preferred geo not available', async () => {
// claude-opus-4-5 has ['us','eu','global'] but NOT apac or jp
const result = await resolveBedrockModel('', 'anthropic.claude-opus-4-5-20251101-v1:0', 'ap-southeast-1')
expect(result.applicationInferenceProfile).toBe('global.anthropic.claude-opus-4-5-20251101-v1:0')
})
it('falls back to global. in ap-northeast-1 when model has neither jp nor apac', async () => {
// claude-opus-4-5 has ['us','eu','global'] → jp miss, apac miss, global matches
const result = await resolveBedrockModel('', 'anthropic.claude-opus-4-5-20251101-v1:0', 'ap-northeast-1')
expect(result.applicationInferenceProfile).toBe('global.anthropic.claude-opus-4-5-20251101-v1:0')
})
// --- South America (sa-east-1 → only global profiles available) ---
it('falls back to global. in sa-east-1 (no sa. profiles exist)', async () => {
// claude-sonnet-4-6 has ['us','eu','jp','global'] → no SA candidates, global matches
const result = await resolveBedrockModel('', 'anthropic.claude-sonnet-4-6', 'sa-east-1')
expect(result.applicationInferenceProfile).toBe('global.anthropic.claude-sonnet-4-6')
})
it('falls back to us. in sa-east-1 when model has no global', async () => {
// meta.llama4-scout has ['us'] only → no SA candidates, no global, us matches
const result = await resolveBedrockModel('', 'meta.llama4-scout-17b-instruct-v1:0', 'sa-east-1')
expect(result.applicationInferenceProfile).toBe('us.meta.llama4-scout-17b-instruct-v1:0')
})
// --- Canada (ca-central-1 → ca preferred, then us fallback) ---
it('auto-applies ca. in ca-central-1 when model has ca', async () => {
// nova-lite has ['us','eu','apac','ca'] → ca matches first
const result = await resolveBedrockModel('', 'amazon.nova-lite-v1:0', 'ca-central-1')
expect(result.applicationInferenceProfile).toBe('ca.amazon.nova-lite-v1:0')
})
it('falls back to us. in ca-central-1 when model has no ca', async () => {
// claude-sonnet-4-6 has ['us','eu','jp','global'] → ca miss, us matches
const result = await resolveBedrockModel('', 'anthropic.claude-sonnet-4-6', 'ca-central-1')
expect(result.applicationInferenceProfile).toBe('us.anthropic.claude-sonnet-4-6')
})
it('falls back to us. when neither preferred geo nor global available', async () => {
// meta.llama4-scout has ['us'] only
const result = await resolveBedrockModel('', 'meta.llama4-scout-17b-instruct-v1:0', 'eu-west-1')
expect(result.applicationInferenceProfile).toBe('us.meta.llama4-scout-17b-instruct-v1:0')
})
it('falls back to us. for deepseek in apac region (us only model)', async () => {
const result = await resolveBedrockModel('', 'deepseek.r1-v1:0', 'ap-northeast-1')
expect(result.applicationInferenceProfile).toBe('us.deepseek.r1-v1:0')
})
// --- no profile models ---
it('does NOT auto-apply profile for MiniMax', async () => {
const result = await resolveBedrockModel('', 'minimax.minimax-m2', 'us-east-1')
expect(result.applicationInferenceProfile).toBeUndefined()
})
it('does NOT auto-apply profile for NVIDIA', async () => {
const result = await resolveBedrockModel('', 'nvidia.nemotron-nano-9b-v2', 'us-east-1')
expect(result.applicationInferenceProfile).toBeUndefined()
})
it('does NOT auto-apply profile for Qwen', async () => {
const result = await resolveBedrockModel('', 'qwen.qwen3-32b-v1:0', 'us-east-1')
expect(result.applicationInferenceProfile).toBeUndefined()
})
// --- explicit profile overrides auto ---
it('does not override explicit profile with auto-mapping', async () => {
const result = await resolveBedrockModel('eu.anthropic.claude-sonnet-4-6', 'anthropic.claude-sonnet-4-6', 'us-east-1')
expect(result.applicationInferenceProfile).toBe('eu.anthropic.claude-sonnet-4-6')
})
// --- misc ---
it('trims whitespace from customModel', async () => {
const result = await resolveBedrockModel(' amazon.nova-pro-v1:0 ', dropdown, 'us-east-1')
expect(result.modelId).toBe('amazon.nova-pro-v1:0')
})
it('defaults to us-east-1 when region not provided', async () => {
const result = await resolveBedrockModel('', 'anthropic.claude-sonnet-4-6')
expect(result.applicationInferenceProfile).toBe('us.anthropic.claude-sonnet-4-6')
})
// --- model loader failure gracefully degrades ---
it('returns no profile when model loader fails', async () => {
const { getModels } = require('../../../src/modelLoader')
getModels.mockRejectedValueOnce(new Error('Network error'))
_resetInferenceProfileCache()
const result = await resolveBedrockModel('', 'anthropic.claude-sonnet-4-6', 'us-east-1')
expect(result.modelId).toBe('anthropic.claude-sonnet-4-6')
expect(result.applicationInferenceProfile).toBeUndefined()
})
// --- runtime discovery: availableProfiles parameter ---
it('applies profile when confirmed available in region', async () => {
const available = new Set(['eu.anthropic.claude-sonnet-4-6', 'global.anthropic.claude-sonnet-4-6'])
const result = await resolveBedrockModel('', 'anthropic.claude-sonnet-4-6', 'eu-west-1', available)
expect(result.applicationInferenceProfile).toBe('eu.anthropic.claude-sonnet-4-6')
})
it('skips profile when NOT available in region, falls back to global', async () => {
// eu-west-2 has global but NOT eu.amazon.nova-pro
const available = new Set(['global.amazon.nova-2-lite-v1:0', 'eu.anthropic.claude-sonnet-4-6'])
const result = await resolveBedrockModel('', 'amazon.nova-pro-v1:0', 'eu-west-2', available)
// nova-pro has geos ['us','eu','apac'] — eu. not in available, no global for this model, try us.
// us.amazon.nova-pro not in available either → no profile (direct invocation)
expect(result.applicationInferenceProfile).toBeUndefined()
})
it('falls back to global. when eu. not available but global. is', async () => {
const available = new Set(['global.amazon.nova-2-lite-v1:0'])
const result = await resolveBedrockModel('', 'amazon.nova-2-lite-v1:0', 'eu-west-2', available)
expect(result.applicationInferenceProfile).toBe('global.amazon.nova-2-lite-v1:0')
})
it('still applies explicit profile from customModel regardless of availableProfiles', async () => {
const available = new Set<string>() // empty — nothing available
const result = await resolveBedrockModel('eu.anthropic.claude-sonnet-4-6', 'anthropic.claude-sonnet-4-6', 'eu-west-2', available)
// Explicit profile bypasses runtime check
expect(result.applicationInferenceProfile).toBe('eu.anthropic.claude-sonnet-4-6')
})
})
// ---------------------------------------------------------------------------
// useGlobalEndpoint override
// ---------------------------------------------------------------------------
describe('useGlobalEndpoint override', () => {
beforeEach(() => {
_resetInferenceProfileCache()
})
it('forces global.* when useGlobalEndpoint=true and model has global geo', async () => {
const result = await resolveBedrockModel('', 'anthropic.claude-sonnet-4-6', 'eu-west-1', undefined, true)
expect(result.applicationInferenceProfile).toBe('global.anthropic.claude-sonnet-4-6')
})
it('falls back to regional when useGlobalEndpoint=true but model has no global geo', async () => {
const result = await resolveBedrockModel('', 'amazon.nova-pro-v1:0', 'eu-west-1', undefined, true)
// nova-pro geos: us, eu, apac — no global. Should fall back to eu.
expect(result.applicationInferenceProfile).toBe('eu.amazon.nova-pro-v1:0')
})
it('uses normal regional selection when useGlobalEndpoint=false', async () => {
const result = await resolveBedrockModel('', 'anthropic.claude-sonnet-4-6', 'eu-west-1', undefined, false)
expect(result.applicationInferenceProfile).toBe('eu.anthropic.claude-sonnet-4-6')
})
it('uses normal regional selection when useGlobalEndpoint=undefined', async () => {
const result = await resolveBedrockModel('', 'anthropic.claude-sonnet-4-6', 'ap-northeast-1', undefined, undefined)
expect(result.applicationInferenceProfile).toBe('jp.anthropic.claude-sonnet-4-6')
})
it('global override with runtime discovery validates profile exists', async () => {
const available = new Set(['global.anthropic.claude-sonnet-4-6'])
const result = await resolveBedrockModel('', 'anthropic.claude-sonnet-4-6', 'eu-west-1', available, true)
expect(result.applicationInferenceProfile).toBe('global.anthropic.claude-sonnet-4-6')
})
it('global override with runtime discovery falls back when global not in region', async () => {
const available = new Set(['eu.anthropic.claude-sonnet-4-6']) // global not available
const result = await resolveBedrockModel('', 'anthropic.claude-sonnet-4-6', 'eu-west-1', available, true)
// global not in available set, allCandidateGeos is just ['global'], nothing matches
expect(result.applicationInferenceProfile).toBeUndefined()
})
it('does not affect models with no inference profiles', async () => {
const result = await resolveBedrockModel('', 'minimax.minimax-m2', 'us-east-1', undefined, true)
expect(result.applicationInferenceProfile).toBeUndefined()
})
})
// ---------------------------------------------------------------------------
// getStopSeqUnsupportedModels
// ---------------------------------------------------------------------------
describe('getStopSeqUnsupportedModels', () => {
it('returns models with stop_sequences: false', async () => {
const set = await getStopSeqUnsupportedModels()
expect(set.has('deepseek.v3.2')).toBe(true)
expect(set.has('deepseek.r1-v1:0')).toBe(true)
expect(set.has('openai.gpt-oss-20b-1:0')).toBe(true)
expect(set.has('openai.gpt-oss-safeguard-20b')).toBe(true)
})
it('does NOT include models without the flag', async () => {
const set = await getStopSeqUnsupportedModels()
expect(set.has('anthropic.claude-sonnet-4-6')).toBe(false)
expect(set.has('amazon.nova-pro-v1:0')).toBe(false)
expect(set.has('minimax.minimax-m2')).toBe(false)
})
})
// ---------------------------------------------------------------------------
// normalizeBedrockError
// ---------------------------------------------------------------------------
describe('normalizeBedrockError', () => {
it('rewrites inference-profile-required errors', () => {
const err = new Error(
"ValidationException: Invocation of model ID anthropic.claude-sonnet-4-6 with on-demand throughput isn't supported."
)
const result = normalizeBedrockError(err)
expect(result.message).toContain('inference profile')
expect(result.message).toContain('Model Name')
})
it('rewrites Converse-unsupported errors', () => {
const err = new Error("This model doesn't support the Converse operation")
const result = normalizeBedrockError(err)
expect(result.message).toContain('InvokeModel')
expect(result.message).toContain('Custom Model ARN')
})
it('rewrites unsupported-field errors', () => {
const err = new Error("This model doesn't support the stopSequences field")
const result = normalizeBedrockError(err)
expect(result.message).toContain('request parameter')
})
it('returns unrecognized errors unchanged', () => {
const err = new Error('Some other random error')
const result = normalizeBedrockError(err)
expect(result).toBe(err)
})
it('handles non-Error values', () => {
const result = normalizeBedrockError('string error' as any)
expect(result).toBe('string error')
})
})
// ---------------------------------------------------------------------------
// BedrockChat error normalization wiring
// ---------------------------------------------------------------------------
describe('BedrockChat error normalization wiring', () => {
let BedrockChat: any
beforeAll(async () => {
// Dynamic import to pick up the jest mock for modelLoader
const mod = await import('./FlowiseAWSChatBedrock')
BedrockChat = mod.BedrockChat
})
it('_generate normalizes inference-profile-required errors', async () => {
const chat = new BedrockChat('test-id', { model: 'anthropic.claude-sonnet-4-6', region: 'us-east-1' })
// Mock the parent _generate to throw a raw Bedrock error
const originalGenerate = Object.getPrototypeOf(Object.getPrototypeOf(chat))._generate
Object.getPrototypeOf(Object.getPrototypeOf(chat))._generate = jest
.fn()
.mockRejectedValue(
new Error(
"ValidationException: Invocation of model ID anthropic.claude-sonnet-4-6 with on-demand throughput isn't supported."
)
)
try {
await chat._generate([], {})
fail('Expected error to be thrown')
} catch (err: any) {
expect(err.message).toContain('cross-region inference profile')
expect(err.message).toContain('Model Name')
}
// Restore
Object.getPrototypeOf(Object.getPrototypeOf(chat))._generate = originalGenerate
})
it('_generate normalizes Converse-unsupported errors', async () => {
const chat = new BedrockChat('test-id', { model: 'custom-model', region: 'us-east-1' })
const originalGenerate = Object.getPrototypeOf(Object.getPrototypeOf(chat))._generate
Object.getPrototypeOf(Object.getPrototypeOf(chat))._generate = jest
.fn()
.mockRejectedValue(new Error("This model doesn't support the Converse operation"))
try {
await chat._generate([], {})
fail('Expected error to be thrown')
} catch (err: any) {
expect(err.message).toContain('InvokeModel')
expect(err.message).toContain('Custom Model ARN')
}
Object.getPrototypeOf(Object.getPrototypeOf(chat))._generate = originalGenerate
})
it('_generate passes through unrecognized errors unchanged', async () => {
const chat = new BedrockChat('test-id', { model: 'some-model', region: 'us-east-1' })
const rawError = new Error('Something completely unexpected')
const originalGenerate = Object.getPrototypeOf(Object.getPrototypeOf(chat))._generate
Object.getPrototypeOf(Object.getPrototypeOf(chat))._generate = jest.fn().mockRejectedValue(rawError)
try {
await chat._generate([], {})
fail('Expected error to be thrown')
} catch (err: any) {
expect(err).toBe(rawError)
}
Object.getPrototypeOf(Object.getPrototypeOf(chat))._generate = originalGenerate
})
it('_generate does not interfere with successful responses', async () => {
const chat = new BedrockChat('test-id', { model: 'some-model', region: 'us-east-1' })
const mockResult = { generations: [{ text: 'hello', message: { content: 'hello' } }] }
const originalGenerate = Object.getPrototypeOf(Object.getPrototypeOf(chat))._generate
Object.getPrototypeOf(Object.getPrototypeOf(chat))._generate = jest.fn().mockResolvedValue(mockResult)
const result = await chat._generate([], {})
expect(result).toBe(mockResult)
Object.getPrototypeOf(Object.getPrototypeOf(chat))._generate = originalGenerate
})
it('stopSeqUnsupported strips stopSequences for flagged models', () => {
const unsupported = new Set(['deepseek.r1-v1:0'])
const chat = new BedrockChat('test-id', { model: 'deepseek.r1-v1:0', region: 'us-east-1' }, unsupported)
// Mock super.invocationParams to return params with stopSequences
const originalInvocationParams = Object.getPrototypeOf(Object.getPrototypeOf(chat)).invocationParams
Object.getPrototypeOf(Object.getPrototypeOf(chat)).invocationParams = jest.fn().mockReturnValue({
inferenceConfig: { maxTokens: 200, stopSequences: ['\\n'] }
})
const params = chat.invocationParams({})
expect(params.inferenceConfig.stopSequences).toBeUndefined()
expect(params.inferenceConfig.maxTokens).toBe(200)
Object.getPrototypeOf(Object.getPrototypeOf(chat)).invocationParams = originalInvocationParams
})
it('stopSeqUnsupported keeps stopSequences for non-flagged models', () => {
const unsupported = new Set(['deepseek.r1-v1:0'])
const chat = new BedrockChat('test-id', { model: 'anthropic.claude-sonnet-4-6', region: 'us-east-1' }, unsupported)
const originalInvocationParams = Object.getPrototypeOf(Object.getPrototypeOf(chat)).invocationParams
Object.getPrototypeOf(Object.getPrototypeOf(chat)).invocationParams = jest.fn().mockReturnValue({
inferenceConfig: { maxTokens: 200, stopSequences: ['\\n'] }
})
const params = chat.invocationParams({})
expect(params.inferenceConfig.stopSequences).toEqual(['\\n'])
Object.getPrototypeOf(Object.getPrototypeOf(chat)).invocationParams = originalInvocationParams
})
})
// ---------------------------------------------------------------------------
// Imported model auto-detection — ARN pattern matching
// ---------------------------------------------------------------------------
describe('Imported model ARN detection', () => {
it('matches imported-model ARNs', () => {
expect('arn:aws:bedrock:us-east-1:123456789000:imported-model/s8s5k3tqgk1b'.includes(':imported-model/')).toBe(true)
})
it('does not match provisioned-model ARNs', () => {
expect('arn:aws:bedrock:us-east-1:123456789000:provisioned-model/my-pt'.includes(':imported-model/')).toBe(false)
})
it('does not match inference-profile ARNs', () => {
expect('arn:aws:bedrock:us-east-1:123456789000:inference-profile/abc'.includes(':imported-model/')).toBe(false)
})
it('does not match plain model IDs', () => {
expect('anthropic.claude-sonnet-4-6'.includes(':imported-model/')).toBe(false)
})
it('does not match geo-prefixed inference profile IDs', () => {
expect('us.anthropic.claude-sonnet-4-6'.includes(':imported-model/')).toBe(false)
})
it('does not match custom-model (fine-tuned) ARNs', () => {
expect('arn:aws:bedrock:us-east-1:123456789000:custom-model/my-ft'.includes(':imported-model/')).toBe(false)
})
})
// ---------------------------------------------------------------------------
// Imported model auto-detection — full init() routing
//
// These tests verify that the Converse node's init() correctly routes to
// BedrockImportedChat or BedrockChat depending on the model type.
// ---------------------------------------------------------------------------
describe('Converse node init() routing for custom models', () => {
// We can't easily call init() directly (it needs INodeData, options, etc.),
// so we test the routing logic extracted into a helper that mirrors init().
// This function replicates the decision tree in AWSChatBedrock.ts init().
function simulateInitRouting(params: {
customModel?: string
endpointHost?: string
instructSupported?: boolean
supportedFormats?: string[]
}): 'BedrockImportedChat' | 'BedrockChat' | 'ValidationError' {
let endpointMigratedArn: string | undefined
if (params.endpointHost) {
const result = validateEndpointHost(params.endpointHost)
endpointMigratedArn = result.migratedArn
}
const effectiveModel = endpointMigratedArn || params.customModel
if (effectiveModel && !effectiveModel.startsWith('arn:aws:bedrock:')) {
return 'ValidationError'
}
if (effectiveModel?.includes(':imported-model/')) {
if (params.instructSupported !== true) {
return 'BedrockImportedChat'
}
}
return 'BedrockChat'
}
// --- Imported model ARN in customModel ---
it('imported ARN + instructSupported:false → BedrockImportedChat', () => {
expect(
simulateInitRouting({
customModel: 'arn:aws:bedrock:us-east-1:123:imported-model/xyz',
instructSupported: false
})
).toBe('BedrockImportedChat')
})
it('imported ARN + instructSupported:undefined → BedrockImportedChat', () => {
expect(
simulateInitRouting({
customModel: 'arn:aws:bedrock:us-east-1:123:imported-model/xyz',
instructSupported: undefined
})
).toBe('BedrockImportedChat')
})
it('imported ARN + instructSupported:true → BedrockChat (Converse)', () => {
expect(
simulateInitRouting({
customModel: 'arn:aws:bedrock:us-east-1:123:imported-model/xyz',
instructSupported: true
})
).toBe('BedrockChat')
})
// --- Imported model ARN via endpointHost auto-migration ---
it('imported ARN in endpointHost + instructSupported:false → BedrockImportedChat', () => {
expect(
simulateInitRouting({
endpointHost: 'arn:aws:bedrock:us-east-1:123:imported-model/xyz',
instructSupported: false
})
).toBe('BedrockImportedChat')
})
it('imported ARN in endpointHost overrides customModel', () => {
expect(
simulateInitRouting({
customModel: 'anthropic.claude-sonnet-4-6',
endpointHost: 'arn:aws:bedrock:us-east-1:123:imported-model/xyz',
instructSupported: false
})
).toBe('BedrockImportedChat')
})
// --- Non-imported model types → always Converse ---
it('provisioned-model ARN → BedrockChat', () => {
expect(
simulateInitRouting({
customModel: 'arn:aws:bedrock:us-east-1:123:provisioned-model/my-pt'
})
).toBe('BedrockChat')
})
it('inference-profile ARN → BedrockChat', () => {
expect(
simulateInitRouting({
customModel: 'arn:aws:bedrock:us-east-1:123:inference-profile/abc'
})
).toBe('BedrockChat')
})
it('custom-model (fine-tuned) ARN → BedrockChat', () => {
expect(
simulateInitRouting({
customModel: 'arn:aws:bedrock:us-east-1:123:custom-model/my-ft'
})
).toBe('BedrockChat')
})
it('geo-prefixed inference profile in customModel → ValidationError (use dropdown)', () => {
expect(
simulateInitRouting({
customModel: 'us.anthropic.claude-sonnet-4-6'
})
).toBe('ValidationError')
})
it('plain model ID in customModel → ValidationError (use dropdown)', () => {
expect(
simulateInitRouting({
customModel: 'anthropic.claude-sonnet-4-6'
})
).toBe('ValidationError')
})
it('empty customModel → BedrockChat (dropdown fallback)', () => {
expect(
simulateInitRouting({
customModel: ''
})
).toBe('BedrockChat')
})
it('undefined customModel → BedrockChat', () => {
expect(
simulateInitRouting({
customModel: undefined
})
).toBe('BedrockChat')
})
it('bare hostname in endpointHost (not ARN) → BedrockChat', () => {
expect(
simulateInitRouting({
endpointHost: 'bedrock-runtime.us-east-1.amazonaws.com'
})
).toBe('BedrockChat')
})
// --- Input validation: reject model names that aren't ARNs ---
it('model name (not ARN) → ValidationError', () => {
expect(simulateInitRouting({ customModel: 'SantaCoder-1B' })).toBe('ValidationError')
})
it('model name with trailing dot → ValidationError', () => {
expect(simulateInitRouting({ customModel: 'jcvze99n15u4.' })).toBe('ValidationError')
})
it('bare hash ID → ValidationError', () => {
expect(simulateInitRouting({ customModel: 'jcvze99n15u4' })).toBe('ValidationError')
})
it('built-in model ID (use dropdown instead) → ValidationError', () => {
expect(simulateInitRouting({ customModel: 'anthropic.claude-sonnet-4-6' })).toBe('ValidationError')
})
it('geo-prefixed ID (auto-applied, not manual) → ValidationError', () => {
expect(simulateInitRouting({ customModel: 'us.anthropic.claude-sonnet-4-6' })).toBe('ValidationError')
})
it('random string → ValidationError', () => {
expect(simulateInitRouting({ customModel: 'My Custom Model' })).toBe('ValidationError')
})
it('valid imported-model ARN → accepted', () => {
expect(simulateInitRouting({ customModel: 'arn:aws:bedrock:us-east-1:123:imported-model/xyz', instructSupported: false })).toBe(
'BedrockImportedChat'
)
})
it('valid provisioned-model ARN → accepted', () => {
expect(simulateInitRouting({ customModel: 'arn:aws:bedrock:us-east-1:123:provisioned-model/xyz' })).toBe('BedrockChat')
})
})
// ---------------------------------------------------------------------------
// Format detection for imported models
// ---------------------------------------------------------------------------
describe('detectFormat for imported models', () => {
it('prefers openai-chat-completion when ChatCompletionRequest available', () => {
expect(detectFormat(['chatcompletionrequest', 'bedrockmetacompletionrequest'])).toBe('openai-chat-completion')
})
it('returns bedrock-completion when only prompt-based formats exist', () => {
expect(detectFormat(['completionrequest'])).toBe('bedrock-completion')
})
it('returns bedrock-completion when only BedrockCompletionRequest exists', () => {
expect(detectFormat(['bedrockcompletionrequest'])).toBe('bedrock-completion')
})
it('defaults to openai-chat-completion when no formats provided', () => {
expect(detectFormat(undefined)).toBe('openai-chat-completion')
})
it('defaults to openai-chat-completion for empty array', () => {
expect(detectFormat([])).toBe('openai-chat-completion')
})
})
@@ -5,6 +5,8 @@ import { getModels, getRegions, MODEL_TYPE } from '../../../src/modelLoader'
import { getAWSCredentialConfig } from '../../../src/awsToolsUtils'
import { ChatBedrockConverseInput, ChatBedrockConverse } from '@langchain/aws'
import { BedrockChat } from './FlowiseAWSChatBedrock'
import { validateEndpointHost, resolveBedrockModel, discoverInferenceProfiles, getStopSeqUnsupportedModels } from './utils'
import { BedrockImportedChat, getImportedModelInfo, detectFormat } from './FlowiseAWSChatBedrockImported'
import { supportsSamplingParams } from '../../../src/anthropicUtils'
class AWSChatBedrock_ChatModels implements INode {
@@ -26,7 +28,8 @@ class AWSChatBedrock_ChatModels implements INode {
this.type = 'AWSChatBedrock'
this.icon = 'aws.svg'
this.category = 'Chat Models'
this.description = 'Wrapper around AWS Bedrock large language models that use the Converse API'
this.description =
'Wrapper around AWS Bedrock large language models. Supports built-in, imported, fine-tuned, and provisioned-throughput models.'
this.baseClasses = [this.type, ...getBaseClasses(ChatBedrockConverse)]
this.credential = {
label: 'AWS Credential',
@@ -54,12 +57,19 @@ class AWSChatBedrock_ChatModels implements INode {
name: 'model',
type: 'asyncOptions',
loadMethod: 'listModels',
default: 'anthropic.claude-3-haiku-20240307-v1:0'
default: 'anthropic.claude-haiku-4-5-20251001-v1:0',
optional: true
},
{
label: 'Custom Model Name',
label: 'Custom Model ARN',
name: 'customModel',
description: 'If provided, will override model selected from Model Name option',
description:
'For imported, fine-tuned, or provisioned-throughput models. ' +
'Enter the full ARN. Imported models are auto-detected and routed to the correct API. ' +
'For fine-tuned models, use the deployment ARN (custom-model-deployment/... for on-demand, ' +
'or provisioned-model/... for Provisioned Throughput) — the raw custom-model/... artifact ARN is not invokable. ' +
'For built-in models, use the dropdown above instead.',
placeholder: 'arn:aws:bedrock:us-east-1:123456:imported-model/my-model',
type: 'string',
optional: true
},
@@ -67,8 +77,12 @@ class AWSChatBedrock_ChatModels implements INode {
label: 'Custom Endpoint Host',
name: 'endpointHost',
type: 'string',
description: 'Custom endpoint host to use for the model. If provided, will override the default endpoint host.',
optional: true
description:
'Hostname-only override for a custom VPC endpoint or proxy ' +
'(e.g. bedrock-runtime.us-east-1.amazonaws.com). ' +
'Do NOT enter model ARNs or inference profile IDs here.',
optional: true,
additionalParams: true
},
{
label: 'Streaming',
@@ -116,6 +130,17 @@ class AWSChatBedrock_ChatModels implements INode {
default: false,
optional: true,
additionalParams: true
},
{
label: 'Use Global Inference Endpoint',
name: 'useGlobalEndpoint',
type: 'boolean',
description:
'Force the global cross-region inference profile instead of the region-specific one. ' +
'Bedrock routes dynamically across regions for maximum availability.',
default: false,
optional: true,
additionalParams: true
}
]
}
@@ -130,6 +155,21 @@ class AWSChatBedrock_ChatModels implements INode {
}
}
/**
* Initializes a BedrockChat instance for the Converse API.
*
* Flow:
* 1. Sanitize endpointHost (auto-migrate ARNs placed there by mistake)
* 2. Resolve AWS credentials (UI creds, AssumeRole, or SDK default chain)
* 3. Discover inference profiles available in the target region (cached)
* 4. Resolve model ID + optional inference profile from user input
* 5. Build ChatBedrockConverseInput and return BedrockChat instance
*
* Credentials are resolved BEFORE model resolution so that
* discoverInferenceProfiles() can reuse the same creds.
*
* @see https://docs.aws.amazon.com/bedrock/latest/userguide/conversation-inference.html
*/
async init(nodeData: INodeData, _: string, options: ICommonObject): Promise<any> {
const iRegion = nodeData.inputs?.region as string
const iModel = nodeData.inputs?.model as string
@@ -139,30 +179,15 @@ class AWSChatBedrock_ChatModels implements INode {
const cache = nodeData.inputs?.cache as BaseCache
const streaming = nodeData.inputs?.streaming as boolean
const latencyOptimized = nodeData.inputs?.latencyOptimized as boolean
const useGlobalEndpoint = nodeData.inputs?.useGlobalEndpoint as boolean
const endpointHost = (nodeData.inputs?.endpointHost as string)?.trim()
const resolvedModel = customModel ? customModel : iModel
const obj: ChatBedrockConverseInput = {
region: iRegion,
model: resolvedModel,
maxTokens: parseInt(iMax_tokens_to_sample, 10),
streaming: streaming ?? true
}
// Newer Anthropic Claude models (Opus 4.7+) don't accept sampling
// parameters. AWS Bedrock surfaces those models with names like
// "anthropic.claude-opus-4-7-v1" or "us.anthropic.claude-opus-4-7-...".
if (supportsSamplingParams(resolvedModel)) {
obj.temperature = parseFloat(iTemperature)
}
if (latencyOptimized) {
obj.performanceConfig = { latency: 'optimized' }
}
let sanitizedEndpointHost: string | undefined
let endpointMigratedArn: string | undefined
if (endpointHost) {
obj.endpointHost = endpointHost
const result = validateEndpointHost(endpointHost)
sanitizedEndpointHost = result.hostname
endpointMigratedArn = result.migratedArn
}
/**
@@ -174,6 +199,84 @@ class AWSChatBedrock_ChatModels implements INode {
* @see https://github.com/aws/aws-sdk-js-v3/blob/main/packages/credential-provider-node/README.md
*/
const credentialConfig = await getAWSCredentialConfig(nodeData, options, iRegion)
const effectiveModel = endpointMigratedArn || customModel
if (effectiveModel && !effectiveModel.startsWith('arn:aws:bedrock:')) {
throw new Error(
`"${effectiveModel}" is not a valid ARN. ` +
`The "Custom Model ARN" field requires a full Bedrock ARN ` +
`(e.g. arn:aws:bedrock:us-east-1:123456789000:imported-model/my-model). ` +
`For built-in models, use the "Model Name" dropdown instead.`
)
}
// Auto-detect imported models and route to InvokeModel API.
if (effectiveModel?.includes(':imported-model/')) {
const modelInfo = await getImportedModelInfo(effectiveModel, iRegion, credentialConfig.credentials)
if (modelInfo.instructSupported !== true) {
const format = detectFormat(modelInfo.supportedFormats)
const chat = new BedrockImportedChat(nodeData.id, {
region: iRegion,
modelId: effectiveModel,
format,
temperature: !isNaN(parseFloat(iTemperature)) ? parseFloat(iTemperature) : 0.7,
maxTokens: !isNaN(parseInt(iMax_tokens_to_sample, 10)) ? parseInt(iMax_tokens_to_sample, 10) : 200,
streaming: streaming ?? true,
credentials: credentialConfig.credentials
})
const multiModalOption: IMultiModalOption = {
image: { allowImageUploads: (nodeData.inputs?.allowImageUploads as boolean) ?? false }
}
chat.setMultiModalOption(multiModalOption)
return chat
}
// instructSupported: true — fall through to Converse path
}
// Discover which inference profiles actually exist in this region.
// Uses the same credentials the user configured (UI, AssumeRole, or SDK default).
const availableProfiles = await discoverInferenceProfiles(iRegion, credentialConfig.credentials)
// Resolve model ID and optional inference profile from customModel input.
// If an ARN was auto-migrated from endpointHost, it takes precedence
// over customModel because the original flow was misconfigured --
// the ARN was the real model target, customModel was likely a
// friendly label (e.g. "nova-custom") that isn't a valid Bedrock ID.
const effectiveCustomModel = endpointMigratedArn || customModel
const { modelId, applicationInferenceProfile } = await resolveBedrockModel(
effectiveCustomModel,
iModel,
iRegion,
availableProfiles,
useGlobalEndpoint
)
const obj: ChatBedrockConverseInput = {
region: iRegion,
model: modelId,
maxTokens: !isNaN(parseInt(iMax_tokens_to_sample, 10)) ? parseInt(iMax_tokens_to_sample, 10) : 200,
streaming: streaming ?? true
}
// Newer Anthropic Claude models (Opus 4.7+) don't accept sampling
// parameters. AWS Bedrock surfaces those models with names like
// "anthropic.claude-opus-4-7-v1" or "us.anthropic.claude-opus-4-7-...".
if (supportsSamplingParams(modelId)) {
obj.temperature = !isNaN(parseFloat(iTemperature)) ? parseFloat(iTemperature) : 0.7
}
if (applicationInferenceProfile) {
obj.applicationInferenceProfile = applicationInferenceProfile
}
if (latencyOptimized) {
obj.performanceConfig = { latency: 'optimized' }
}
if (sanitizedEndpointHost) {
obj.endpointHost = sanitizedEndpointHost
}
if (credentialConfig.credentials) {
obj.credentials = credentialConfig.credentials
}
@@ -187,7 +290,8 @@ class AWSChatBedrock_ChatModels implements INode {
}
}
const amazonBedrock = new BedrockChat(nodeData.id, obj)
const stopSeqUnsupported = await getStopSeqUnsupportedModels()
const amazonBedrock = new BedrockChat(nodeData.id, obj, stopSeqUnsupported)
amazonBedrock.setMultiModalOption(multiModalOption)
return amazonBedrock
}
@@ -0,0 +1,455 @@
import { BedrockImportedChat, detectFormat, getImportedModelInfo, _resetModelInfoCache } from './FlowiseAWSChatBedrockImported'
import { HumanMessage, SystemMessage, AIMessage, ToolMessage } from '@langchain/core/messages'
// ---------------------------------------------------------------------------
// Mock AWS SDK clients
// ---------------------------------------------------------------------------
const mockSendRuntime = jest.fn()
jest.mock('@aws-sdk/client-bedrock-runtime', () => ({
BedrockRuntimeClient: jest.fn().mockImplementation(() => ({
send: mockSendRuntime
})),
InvokeModelCommand: jest.fn().mockImplementation((input) => ({ ...input, _type: 'InvokeModelCommand' })),
InvokeModelWithResponseStreamCommand: jest
.fn()
.mockImplementation((input) => ({ ...input, _type: 'InvokeModelWithResponseStreamCommand' }))
}))
const mockSendBedrock = jest.fn()
jest.mock('@aws-sdk/client-bedrock', () => ({
BedrockClient: jest.fn().mockImplementation(() => ({
send: mockSendBedrock
})),
GetImportedModelCommand: jest.fn().mockImplementation((input) => ({ ...input, _type: 'GetImportedModelCommand' }))
}))
function makeInvokeResponse(body: object) {
return { body: new TextEncoder().encode(JSON.stringify(body)) }
}
function makeStreamResponse(chunks: object[]) {
return {
body: (async function* () {
for (const chunk of chunks) {
yield { chunk: { bytes: new TextEncoder().encode(JSON.stringify(chunk)) } }
}
})()
}
}
// ---------------------------------------------------------------------------
// detectFormat
// ---------------------------------------------------------------------------
describe('detectFormat', () => {
it('prefers openai-chat-completion when ChatCompletionRequest is supported', () => {
expect(detectFormat(['chatcompletionrequest', 'bedrockmetacompletionrequest'])).toBe('openai-chat-completion')
})
it('returns openai-chat-completion for ChatCompletion variant names', () => {
expect(detectFormat(['responsesrequest', 'chatcompletionrequest', 'completionrequest'])).toBe('openai-chat-completion')
})
it('returns bedrock-completion when only BedrockCompletion is available', () => {
expect(detectFormat(['bedrockcompletionrequest'])).toBe('bedrock-completion')
})
it('returns bedrock-completion when no chat completion format exists', () => {
expect(detectFormat(['completionrequest', 'embeddingchatrequest'])).toBe('bedrock-completion')
})
it('defaults to openai-chat-completion when no formats provided', () => {
expect(detectFormat(undefined)).toBe('openai-chat-completion')
})
it('defaults to openai-chat-completion for empty array', () => {
expect(detectFormat([])).toBe('openai-chat-completion')
})
})
// ---------------------------------------------------------------------------
// getImportedModelInfo
// ---------------------------------------------------------------------------
describe('getImportedModelInfo', () => {
beforeEach(() => {
_resetModelInfoCache()
mockSendBedrock.mockReset()
mockSendRuntime.mockReset()
})
it('returns model metadata and probed formats', async () => {
mockSendBedrock.mockResolvedValueOnce({
instructSupported: false,
modelArchitecture: 'llama'
})
mockSendRuntime.mockRejectedValueOnce(
new Error(
'ValidationException: Available for this model: ChatCompletionRequest, BedrockMetaCompletionRequest, CompletionRequest'
)
)
const info = await getImportedModelInfo('arn:aws:bedrock:us-east-1:123:imported-model/test', 'us-east-1')
expect(info.instructSupported).toBe(false)
expect(info.modelArchitecture).toBe('llama')
expect(info.supportedFormats).toContain('chatcompletionrequest')
expect(info.supportedFormats).toContain('bedrockmetacompletionrequest')
})
it('caches results per model ID', async () => {
mockSendBedrock.mockResolvedValueOnce({ instructSupported: false, modelArchitecture: 'llama' })
mockSendRuntime.mockRejectedValueOnce(new Error('ValidationException: Available for this model: ChatCompletionRequest'))
const arn = 'arn:aws:bedrock:us-east-1:123:imported-model/cached'
await getImportedModelInfo(arn, 'us-east-1')
await getImportedModelInfo(arn, 'us-east-1')
expect(mockSendBedrock).toHaveBeenCalledTimes(1)
expect(mockSendRuntime).toHaveBeenCalledTimes(1)
})
it('throws when GetImportedModel fails', async () => {
mockSendBedrock.mockRejectedValueOnce(new Error('ValidationException: model not found'))
await expect(getImportedModelInfo('bad-arn', 'us-east-1')).rejects.toThrow('ValidationException')
})
it('throws when probe fails with non-format error', async () => {
mockSendBedrock.mockResolvedValueOnce({ instructSupported: false, modelArchitecture: 'llama' })
mockSendRuntime.mockRejectedValueOnce(new Error('InternalServerException: unexpected error'))
await expect(getImportedModelInfo('some-arn', 'us-east-1')).rejects.toThrow('InternalServerException')
})
})
// ---------------------------------------------------------------------------
// BedrockImportedChat — message conversion
// ---------------------------------------------------------------------------
describe('BedrockImportedChat message conversion', () => {
function makeChat(format = 'bedrock-completion' as any) {
return new BedrockImportedChat('test-id', {
region: 'us-east-1',
modelId: 'test-model',
format,
temperature: 0.7,
maxTokens: 200,
streaming: false
})
}
describe('convertMessagesToPrompt (BedrockCompletion)', () => {
const chat = makeChat('bedrock-completion')
it('converts basic user message', () => {
const result = chat.convertMessagesToPrompt([new HumanMessage('Hello')])
expect(result).toBe('User: Hello\nAssistant:')
})
it('converts system + user + assistant messages', () => {
const result = chat.convertMessagesToPrompt([
new SystemMessage('You are helpful.'),
new HumanMessage('Hi'),
new AIMessage('Hello!'),
new HumanMessage('Bye')
])
expect(result).toContain('You are helpful.')
expect(result).toContain('User: Hi')
expect(result).toContain('Assistant: Hello!')
expect(result).toContain('User: Bye')
expect(result.endsWith('Assistant:')).toBe(true)
})
it('converts tool messages', () => {
const result = chat.convertMessagesToPrompt([
new HumanMessage('call tool'),
new ToolMessage({ content: '{"result": 42}', tool_call_id: 'tc1' })
])
expect(result).toContain('Tool: {"result": 42}')
})
})
describe('convertMessagesToOpenAI (OpenAIChatCompletion)', () => {
const chat = makeChat('openai-chat-completion')
it('converts basic messages to OpenAI format', () => {
const result = chat.convertMessagesToOpenAI([new SystemMessage('Be helpful'), new HumanMessage('Hello')])
expect(result).toEqual([
{ role: 'system', content: 'Be helpful' },
{ role: 'user', content: 'Hello' }
])
})
it('converts AI messages with tool_calls', () => {
const aiMsg = new AIMessage({
content: '',
additional_kwargs: {
tool_calls: [{ id: 'tc1', type: 'function', function: { name: 'add', arguments: '{"a":1}' } }]
}
})
const result = chat.convertMessagesToOpenAI([aiMsg])
expect(result[0].role).toBe('assistant')
expect(result[0].tool_calls).toHaveLength(1)
})
it('converts ToolMessage with tool_call_id', () => {
const result = chat.convertMessagesToOpenAI([new ToolMessage({ content: 'result', tool_call_id: 'tc1' })])
expect(result[0]).toEqual({ role: 'tool', content: 'result', tool_call_id: 'tc1' })
})
})
})
// ---------------------------------------------------------------------------
// BedrockImportedChat — _generate (non-streaming)
// ---------------------------------------------------------------------------
describe('BedrockImportedChat _generate', () => {
beforeEach(() => {
mockSendRuntime.mockReset()
})
it('sends BedrockCompletion request and parses response', async () => {
mockSendRuntime.mockResolvedValueOnce(makeInvokeResponse({ completion: 'Hello from Llama!', stop_reason: 'stop' }))
const chat = new BedrockImportedChat('test-id', {
region: 'us-east-1',
modelId: 'arn:aws:bedrock:us-east-1:123:imported-model/llama',
format: 'bedrock-completion',
streaming: false
})
const result = await chat._generate([new HumanMessage('Hi')], {} as any)
expect(result.generations).toHaveLength(1)
expect(result.generations[0].text).toBe('Hello from Llama!')
})
it('sends OpenAIChatCompletion request and parses response', async () => {
mockSendRuntime.mockResolvedValueOnce(
makeInvokeResponse({
choices: [{ message: { role: 'assistant', content: 'Hello from GPT!' } }],
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }
})
)
const chat = new BedrockImportedChat('test-id', {
region: 'us-east-1',
modelId: 'arn:aws:bedrock:us-east-1:123:imported-model/gpt',
format: 'openai-chat-completion',
streaming: false
})
const result = await chat._generate([new HumanMessage('Hi')], {} as any)
expect(result.generations[0].text).toBe('Hello from GPT!')
expect((result.llmOutput as any)?.tokenUsage.totalTokens).toBe(15)
})
it('parses OpenAI tool_calls in response', async () => {
mockSendRuntime.mockResolvedValueOnce(
makeInvokeResponse({
choices: [
{
message: {
role: 'assistant',
content: '',
tool_calls: [{ id: 'tc1', type: 'function', function: { name: 'calc', arguments: '{"x":1}' } }]
}
}
]
})
)
const chat = new BedrockImportedChat('test-id', {
region: 'us-east-1',
modelId: 'test-model',
format: 'openai-chat-completion',
streaming: false
})
const result = await chat._generate([new HumanMessage('Use tool')], {} as any)
expect((result.generations[0].message.additional_kwargs as any).tool_calls).toHaveLength(1)
expect((result.generations[0].message.additional_kwargs as any).tool_calls[0].function.name).toBe('calc')
})
it('handles empty choices in OpenAI response', async () => {
mockSendRuntime.mockResolvedValueOnce(makeInvokeResponse({ choices: [] }))
const chat = new BedrockImportedChat('test-id', {
region: 'us-east-1',
modelId: 'test-model',
format: 'openai-chat-completion',
streaming: false
})
const result = await chat._generate([new HumanMessage('Hi')], {} as any)
expect(result.generations[0].text).toBe('')
})
it('normalizes ModelNotReady errors', async () => {
mockSendRuntime.mockRejectedValueOnce(new Error('ModelNotReadyException: model is still loading'))
const chat = new BedrockImportedChat('test-id', {
region: 'us-east-1',
modelId: 'test-model',
format: 'bedrock-completion',
streaming: false
})
await expect(chat._generate([new HumanMessage('Hi')], {} as any)).rejects.toThrow('not ready to serve')
})
it('normalizes ResourceNotFound errors', async () => {
mockSendRuntime.mockRejectedValueOnce(new Error('ResourceNotFoundException: model xyz not found'))
const chat = new BedrockImportedChat('test-id', {
region: 'us-east-1',
modelId: 'bad-arn',
format: 'bedrock-completion',
streaming: false
})
await expect(chat._generate([new HumanMessage('Hi')], {} as any)).rejects.toThrow('Model not found')
})
})
// ---------------------------------------------------------------------------
// BedrockImportedChat — streaming
// ---------------------------------------------------------------------------
describe('BedrockImportedChat streaming', () => {
beforeEach(() => {
mockSendRuntime.mockReset()
})
it('streams BedrockCompletion chunks', async () => {
mockSendRuntime.mockResolvedValueOnce(makeStreamResponse([{ completion: 'Hello ' }, { completion: 'world!' }]))
const chat = new BedrockImportedChat('test-id', {
region: 'us-east-1',
modelId: 'test-model',
format: 'bedrock-completion',
streaming: true
})
const chunks: string[] = []
for await (const chunk of chat._streamResponseChunks([new HumanMessage('Hi')], {} as any)) {
chunks.push(chunk.text)
}
expect(chunks).toEqual(['Hello ', 'world!'])
})
it('streams OpenAIChatCompletion delta chunks', async () => {
mockSendRuntime.mockResolvedValueOnce(
makeStreamResponse([{ choices: [{ delta: { content: 'Hello ' } }] }, { choices: [{ delta: { content: 'world!' } }] }])
)
const chat = new BedrockImportedChat('test-id', {
region: 'us-east-1',
modelId: 'test-model',
format: 'openai-chat-completion',
streaming: true
})
const chunks: string[] = []
for await (const chunk of chat._streamResponseChunks([new HumanMessage('Hi')], {} as any)) {
chunks.push(chunk.text)
}
expect(chunks).toEqual(['Hello ', 'world!'])
})
it('skips empty stream chunks', async () => {
mockSendRuntime.mockResolvedValueOnce(makeStreamResponse([{ completion: '' }, { completion: 'data' }, { completion: '' }]))
const chat = new BedrockImportedChat('test-id', {
region: 'us-east-1',
modelId: 'test-model',
format: 'bedrock-completion',
streaming: true
})
const chunks: string[] = []
for await (const chunk of chat._streamResponseChunks([new HumanMessage('Hi')], {} as any)) {
chunks.push(chunk.text)
}
expect(chunks).toEqual(['data'])
})
it('_generate uses streaming when enabled with runManager', async () => {
mockSendRuntime.mockResolvedValueOnce(makeStreamResponse([{ completion: 'streamed' }]))
const chat = new BedrockImportedChat('test-id', {
region: 'us-east-1',
modelId: 'test-model',
format: 'bedrock-completion',
streaming: true
})
const mockRunManager = { handleLLMNewToken: jest.fn() }
const result = await chat._generate([new HumanMessage('Hi')], {} as any, mockRunManager as any)
expect(result.generations[0].text).toBe('streamed')
expect(mockRunManager.handleLLMNewToken).toHaveBeenCalledWith('streamed')
})
})
// ---------------------------------------------------------------------------
// Image validation
// ---------------------------------------------------------------------------
describe('BedrockImportedChat image handling', () => {
beforeEach(() => {
mockSendRuntime.mockReset()
})
it('rejects remote image URLs', async () => {
const chat = new BedrockImportedChat('test-id', {
region: 'us-east-1',
modelId: 'test-model',
format: 'openai-chat-completion',
streaming: false
})
const msg = new HumanMessage({
content: [
{ type: 'text', text: 'What is this?' },
{ type: 'image_url', image_url: { url: 'https://example.com/image.png' } }
]
})
await expect(chat._generate([msg], {} as any)).rejects.toThrow('base64 data URLs')
})
it('accepts base64 data URL images', async () => {
mockSendRuntime.mockResolvedValueOnce(makeInvokeResponse({ choices: [{ message: { content: 'A cat' } }] }))
const chat = new BedrockImportedChat('test-id', {
region: 'us-east-1',
modelId: 'test-model',
format: 'openai-chat-completion',
streaming: false
})
const msg = new HumanMessage({
content: [
{ type: 'text', text: 'What is this?' },
{ type: 'image_url', image_url: { url: 'data:image/png;base64,iVBORw0KGgo=' } }
]
})
const result = await chat._generate([msg], {} as any)
expect(result.generations[0].text).toBe('A cat')
})
})
// ---------------------------------------------------------------------------
// IVisionChatModal interface
// ---------------------------------------------------------------------------
describe('IVisionChatModal interface', () => {
it('implements setMultiModalOption', () => {
const chat = new BedrockImportedChat('test-id', {
region: 'us-east-1',
modelId: 'test-model',
format: 'bedrock-completion'
})
const option = { image: { allowImageUploads: true } }
chat.setMultiModalOption(option)
expect(chat.multiModalOption).toEqual(option)
})
it('exposes configuredModel and id', () => {
const chat = new BedrockImportedChat('my-node-id', {
region: 'us-east-1',
modelId: 'arn:aws:bedrock:us-east-1:123:imported-model/test',
format: 'bedrock-completion'
})
expect(chat.id).toBe('my-node-id')
expect(chat.configuredModel).toBe('arn:aws:bedrock:us-east-1:123:imported-model/test')
})
})
@@ -1,17 +1,106 @@
/**
* Flowise wrapper around LangChain's ChatBedrockConverse.
*
* Extends the base class with three Flowise-specific behaviors:
*
* 1. **stopSequences stripping** — Some Bedrock models (DeepSeek, OpenAI GPT OSS)
* reject the `stopSequences` inference config field. The `invocationParams()`
* override removes it for models flagged with `stop_sequences: false` in models.json.
*
* 2. **Temperature auto-retry** — Some models (e.g., Claude Opus 4.7) reject the
* `temperature` parameter. `_generate()` and `_streamResponseChunks()` catch
* "temperature is deprecated" errors and retry without temperature. This avoids
* maintaining a per-model flag in models.json since Bedrock has no API to query
* supported inference parameters, and the UI form renderer can't conditionally
* disable fields based on the selected model.
*
* 3. **Error normalization** — `_generate()` and `_streamResponseChunks()` catch
* Bedrock errors and rewrite them into actionable user-facing messages via
* `normalizeBedrockError()` (e.g., "use an inference profile" instead of raw
* ValidationException).
*
* 4. **Multimodal support** — Implements `IVisionChatModal` for image upload handling.
*
* @see https://docs.aws.amazon.com/bedrock/latest/userguide/conversation-inference.html
*/
import { IVisionChatModal, IMultiModalOption } from '../../../src'
import { ChatBedrockConverse as LCBedrockChat, ChatBedrockConverseInput } from '@langchain/aws'
import type { BaseMessage } from '@langchain/core/messages'
import type { ChatResult } from '@langchain/core/outputs'
import type { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager'
import { normalizeBedrockError } from './utils'
export class BedrockChat extends LCBedrockChat implements IVisionChatModal {
configuredModel: string
configuredMaxToken?: number
multiModalOption: IMultiModalOption
id: string
stopSeqUnsupported: Set<string>
constructor(id: string, fields: ChatBedrockConverseInput) {
constructor(id: string, fields: ChatBedrockConverseInput, stopSeqUnsupported?: Set<string>) {
super(fields)
this.id = id
this.configuredModel = fields?.model || ''
this.configuredMaxToken = fields?.maxTokens
this.stopSeqUnsupported = stopSeqUnsupported ?? new Set()
}
/**
* Strips stopSequences for models that don't support it.
* Models are identified by exact ID from models.json (`stop_sequences: false`),
* not by provider prefix, to avoid silently stripping from future models that
* may add support.
*/
override invocationParams(options?: this['ParsedCallOptions']) {
const params = super.invocationParams(options)
const modelId = this.model ?? this.configuredModel
if (this.stopSeqUnsupported.has(modelId)) {
if (params.inferenceConfig) {
delete params.inferenceConfig.stopSequences
}
}
return params
}
private isTemperatureDeprecatedError(err: unknown): boolean {
return err instanceof Error && err.message.includes('temperature') && err.message.includes('deprecated')
}
private disableTemperature(): void {
this.temperature = undefined as any
}
override async _generate(
messages: BaseMessage[],
options: this['ParsedCallOptions'],
runManager?: CallbackManagerForLLMRun
): Promise<ChatResult> {
try {
return await super._generate(messages, options, runManager)
} catch (err) {
if (this.isTemperatureDeprecatedError(err)) {
this.disableTemperature()
return await super._generate(messages, options, runManager)
}
throw normalizeBedrockError(err)
}
}
override async *_streamResponseChunks(
messages: BaseMessage[],
options: this['ParsedCallOptions'],
runManager?: CallbackManagerForLLMRun
) {
try {
yield* super._streamResponseChunks(messages, options, runManager)
} catch (err) {
if (this.isTemperatureDeprecatedError(err)) {
this.disableTemperature()
yield* super._streamResponseChunks(messages, options, runManager)
return
}
throw normalizeBedrockError(err)
}
}
setMultiModalOption(multiModalOption: IMultiModalOption): void {
@@ -0,0 +1,447 @@
import { BaseChatModel, type BaseChatModelParams } from '@langchain/core/language_models/chat_models'
import { AIMessage, AIMessageChunk, BaseMessage, HumanMessage, SystemMessage, ToolMessage } from '@langchain/core/messages'
import { ChatResult, ChatGenerationChunk } from '@langchain/core/outputs'
import { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager'
import { BedrockRuntimeClient, InvokeModelCommand, InvokeModelWithResponseStreamCommand } from '@aws-sdk/client-bedrock-runtime'
import { IVisionChatModal, IMultiModalOption } from '../../../src'
/**
* Request format used when calling Bedrock's InvokeModel API for imported models.
*
* - 'bedrock-completion': Uses { prompt, max_gen_len, temperature } request shape.
* Response: { generation, stop_reason }. For models without a chat template (e.g. GPTBigCode).
* - 'openai-chat-completion': Uses { messages, max_tokens, temperature } request shape.
* Response: { choices: [{ message }], usage }. For models with chat support (Llama, Qwen, etc.).
*
* @see https://docs.aws.amazon.com/bedrock/latest/userguide/invoke-imported-model.html
*/
export type ImportedModelFormat = 'bedrock-completion' | 'openai-chat-completion'
export interface BedrockImportedChatInput extends BaseChatModelParams {
region: string
modelId: string
format: ImportedModelFormat
temperature?: number
maxTokens?: number
streaming?: boolean
credentials?: {
accessKeyId: string
secretAccessKey: string
sessionToken?: string
}
}
interface OpenAIToolCall {
id: string
type: 'function'
function: { name: string; arguments: string }
}
interface ImportedModelInfo {
instructSupported?: boolean
modelArchitecture?: string
supportedFormats?: string[]
}
const _modelInfoCache = new Map<string, ImportedModelInfo>()
/**
* Fetches metadata and supported request formats for an imported Bedrock model.
*
* Two-step process:
* 1. Calls GetImportedModel to retrieve instructSupported and modelArchitecture.
* 2. Probes InvokeModel with an empty body — the resulting ValidationException
* lists the formats the model accepts (e.g. "ChatCompletionRequest, BedrockMetaCompletionRequest").
*
* Results are cached per modelId for the process lifetime.
* Errors propagate directly — if the model doesn't exist, the caller gets the real Bedrock error.
*
* @see https://docs.aws.amazon.com/bedrock/latest/APIReference/API_GetImportedModel.html
* @see https://docs.aws.amazon.com/bedrock/latest/userguide/invoke-imported-model.html
*/
export async function getImportedModelInfo(
modelId: string,
region: string,
credentials?: { accessKeyId: string; secretAccessKey: string; sessionToken?: string }
): Promise<ImportedModelInfo> {
if (_modelInfoCache.has(modelId)) {
return _modelInfoCache.get(modelId)!
}
const info: ImportedModelInfo = {}
// Step 1: GetImportedModel for metadata (instructSupported, modelArchitecture)
const { BedrockClient, GetImportedModelCommand } = await import('@aws-sdk/client-bedrock')
const client = new BedrockClient({
region,
...(credentials && { credentials })
})
const resp = await client.send(new GetImportedModelCommand({ modelIdentifier: modelId }))
info.instructSupported = resp.instructSupported
info.modelArchitecture = resp.modelArchitecture
// Step 2: Probe InvokeModel with an empty body to discover supported formats.
// Bedrock returns a ValidationException whose message lists the formats, e.g.:
// "Available for this model: ChatCompletionRequest, BedrockMetaCompletionRequest, ..."
// This is a control-plane call that fails instantly — no inference cost.
try {
const runtimeClient = new BedrockRuntimeClient({
region,
...(credentials && { credentials })
})
await runtimeClient.send(
new InvokeModelCommand({
modelId,
contentType: 'application/json',
accept: 'application/json',
body: JSON.stringify({})
})
)
} catch (probeErr: any) {
const msg = probeErr?.message ?? ''
const match = msg.match(/Available for this model:\s*(.+?)(?:"|$)/)
if (match) {
info.supportedFormats = match[1].split(',').map((s: string) => s.trim().toLowerCase())
} else if (msg.includes('prompt') && msg.includes('must be provided')) {
// Model expects a prompt field (completion-style, no chat template)
info.supportedFormats = ['completionrequest']
} else {
throw probeErr
}
}
_modelInfoCache.set(modelId, info)
return info
}
/**
* Selects the best request format based on the model's supported formats.
* Prefers OpenAIChatCompletion (structured messages, tool_calls, token usage)
* over BedrockCompletion (raw prompt string). Falls back to openai-chat-completion
* if no format info is available.
*/
export function detectFormat(supportedFormats?: string[]): ImportedModelFormat {
if (!supportedFormats?.length) {
return 'openai-chat-completion'
}
// Prefer OpenAI Chat if available — structured messages, tool_calls, token usage
if (supportedFormats.some((f) => f.includes('chatcompletion'))) {
return 'openai-chat-completion'
}
return 'bedrock-completion'
}
export function _resetModelInfoCache(): void {
_modelInfoCache.clear()
}
/**
* LangChain-compatible chat model for Bedrock imported models that don't support
* the Converse API (instructSupported: false).
*
* Uses InvokeModel / InvokeModelWithResponseStream instead of Converse / ConverseStream.
* Supports two request formats (auto-detected at init time via getImportedModelInfo):
* - OpenAIChatCompletion: messages array with tool_calls support
* - BedrockCompletion: raw prompt string with max_gen_len
*
* This class is instantiated by AWSChatBedrock.init() when it detects an imported-model
* ARN in the Custom Model ARN field. Users don't interact with it directly.
*
* @see https://docs.aws.amazon.com/bedrock/latest/userguide/invoke-imported-model.html
* @see https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_InvokeModel.html
*/
export class BedrockImportedChat extends BaseChatModel implements IVisionChatModal {
configuredModel: string
configuredMaxToken?: number
multiModalOption: IMultiModalOption
id: string
private region: string
private modelId: string
private format: ImportedModelFormat
private temperature: number
private maxTokens: number
private streamingEnabled: boolean
private credentials?: { accessKeyId: string; secretAccessKey: string; sessionToken?: string }
private client: BedrockRuntimeClient
constructor(id: string, fields: BedrockImportedChatInput) {
super(fields)
this.id = id
this.region = fields.region
this.modelId = fields.modelId
this.format = fields.format
this.temperature = fields.temperature ?? 0.7
this.maxTokens = fields.maxTokens ?? 200
this.streamingEnabled = fields.streaming ?? true
this.credentials = fields.credentials
this.configuredModel = fields.modelId
this.configuredMaxToken = fields.maxTokens
this.client = new BedrockRuntimeClient({
region: this.region,
...(this.credentials && { credentials: this.credentials })
})
}
_llmType(): string {
return 'bedrock-imported'
}
get callKeys(): string[] {
return ['stop', 'signal', 'options']
}
setMultiModalOption(multiModalOption: IMultiModalOption): void {
this.multiModalOption = multiModalOption
}
async _generate(
messages: BaseMessage[],
options: this['ParsedCallOptions'],
runManager?: CallbackManagerForLLMRun
): Promise<ChatResult> {
if (this.streamingEnabled && runManager) {
const generations: ChatGenerationChunk[] = []
let fullText = ''
for await (const chunk of this._streamResponseChunks(messages, options, runManager)) {
generations.push(chunk)
fullText += chunk.text
}
return {
generations: [{ text: fullText, message: new AIMessage(fullText) }]
}
}
const body = this.buildRequestBody(messages)
try {
const resp = await this.client.send(
new InvokeModelCommand({
modelId: this.modelId,
contentType: 'application/json',
accept: 'application/json',
body: JSON.stringify(body)
})
)
if (resp.body == null) throw new Error('Response body is undefined')
const decoded = JSON.parse(new TextDecoder().decode(resp.body))
return this.parseResponse(decoded)
} catch (err) {
throw this.normalizeError(err)
}
}
async *_streamResponseChunks(
messages: BaseMessage[],
_options: this['ParsedCallOptions'],
runManager?: CallbackManagerForLLMRun
): AsyncGenerator<ChatGenerationChunk> {
const body = this.buildRequestBody(messages)
try {
const resp = await this.client.send(
new InvokeModelWithResponseStreamCommand({
modelId: this.modelId,
contentType: 'application/json',
accept: 'application/json',
body: JSON.stringify(body)
})
)
if (!resp.body) {
throw new Error('No response stream received from Bedrock')
}
for await (const event of resp.body) {
if (event.chunk?.bytes) {
const decoded = JSON.parse(new TextDecoder().decode(event.chunk.bytes))
const text = this.extractStreamChunkText(decoded)
if (text) {
const chunk = new ChatGenerationChunk({
text,
message: new AIMessageChunk(text)
})
yield chunk
await runManager?.handleLLMNewToken(text)
}
}
}
} catch (err) {
throw this.normalizeError(err)
}
}
private buildRequestBody(messages: BaseMessage[]): Record<string, unknown> {
if (this.format === 'openai-chat-completion') {
return this.buildOpenAIChatBody(messages)
}
return this.buildBedrockCompletionBody(messages)
}
private buildBedrockCompletionBody(messages: BaseMessage[]): Record<string, unknown> {
const prompt = this.convertMessagesToPrompt(messages)
return {
prompt,
temperature: this.temperature,
max_gen_len: this.maxTokens
}
}
private buildOpenAIChatBody(messages: BaseMessage[]): Record<string, unknown> {
const openaiMessages = this.convertMessagesToOpenAI(messages)
return {
messages: openaiMessages,
temperature: this.temperature,
max_tokens: this.maxTokens
}
}
convertMessagesToPrompt(messages: BaseMessage[]): string {
const parts: string[] = []
for (const msg of messages) {
const content = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content)
if (msg instanceof SystemMessage) {
parts.push(`${content}\n`)
} else if (msg instanceof HumanMessage) {
parts.push(`User: ${content}`)
} else if (msg instanceof AIMessage) {
parts.push(`Assistant: ${content}`)
} else if (msg instanceof ToolMessage) {
parts.push(`Tool: ${content}`)
} else {
parts.push(content)
}
}
parts.push('Assistant:')
return parts.join('\n')
}
convertMessagesToOpenAI(messages: BaseMessage[]): Array<Record<string, unknown>> {
return messages.map((msg) => {
const content = typeof msg.content === 'string' ? msg.content : this.convertMultiModalContent(msg.content)
if (msg instanceof SystemMessage) {
return { role: 'system', content }
}
if (msg instanceof HumanMessage) {
return { role: 'user', content }
}
if (msg instanceof AIMessage) {
const result: Record<string, unknown> = { role: 'assistant', content }
if (msg.additional_kwargs?.tool_calls) {
result.tool_calls = msg.additional_kwargs.tool_calls
}
return result
}
if (msg instanceof ToolMessage) {
return {
role: 'tool',
content,
tool_call_id: (msg as ToolMessage).tool_call_id
}
}
return { role: 'user', content }
})
}
private convertMultiModalContent(content: unknown): unknown {
if (!Array.isArray(content)) return content
return content.map((part: any) => {
if (part.type === 'image_url' && typeof part.image_url?.url === 'string') {
const url = part.image_url.url
if (!url.startsWith('data:')) {
throw new Error(
'AWS Bedrock Imported models only support base64 data URLs for images, ' +
'not remote URLs. Convert the image to a data URL first.'
)
}
return part
}
return part
})
}
private parseResponse(decoded: Record<string, unknown>): ChatResult {
if (this.format === 'openai-chat-completion') {
return this.parseOpenAIChatResponse(decoded)
}
return this.parseBedrockCompletionResponse(decoded)
}
private parseBedrockCompletionResponse(decoded: Record<string, unknown>): ChatResult {
const text = (decoded.completion as string) ?? (decoded.generation as string) ?? ''
return {
generations: [{ text, message: new AIMessage(text) }]
}
}
private parseOpenAIChatResponse(decoded: Record<string, unknown>): ChatResult {
const choices = decoded.choices as Array<Record<string, any>> | undefined
if (!choices?.length) {
return { generations: [{ text: '', message: new AIMessage('') }] }
}
const choice = choices[0]
const message = choice.message ?? {}
const text = (message.content as string) ?? ''
const toolCalls: OpenAIToolCall[] | undefined = message.tool_calls
const aiMsg = new AIMessage({
content: text,
additional_kwargs: toolCalls ? { tool_calls: toolCalls } : {}
})
return {
generations: [{ text, message: aiMsg }],
llmOutput: decoded.usage
? {
tokenUsage: {
promptTokens: (decoded.usage as any).prompt_tokens,
completionTokens: (decoded.usage as any).completion_tokens,
totalTokens: (decoded.usage as any).total_tokens
}
}
: undefined
}
}
private extractStreamChunkText(decoded: Record<string, unknown>): string {
if (this.format === 'openai-chat-completion') {
const choices = decoded.choices as Array<Record<string, any>> | undefined
if (!choices?.length) return ''
const delta = choices[0].delta ?? {}
return (delta.content as string) ?? ''
}
return (decoded.completion as string) ?? (decoded.generation as string) ?? (decoded.outputText as string) ?? ''
}
private normalizeError(err: unknown): Error {
if (!(err instanceof Error)) return err as Error
const msg = err.message ?? ''
if (msg.includes('ModelNotReadyException') || msg.includes('not ready')) {
return new Error(
`The imported model is not ready to serve requests. ` +
`This can happen shortly after import or if the model is being updated. ` +
`Wait a few minutes and try again. Original error: ${msg}`
)
}
if (msg.includes('ValidationException') && msg.includes('format')) {
return new Error(
`The request format may not match what this imported model expects. ` +
`The auto-detected format is "${this.format}". ` +
`Original error: ${msg}`
)
}
if (msg.includes('ResourceNotFoundException')) {
return new Error(
`Model not found: "${this.modelId}". ` +
`Verify the model ARN or ID is correct and the model exists in the "${this.region}" region. ` +
`Original error: ${msg}`
)
}
return err
}
}
@@ -0,0 +1,406 @@
/**
* Bedrock-specific utilities for the AWS Bedrock Converse node.
*
* This module centralizes all Bedrock model resolution logic:
*
* - **validateEndpointHost** — Sanitizes the endpointHost field. Auto-migrates
* ARNs that users mistakenly placed there (the original "invalid URL" bug).
*
* - **discoverInferenceProfiles** — Runtime discovery of which inference profiles
* exist in a given region via the Bedrock control-plane API. Cached per region.
* @see https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles.html
*
* - **resolveBedrockModel** — Determines the correct model ID and optional
* applicationInferenceProfile for a Converse API call. Handles ARNs,
* geo-prefixed profile IDs, and auto-application of profiles for models
* that require them.
* @see https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-use.html
*
* - **getStopSeqUnsupportedModels** — Loads the set of models that reject the
* stopSequences inference config field, read from models.json (`stop_sequences: false`).
*
* - **normalizeBedrockError** — Rewrites raw Bedrock Converse API errors into
* actionable user-facing messages.
*
* All model metadata (inference profile geos, stop_sequences flags) lives in
* models.json as the single source of truth, loaded via the model loader.
*/
// ---------------------------------------------------------------------------
// Endpoint-host validation
// ---------------------------------------------------------------------------
export interface EndpointHostResult {
/** The sanitized hostname to use, or undefined if the value was an ARN/URL that should be ignored. */
hostname?: string
/** If the value looked like an ARN, it is returned here so init() can treat it as a model target. */
migratedArn?: string
}
/**
* Inspects the endpointHost value and decides what to do with it.
*
* - Bare hostname (valid) → returned as-is.
* - ARN (common misconfiguration) → stripped from endpointHost and
* returned as `migratedArn` so init() can route it to the inference
* profile path. This provides backward compatibility for existing
* flows that placed ARNs in the wrong field.
* - URL with scheme → hostname extracted and returned with a console
* warning.
*/
export function validateEndpointHost(value: string): EndpointHostResult {
// ARN in endpointHost — auto-migrate to inference profile
if (value.startsWith('arn:aws:bedrock:')) {
console.warn(
`[AWSBedrock] "Custom Endpoint Host" contained an ARN (${value}). ` +
`This has been auto-migrated to the inference profile path. ` +
`Please move this value to the "Custom Model ARN" field instead.`
)
return { migratedArn: value }
}
// URL with scheme — extract hostname
if (value.includes('://')) {
try {
const url = new URL(value)
console.warn(`[AWSBedrock] "Custom Endpoint Host" contained a full URL. ` + `Using hostname "${url.hostname}" only.`)
return { hostname: url.hostname }
} catch {
// If URL parsing fails, reject
return {}
}
}
// Contains a path — likely a mistake, but not an ARN
if (value.includes('/')) {
console.warn(
`[AWSBedrock] "Custom Endpoint Host" contained a path separator. ` + `Using the value before the first "/" as hostname.`
)
return { hostname: value.split('/')[0] }
}
// Valid bare hostname
return { hostname: value }
}
// ---------------------------------------------------------------------------
// Runtime inference-profile discovery
// ---------------------------------------------------------------------------
/**
* Per-region cache of available inference profile IDs.
* Populated by calling `ListInferenceProfiles` once per region.
*/
const _regionProfileCache = new Map<string, Set<string>>()
/**
* Discovers which system-defined inference profiles are available in a
* given region by calling the Bedrock control-plane API. Results are
* cached in-memory for the process lifetime.
*
* This is necessary because inference profile availability is per-region,
* not per-geo. For example, `eu.amazon.nova-pro-v1:0` exists in eu-west-1
* but NOT in eu-west-2, even though both are EU regions.
*
* Uses the same credentials the user configured on the Bedrock node
* (UI creds, AssumeRole, or SDK default chain).
*
* Note: The API paginates (~13 results per page despite maxResults: 100),
* so we loop on nextToken to get the full list.
*
* On any error (auth failure, region not supported, etc.) returns an
* empty set — the caller falls back to direct invocation without a profile.
*
* @see https://docs.aws.amazon.com/bedrock/latest/APIReference/API_ListInferenceProfiles.html
*/
export async function discoverInferenceProfiles(
region: string,
credentials?: { accessKeyId: string; secretAccessKey: string; sessionToken?: string }
): Promise<Set<string>> {
if (_regionProfileCache.has(region)) {
return _regionProfileCache.get(region)!
}
try {
const { BedrockClient, ListInferenceProfilesCommand } = await import('@aws-sdk/client-bedrock')
const client = new BedrockClient({
region,
...(credentials && { credentials })
})
const ids = new Set<string>()
let nextToken: string | undefined
do {
const resp = await client.send(
new ListInferenceProfilesCommand({
typeEquals: 'SYSTEM_DEFINED',
maxResults: 100,
...(nextToken && { nextToken })
})
)
for (const p of resp.inferenceProfileSummaries ?? []) {
if (p.inferenceProfileId) {
ids.add(p.inferenceProfileId)
}
}
nextToken = resp.nextToken
} while (nextToken)
_regionProfileCache.set(region, ids)
return ids
} catch {
const empty = new Set<string>()
_regionProfileCache.set(region, empty)
return empty
}
}
/** Exported for testing — resets the region profile cache. */
export function _resetRegionProfileCache(): void {
_regionProfileCache.clear()
}
// ---------------------------------------------------------------------------
// Stop-sequence support
// ---------------------------------------------------------------------------
/**
* Cached set of model IDs that do NOT support the stopSequences
* inference-config field. Populated from models.json via the model
* loader by looking for `stop_sequences: false`.
*
* Affected models include DeepSeek and OpenAI GPT OSS variants which
* return: "This model doesn't support the stopSequences field."
* We use exact model IDs (not prefix matching) because future models
* from the same provider may add stopSequences support.
*
* @see https://docs.aws.amazon.com/bedrock/latest/userguide/conversation-inference-supported-models-features.html
*/
let _stopSeqUnsupported: Set<string> | null = null
export async function getStopSeqUnsupportedModels(): Promise<Set<string>> {
if (_stopSeqUnsupported) return _stopSeqUnsupported
try {
const { getModels, MODEL_TYPE } = await import('../../../src/modelLoader')
const models = await getModels(MODEL_TYPE.CHAT, 'awsChatBedrock')
_stopSeqUnsupported = new Set(models.filter((m: any) => m.stop_sequences === false).map((m: any) => m.name))
} catch {
_stopSeqUnsupported = new Set()
}
return _stopSeqUnsupported
}
/** Exported for testing — resets the stop-sequence cache. */
export function _resetStopSeqCache(): void {
_stopSeqUnsupported = null
}
// ---------------------------------------------------------------------------
// Model / inference-profile resolution
// ---------------------------------------------------------------------------
/**
* Cached map of model ID → available geo prefixes (e.g. ['us','eu','global']).
* Populated at first use from the model catalog (models.json via the
* model loader) by reading the `inference_profile_geos` array on each model.
*/
let _inferenceProfileGeos: Map<string, string[]> | null = null
async function getInferenceProfileGeos(): Promise<Map<string, string[]>> {
if (_inferenceProfileGeos) return _inferenceProfileGeos
try {
const { getModels, MODEL_TYPE } = await import('../../../src/modelLoader')
const models = await getModels(MODEL_TYPE.CHAT, 'awsChatBedrock')
_inferenceProfileGeos = new Map()
for (const m of models) {
if ((m as any).inference_profile_geos?.length) {
_inferenceProfileGeos.set((m as any).name, (m as any).inference_profile_geos)
}
}
} catch {
_inferenceProfileGeos = new Map()
}
return _inferenceProfileGeos
}
/** Exported for testing -- resets the cached map so tests can control it. */
export function _resetInferenceProfileCache(): void {
_inferenceProfileGeos = null
}
/**
* Returns an ordered list of geo-prefix candidates for a given AWS region.
* The first match against a model's `inference_profile_geos` wins.
*
* Some regions have unique prefixes (jp for Tokyo, ca for Canada)
* alongside broader geo prefixes. The fallback chain in
* resolveBedrockModel() appends `global` and `us` after these candidates.
*
* Source: `aws bedrock list-inference-profiles --type-equals SYSTEM_DEFINED`
* across us-east-1, us-west-2, eu-west-1, eu-central-1, ap-southeast-1,
* ap-northeast-1, ca-central-1, sa-east-1 (2026-04-16).
*/
export function regionToGeoCandidates(region: string): string[] {
if (region.startsWith('us-')) return ['us']
if (region.startsWith('eu-')) return ['eu']
if (region === 'ap-northeast-1' || region === 'ap-northeast-3') return ['jp', 'apac']
if (region === 'ap-southeast-2') return ['au', 'apac']
if (region.startsWith('ap-')) return ['apac']
if (region.startsWith('ca-')) return ['ca', 'us']
if (region.startsWith('sa-')) return [] // SA only has global. profiles, handled by fallback
return ['us']
}
/**
* Geo-prefixes used by Bedrock cross-region inference profile IDs.
* These are prepended to the base model ID to form the profile ID
* (e.g., `us.anthropic.claude-sonnet-4-6`).
*
* @see https://docs.aws.amazon.com/bedrock/latest/userguide/cross-region-inference-support.html
*/
const GEO_PREFIX_RE = /^(us|eu|apac|jp|au|ca|global)\./
export interface ResolvedBedrockModel {
/** The base model ID (used for metadata, pricing lookup, etc.). */
modelId: string
/** Set when the target requires profile-routed inference. */
applicationInferenceProfile?: string
}
/**
* Accepts whatever the user typed into `customModel` (or the dropdown
* selection) and returns the values that should be passed to
* ChatBedrockConverseInput.
*
* Detection logic:
*
* 1. ARN (`arn:aws:bedrock:…`) → treated as an inference-profile or
* provisioned-model ARN. The dropdown model is kept as `modelId`
* for metadata.
* 2. Geo-prefixed ID (`us.`, `eu.`, `apac.`, `global.`) → cross-region
* inference profile. The prefix is stripped to derive `modelId`.
* 3. Plain model ID → passed through as `modelId`.
* 4. Empty / undefined → falls back to `dropdownModel`.
*
* After detection, if no explicit profile was set, the model's
* `inference_profile_geos` from models.json is checked. The best
* geo is chosen by trying candidates from regionToGeoCandidates()
* in order, then falling back to global → us → none.
*
* When `useGlobalEndpoint` is true, skips regional candidates and
* tries `global.*` first. Falls back to normal selection if the model
* has no global profile.
*
* @see https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles.html
* @see https://docs.aws.amazon.com/bedrock/latest/userguide/cross-region-inference.html
*/
export async function resolveBedrockModel(
customModel: string | undefined,
dropdownModel: string,
region?: string,
availableProfiles?: Set<string>,
useGlobalEndpoint?: boolean
): Promise<ResolvedBedrockModel> {
const resolvedInput = customModel?.trim() || dropdownModel
let modelId = resolvedInput
let applicationInferenceProfile: string | undefined
if (resolvedInput.startsWith('arn:aws:bedrock:')) {
// Full ARN — use as inference profile, keep dropdown for metadata
applicationInferenceProfile = resolvedInput
modelId = dropdownModel
} else if (GEO_PREFIX_RE.test(resolvedInput)) {
// Cross-region inference profile ID (e.g. us.anthropic.claude-sonnet-4-6)
applicationInferenceProfile = resolvedInput
modelId = resolvedInput.replace(GEO_PREFIX_RE, '')
}
// Auto-apply inference profile using best available geo for the region.
// If useGlobalEndpoint is true, try global.* first.
// Otherwise try region-specific candidates first, then global, then us.
// If availableProfiles is provided (from runtime discovery), only apply
// a profile that is confirmed to exist in the target region.
if (!applicationInferenceProfile) {
const geoMap = await getInferenceProfileGeos()
const modelGeos = geoMap.get(modelId)
if (modelGeos?.length) {
let allCandidateGeos: string[]
if (useGlobalEndpoint && modelGeos.includes('global')) {
allCandidateGeos = ['global']
} else {
const candidates = regionToGeoCandidates(region || 'us-east-1')
allCandidateGeos = [...candidates]
if (modelGeos.includes('global')) allCandidateGeos.push('global')
if (modelGeos.includes('us') && !allCandidateGeos.includes('us')) allCandidateGeos.push('us')
}
for (const geo of allCandidateGeos) {
if (!modelGeos.includes(geo)) continue
const profileId = `${geo}.${modelId}`
if (availableProfiles) {
// Runtime discovery: only apply if confirmed in this region
if (availableProfiles.has(profileId)) {
applicationInferenceProfile = profileId
break
}
} else {
// No runtime data: trust models.json (backward compat)
applicationInferenceProfile = profileId
break
}
}
}
}
return { modelId, applicationInferenceProfile }
}
// ---------------------------------------------------------------------------
// Error normalization
// ---------------------------------------------------------------------------
/**
* Rewrites common Bedrock Converse runtime errors into actionable messages.
*
* Handled patterns:
* - "inference profile" / "on-demand throughput" → guides user to select model from dropdown
* - "doesn't support Converse" → suggests providing imported model ARN in Custom Model ARN field
* - "doesn't support ... field" → explains some models reject certain inference config params
*
* Returns the original error unchanged if no pattern matches.
*
* @see https://docs.aws.amazon.com/bedrock/latest/userguide/troubleshooting-api-error-codes.html
*/
export function normalizeBedrockError(err: unknown): Error {
if (!(err instanceof Error)) return err as Error
const msg = err.message ?? ''
// Model requires an inference profile
if (msg.includes('inference profile') || (msg.includes('ValidationException') && msg.includes('on-demand throughput'))) {
return new Error(
`This model requires a cross-region inference profile. ` +
`The system attempted to auto-apply one but none was available in this region. ` +
`For built-in models, select the model from the "Model Name" dropdown and choose a supported region. ` +
`Original error: ${msg}`
)
}
// Model does not support the Converse API
if (msg.includes("doesn't support") && msg.includes('Converse')) {
return new Error(
`This model does not support the Bedrock Converse API. ` +
`If this is an imported model, provide its full ARN in the "Custom Model ARN" field ` +
`— it will be auto-routed to the InvokeModel API. ` +
`Original error: ${msg}`
)
}
// Model doesn't support a specific field (e.g. stopSequences)
if (msg.includes("doesn't support") && msg.includes('field')) {
return new Error(
`This model rejected a request parameter. ` +
`Some Bedrock models do not support all inference configuration fields. ` +
`Original error: ${msg}`
)
}
return err
}
+1
View File
@@ -34,6 +34,7 @@
"@anthropic-ai/sdk": "^0.73.0",
"@apidevtools/json-schema-ref-parser": "^11.7.0",
"@arizeai/openinference-instrumentation-langchain": "^2.0.0",
"@aws-sdk/client-bedrock": "3.966.0",
"@aws-sdk/client-bedrock-runtime": "3.966.0",
"@aws-sdk/client-dynamodb": "^3.360.0",
"@aws-sdk/client-kendra": "^3.750.0",
@@ -471,7 +471,9 @@ const AgentFlowNode = ({ data }) => {
alt={item.model}
/>
<Typography sx={{ fontSize: '0.7rem', ml: 0.5 }}>
{item.config.modelName || item.config.model}
{item.config.customModel
? item.config.customModel.replace(/^arn:aws:bedrock:[^:]+:[^:]+:/, '')
: item.config.modelName || item.config.model}
</Typography>
</Box>
</Box>
+1023 -1126
View File
File diff suppressed because it is too large Load Diff