mirror of
https://github.com/FlowiseAI/Flowise.git
synced 2026-10-11 21:59:56 +00:00
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:
co-authored by
gemini-code-assist[bot]
Ankit
Henry
parent
aee37e16b1
commit
1449e80d34
+2
-1
@@ -118,4 +118,5 @@ apps/*/
|
||||
# Claude - session/user specific files
|
||||
.claude/plans/
|
||||
.claude/settings.local.json
|
||||
.claude/agent-memory/*
|
||||
.claude/agent-memory/*
|
||||
|
||||
|
||||
+270
-270
@@ -1,270 +1,270 @@
|
||||
# @flowiseai/agentflow
|
||||
|
||||
[](https://www.npmjs.com/package/@flowiseai/agentflow)
|
||||
[](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
|
||||
|
||||
[](https://www.npmjs.com/package/@flowiseai/agentflow)
|
||||
[](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(
|
||||
|
||||
+1002
-405
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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Generated
+1023
-1126
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user