mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[Security solution] AWS Bedrock connector (#166662)
This commit is contained in:
parent
107239c333
commit
bacebd27e0
77 changed files with 2732 additions and 708 deletions
|
@ -7,6 +7,10 @@ Connectors provide a central place to store connection information for services
|
|||
[cols="2"]
|
||||
|===
|
||||
|
||||
a| <<bedrock-action-type,AWS Bedrock>>
|
||||
|
||||
| Send a request to AWS Bedrock.
|
||||
|
||||
a| <<d3security-action-type,D3 Security>>
|
||||
|
||||
| Send a request to D3 Security.
|
||||
|
@ -15,10 +19,6 @@ a| <<email-action-type,Email>>
|
|||
|
||||
| Send email from your server.
|
||||
|
||||
a| <<gen-ai-action-type,Generative AI>>
|
||||
|
||||
| Send a request to OpenAI.
|
||||
|
||||
a| <<resilient-action-type,{ibm-r}>>
|
||||
|
||||
| Create an incident in {ibm-r}.
|
||||
|
@ -35,6 +35,10 @@ a| <<teams-action-type,Microsoft Teams>>
|
|||
|
||||
| Send a message to a Microsoft Teams channel.
|
||||
|
||||
a| <<gen-ai-action-type,OpenAI>>
|
||||
|
||||
| Send a request to OpenAI.
|
||||
|
||||
a| <<opsgenie-action-type,{opsgenie}>>
|
||||
|
||||
| Create or close an alert in {opsgenie}.
|
||||
|
|
68
docs/management/connectors/action-types/bedrock.asciidoc
Normal file
68
docs/management/connectors/action-types/bedrock.asciidoc
Normal file
|
@ -0,0 +1,68 @@
|
|||
[[bedrock-action-type]]
|
||||
== AWS Bedrock connector and action
|
||||
++++
|
||||
<titleabbrev>AWS Bedrock</titleabbrev>
|
||||
++++
|
||||
:frontmatter-description: Add a connector that can send requests to AWS Bedrock.
|
||||
:frontmatter-tags-products: [kibana]
|
||||
:frontmatter-tags-content-type: [how-to]
|
||||
:frontmatter-tags-user-goals: [configure]
|
||||
|
||||
|
||||
The AWS Bedrock connector uses https://github.com/axios/axios[axios] to send a POST request to AWS Bedrock. The connector uses the <<execute-connector-api,run connector API>> to send the request.
|
||||
|
||||
[float]
|
||||
[[define-bedrock-ui]]
|
||||
=== Create connectors in {kib}
|
||||
|
||||
You can create connectors in *{stack-manage-app} > {connectors-ui}*. For example:
|
||||
|
||||
[role="screenshot"]
|
||||
// TODO: need logo before screenshot
|
||||
image::management/connectors/images/bedrock-connector.png[AWS Bedrock connector]
|
||||
|
||||
[float]
|
||||
[[bedrock-connector-configuration]]
|
||||
==== Connector configuration
|
||||
|
||||
AWS Bedrock connectors have the following configuration properties:
|
||||
|
||||
Name:: The name of the connector.
|
||||
API URL:: The AWS Bedrock request URL.
|
||||
Default model:: The GAI model for AWS Bedrock to use. Current support is for the Anthropic Claude models, defaulting to Claude 2. The model can be set on a per request basis by including a "model" parameter alongside the request body.
|
||||
Region:: The AWS Bedrock request URL.
|
||||
Access Key:: The AWS access key for authentication.
|
||||
Secret:: The secret for authentication.
|
||||
|
||||
[float]
|
||||
[[bedrock-action-configuration]]
|
||||
=== Test connectors
|
||||
|
||||
You can test connectors with the <<execute-connector-api,run connector API>> or
|
||||
as you're creating or editing the connector in {kib}. For example:
|
||||
|
||||
[role="screenshot"]
|
||||
// TODO: need logo before screenshot
|
||||
image::management/connectors/images/bedrock-params.png[AWS Bedrock params test]
|
||||
|
||||
The AWS Bedrock actions have the following configuration properties.
|
||||
|
||||
Body:: A stringified JSON payload sent to the AWS Bedrock Invoke Model API URL. For example:
|
||||
+
|
||||
[source,text]
|
||||
--
|
||||
{
|
||||
body: JSON.stringify({
|
||||
prompt: `${combinedMessages} \n\nAssistant:`,
|
||||
max_tokens_to_sample: 300,
|
||||
stop_sequences: ['\n\nHuman:']
|
||||
})
|
||||
}
|
||||
--
|
||||
Model:: An optional string that will overwrite the connector's default model. For
|
||||
|
||||
[float]
|
||||
[[bedrock-connector-networking-configuration]]
|
||||
=== Connector networking configuration
|
||||
|
||||
Use the <<action-settings, Action configuration settings>> to customize connector networking configurations, such as proxies, certificates, or TLS settings. You can set configurations that apply to all your connectors or use `xpack.actions.customHostSettings` to set per-host configurations.
|
BIN
docs/management/connectors/images/bedrock-connector.png
Normal file
BIN
docs/management/connectors/images/bedrock-connector.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 296 KiB |
BIN
docs/management/connectors/images/bedrock-params.png
Normal file
BIN
docs/management/connectors/images/bedrock-params.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 193 KiB |
Binary file not shown.
Before Width: | Height: | Size: 222 KiB After Width: | Height: | Size: 259 KiB |
|
@ -1,3 +1,4 @@
|
|||
include::action-types/bedrock.asciidoc[leveloffset=+1]
|
||||
include::action-types/d3security.asciidoc[leveloffset=+1]
|
||||
include::action-types/email.asciidoc[leveloffset=+1]
|
||||
include::action-types/gen-ai.asciidoc[leveloffset=+1]
|
||||
|
|
|
@ -138,7 +138,7 @@ WARNING: This feature is available in {kib} 7.17.4 and 8.3.0 onwards but is not
|
|||
A boolean value indicating that a footer with a relevant link should be added to emails sent as alerting actions. Default: true.
|
||||
|
||||
`xpack.actions.enabledActionTypes` {ess-icon}::
|
||||
A list of action types that are enabled. It defaults to `["*"]`, enabling all types. The names for built-in {kib} action types are prefixed with a `.` and include: `.email`, `.index`, `.jira`, `.opsgenie`, `.pagerduty`, `.resilient`, `.server-log`, `.servicenow`, .`servicenow-itom`, `.servicenow-sir`, `.slack`, `.swimlane`, `.teams`, `.tines`, `.torq`, `.xmatters`, `.gen-ai`, `.d3security`, and `.webhook`. An empty list `[]` will disable all action types.
|
||||
A list of action types that are enabled. It defaults to `["*"]`, enabling all types. The names for built-in {kib} action types are prefixed with a `.` and include: `.email`, `.index`, `.jira`, `.opsgenie`, `.pagerduty`, `.resilient`, `.server-log`, `.servicenow`, .`servicenow-itom`, `.servicenow-sir`, `.slack`, `.swimlane`, `.teams`, `.tines`, `.torq`, `.xmatters`, `.gen-ai`, `.bedrock`, `.d3security`, and `.webhook`. An empty list `[]` will disable all action types.
|
||||
+
|
||||
Disabled action types will not appear as an option when creating new connectors, but existing connectors and actions of that type will remain in {kib} and will not function.
|
||||
|
||||
|
|
|
@ -842,6 +842,7 @@
|
|||
"antlr4ts": "^0.5.0-alpha.3",
|
||||
"archiver": "^5.3.1",
|
||||
"async": "^3.2.3",
|
||||
"aws4": "^1.12.0",
|
||||
"axios": "^1.4.0",
|
||||
"base64-js": "^1.3.1",
|
||||
"bitmap-sdf": "^1.0.3",
|
||||
|
@ -1272,6 +1273,7 @@
|
|||
"@types/adm-zip": "^0.5.0",
|
||||
"@types/archiver": "^5.3.1",
|
||||
"@types/async": "^3.2.3",
|
||||
"@types/aws4": "^1.5.0",
|
||||
"@types/babel__core": "^7.20.0",
|
||||
"@types/babel__generator": "^7.6.4",
|
||||
"@types/babel__helper-plugin-utils": "^7.10.0",
|
||||
|
|
|
@ -46,7 +46,7 @@ describe('fetchConnectorExecuteAction', () => {
|
|||
expect(mockHttp.fetch).toHaveBeenCalledWith(
|
||||
'/internal/elastic_assistant/actions/connector/foo/_execute',
|
||||
{
|
||||
body: '{"params":{"subActionParams":{"body":"{\\"model\\":\\"gpt-4\\",\\"messages\\":[{\\"role\\":\\"user\\",\\"content\\":\\"This is a test\\"}],\\"n\\":1,\\"stop\\":null,\\"temperature\\":0.2}"},"subAction":"test"}}',
|
||||
body: '{"params":{"subActionParams":{"model":"gpt-4","messages":[{"role":"user","content":"This is a test"}],"n":1,"stop":null,"temperature":0.2},"subAction":"invokeAI"}}',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST',
|
||||
signal: undefined,
|
||||
|
@ -65,7 +65,7 @@ describe('fetchConnectorExecuteAction', () => {
|
|||
await fetchConnectorExecuteAction(testProps);
|
||||
|
||||
expect(mockHttp.fetch).toHaveBeenCalledWith('/api/actions/connector/foo/_execute', {
|
||||
body: '{"params":{"subActionParams":{"body":"{\\"model\\":\\"gpt-4\\",\\"messages\\":[{\\"role\\":\\"user\\",\\"content\\":\\"This is a test\\"}],\\"n\\":1,\\"stop\\":null,\\"temperature\\":0.2}"},"subAction":"test"}}',
|
||||
body: '{"params":{"subActionParams":{"model":"gpt-4","messages":[{"role":"user","content":"This is a test"}],"n":1,"stop":null,"temperature":0.2},"subAction":"invokeAI"}}',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST',
|
||||
signal: undefined,
|
||||
|
@ -88,7 +88,7 @@ describe('fetchConnectorExecuteAction', () => {
|
|||
});
|
||||
|
||||
it('returns API_ERROR when there are no choices', async () => {
|
||||
(mockHttp.fetch as jest.Mock).mockResolvedValue({ status: 'ok', data: {} });
|
||||
(mockHttp.fetch as jest.Mock).mockResolvedValue({ status: 'ok', data: '' });
|
||||
const testProps: FetchConnectorExecuteAction = {
|
||||
assistantLangChain: false,
|
||||
http: mockHttp,
|
||||
|
@ -101,46 +101,12 @@ describe('fetchConnectorExecuteAction', () => {
|
|||
expect(result).toBe(API_ERROR);
|
||||
});
|
||||
|
||||
it('return the trimmed first `choices` `message` `content` when the API call is successful', async () => {
|
||||
(mockHttp.fetch as jest.Mock).mockResolvedValue({
|
||||
status: 'ok',
|
||||
data: {
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: ' Test response ', // leading and trailing whitespace
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const testProps: FetchConnectorExecuteAction = {
|
||||
assistantLangChain: false,
|
||||
http: mockHttp,
|
||||
messages,
|
||||
apiConfig,
|
||||
};
|
||||
|
||||
const result = await fetchConnectorExecuteAction(testProps);
|
||||
|
||||
expect(result).toBe('Test response');
|
||||
});
|
||||
|
||||
it('returns the value of the action_input property when assistantLangChain is true, and `content` has properly prefixed and suffixed JSON with the action_input property', async () => {
|
||||
const content = '```json\n{"action_input": "value from action_input"}\n```';
|
||||
|
||||
(mockHttp.fetch as jest.Mock).mockResolvedValue({
|
||||
status: 'ok',
|
||||
data: {
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
data: content,
|
||||
});
|
||||
|
||||
const testProps: FetchConnectorExecuteAction = {
|
||||
|
@ -160,15 +126,7 @@ describe('fetchConnectorExecuteAction', () => {
|
|||
|
||||
(mockHttp.fetch as jest.Mock).mockResolvedValue({
|
||||
status: 'ok',
|
||||
data: {
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
data: content,
|
||||
});
|
||||
|
||||
const testProps: FetchConnectorExecuteAction = {
|
||||
|
@ -188,15 +146,7 @@ describe('fetchConnectorExecuteAction', () => {
|
|||
|
||||
(mockHttp.fetch as jest.Mock).mockResolvedValue({
|
||||
status: 'ok',
|
||||
data: {
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
data: content,
|
||||
});
|
||||
|
||||
const testProps: FetchConnectorExecuteAction = {
|
||||
|
|
|
@ -44,15 +44,14 @@ export const fetchConnectorExecuteAction = async ({
|
|||
temperature: 0.2,
|
||||
}
|
||||
: {
|
||||
// Azure OpenAI and Bedrock invokeAI both expect this body format
|
||||
messages: outboundMessages,
|
||||
};
|
||||
|
||||
const requestBody = {
|
||||
params: {
|
||||
subActionParams: {
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
subAction: 'test',
|
||||
subActionParams: body,
|
||||
subAction: 'invokeAI',
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -61,29 +60,23 @@ export const fetchConnectorExecuteAction = async ({
|
|||
? `/internal/elastic_assistant/actions/connector/${apiConfig?.connectorId}/_execute`
|
||||
: `/api/actions/connector/${apiConfig?.connectorId}/_execute`;
|
||||
|
||||
// TODO: Find return type for this API
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const response = await http.fetch<any>(path, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
signal,
|
||||
});
|
||||
const response = await http.fetch<{ connector_id: string; status: string; data: string }>(
|
||||
path,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
signal,
|
||||
}
|
||||
);
|
||||
|
||||
const data = response.data;
|
||||
if (response.status !== 'ok') {
|
||||
if (response.status !== 'ok' || !response.data) {
|
||||
return API_ERROR;
|
||||
}
|
||||
|
||||
if (data.choices && data.choices.length > 0 && data.choices[0].message.content) {
|
||||
const result = data.choices[0].message.content.trim();
|
||||
|
||||
return assistantLangChain ? getFormattedMessageContent(result) : result;
|
||||
} else {
|
||||
return API_ERROR;
|
||||
}
|
||||
return assistantLangChain ? getFormattedMessageContent(response.data) : response.data;
|
||||
} catch (error) {
|
||||
return API_ERROR;
|
||||
}
|
||||
|
|
|
@ -110,7 +110,6 @@ export const AssistantTitle: React.FC<{
|
|||
<EuiFlexItem grow={false}>
|
||||
<ConnectorSelectorInline
|
||||
isDisabled={isDisabled || selectedConversation === undefined}
|
||||
onConnectorModalVisibilityChange={() => {}}
|
||||
selectedConnectorId={selectedConnectorId}
|
||||
selectedConversation={selectedConversation}
|
||||
/>
|
||||
|
|
|
@ -8,7 +8,6 @@
|
|||
import { EuiFormRow, EuiLink, EuiTitle, EuiText, EuiHorizontalRule, EuiSpacer } from '@elastic/eui';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
|
||||
import { ActionTypeRegistryContract } from '@kbn/triggers-actions-ui-plugin/public';
|
||||
import { HttpSetup } from '@kbn/core-http-browser';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/public/common';
|
||||
|
@ -27,7 +26,6 @@ import { useLoadConnectors } from '../../../connectorland/use_load_connectors';
|
|||
import { getGenAiConfig } from '../../../connectorland/helpers';
|
||||
|
||||
export interface ConversationSettingsProps {
|
||||
actionTypeRegistry: ActionTypeRegistryContract;
|
||||
allSystemPrompts: Prompt[];
|
||||
conversationSettings: UseAssistantContext['conversations'];
|
||||
defaultConnectorId?: string;
|
||||
|
@ -46,7 +44,6 @@ export interface ConversationSettingsProps {
|
|||
*/
|
||||
export const ConversationSettings: React.FC<ConversationSettingsProps> = React.memo(
|
||||
({
|
||||
actionTypeRegistry,
|
||||
allSystemPrompts,
|
||||
defaultConnectorId,
|
||||
defaultProvider,
|
||||
|
@ -250,10 +247,7 @@ export const ConversationSettings: React.FC<ConversationSettingsProps> = React.m
|
|||
}
|
||||
>
|
||||
<ConnectorSelector
|
||||
actionTypeRegistry={actionTypeRegistry}
|
||||
http={http}
|
||||
isDisabled={selectedConversation == null}
|
||||
onConnectorModalVisibilityChange={() => {}}
|
||||
onConnectorSelectionChange={handleOnConnectorSelectionChange}
|
||||
selectedConnectorId={selectedConnector?.id}
|
||||
/>
|
||||
|
|
|
@ -76,13 +76,8 @@ export const AssistantSettings: React.FC<Props> = React.memo(
|
|||
selectedConversation: defaultSelectedConversation,
|
||||
setSelectedConversationId,
|
||||
}) => {
|
||||
const {
|
||||
actionTypeRegistry,
|
||||
assistantLangChain,
|
||||
http,
|
||||
selectedSettingsTab,
|
||||
setSelectedSettingsTab,
|
||||
} = useAssistantContext();
|
||||
const { assistantLangChain, http, selectedSettingsTab, setSelectedSettingsTab } =
|
||||
useAssistantContext();
|
||||
const {
|
||||
conversationSettings,
|
||||
defaultAllow,
|
||||
|
@ -267,7 +262,6 @@ export const AssistantSettings: React.FC<Props> = React.memo(
|
|||
conversationSettings={conversationSettings}
|
||||
setUpdatedConversationSettings={setUpdatedConversationSettings}
|
||||
allSystemPrompts={systemPromptSettings}
|
||||
actionTypeRegistry={actionTypeRegistry}
|
||||
selectedConversation={selectedConversation}
|
||||
onSelectedConversationChange={onHandleSelectedConversationChange}
|
||||
http={http}
|
||||
|
|
|
@ -45,6 +45,7 @@ export interface ConversationTheme {
|
|||
export interface Conversation {
|
||||
apiConfig: {
|
||||
connectorId?: string;
|
||||
connectorTypeTitle?: string;
|
||||
defaultSystemPromptId?: string;
|
||||
provider?: OpenAiProviderType;
|
||||
model?: string;
|
||||
|
|
|
@ -8,61 +8,66 @@
|
|||
import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiSuperSelect, EuiText } from '@elastic/eui';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
ActionConnector,
|
||||
ActionTypeRegistryContract,
|
||||
} from '@kbn/triggers-actions-ui-plugin/public';
|
||||
import { ActionConnector, ActionType } from '@kbn/triggers-actions-ui-plugin/public';
|
||||
|
||||
import { HttpSetup } from '@kbn/core-http-browser';
|
||||
import { ConnectorAddModal } from '@kbn/triggers-actions-ui-plugin/public/common/constants';
|
||||
import { GEN_AI_CONNECTOR_ID } from '@kbn/stack-connectors-plugin/public/common';
|
||||
import { ActionTypeSelectorModal } from '../connector_selector_inline/action_type_selector_modal';
|
||||
import { useLoadConnectors } from '../use_load_connectors';
|
||||
import * as i18n from '../translations';
|
||||
import { useLoadActionTypes } from '../use_load_action_types';
|
||||
import { useAssistantContext } from '../../assistant_context';
|
||||
import { getGenAiConfig } from '../helpers';
|
||||
import { getActionTypeTitle, getGenAiConfig } from '../helpers';
|
||||
|
||||
export const ADD_NEW_CONNECTOR = 'ADD_NEW_CONNECTOR';
|
||||
|
||||
interface Props {
|
||||
actionTypeRegistry: ActionTypeRegistryContract;
|
||||
http: HttpSetup;
|
||||
isDisabled?: boolean;
|
||||
onConnectorSelectionChange: (connector: ActionConnector | undefined) => void;
|
||||
isOpen?: boolean;
|
||||
onConnectorSelectionChange: (connector: AIConnector) => void;
|
||||
selectedConnectorId?: string;
|
||||
onConnectorModalVisibilityChange?: (isVisible: boolean) => void;
|
||||
displayFancy?: (displayText: string) => React.ReactNode;
|
||||
setIsOpen?: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export type AIConnector = ActionConnector & {
|
||||
connectorTypeTitle: string;
|
||||
};
|
||||
|
||||
export const ConnectorSelector: React.FC<Props> = React.memo(
|
||||
({
|
||||
actionTypeRegistry,
|
||||
http,
|
||||
isDisabled = false,
|
||||
onConnectorModalVisibilityChange,
|
||||
isOpen = false,
|
||||
displayFancy,
|
||||
selectedConnectorId,
|
||||
onConnectorSelectionChange,
|
||||
setIsOpen,
|
||||
}) => {
|
||||
const { assistantAvailability } = useAssistantContext();
|
||||
const { actionTypeRegistry, http, assistantAvailability } = useAssistantContext();
|
||||
// Connector Modal State
|
||||
const [isConnectorModalVisible, setIsConnectorModalVisible] = useState<boolean>(false);
|
||||
const { data: actionTypes } = useLoadActionTypes({ http });
|
||||
const actionType = actionTypes?.find((at) => at.id === GEN_AI_CONNECTOR_ID) ?? {
|
||||
enabledInConfig: true,
|
||||
enabledInLicense: true,
|
||||
minimumLicenseRequired: 'platinum',
|
||||
supportedFeatureIds: ['general'],
|
||||
isSystemActionType: false,
|
||||
id: '.gen-ai',
|
||||
name: 'Generative AI',
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const [selectedActionType, setSelectedActionType] = useState<ActionType | null>(null);
|
||||
|
||||
const {
|
||||
data: connectors,
|
||||
isLoading: isLoadingActionTypes,
|
||||
isFetching: isFetchingActionTypes,
|
||||
data: connectorsWithoutActionContext,
|
||||
isLoading: isLoadingConnectors,
|
||||
isFetching: isFetchingConnectors,
|
||||
refetch: refetchConnectors,
|
||||
} = useLoadConnectors({ http });
|
||||
const isLoading = isLoadingActionTypes || isFetchingActionTypes;
|
||||
|
||||
const aiConnectors: AIConnector[] = useMemo(
|
||||
() =>
|
||||
connectorsWithoutActionContext
|
||||
? connectorsWithoutActionContext.map((c) => ({
|
||||
...c,
|
||||
connectorTypeTitle: getActionTypeTitle(actionTypeRegistry.get(c.actionTypeId)),
|
||||
}))
|
||||
: [],
|
||||
[actionTypeRegistry, connectorsWithoutActionContext]
|
||||
);
|
||||
|
||||
const isLoading = isLoadingConnectors || isFetchingConnectors;
|
||||
const localIsDisabled = isDisabled || !assistantAvailability.hasConnectorsReadPrivilege;
|
||||
|
||||
const addNewConnectorOption = useMemo(() => {
|
||||
|
@ -72,7 +77,12 @@ export const ConnectorSelector: React.FC<Props> = React.memo(
|
|||
dropdownDisplay: (
|
||||
<EuiFlexGroup gutterSize="none" key={ADD_NEW_CONNECTOR}>
|
||||
<EuiFlexItem grow={true}>
|
||||
<EuiButtonEmpty href="#" iconType="plus" size="xs">
|
||||
<EuiButtonEmpty
|
||||
data-test-subj="addNewConnectorButton"
|
||||
href="#"
|
||||
iconType="plus"
|
||||
size="xs"
|
||||
>
|
||||
{i18n.ADD_NEW_CONNECTOR}
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
|
@ -85,16 +95,17 @@ export const ConnectorSelector: React.FC<Props> = React.memo(
|
|||
};
|
||||
}, []);
|
||||
|
||||
const connectorOptions = useMemo(() => {
|
||||
return (
|
||||
connectors?.map((connector) => {
|
||||
const apiProvider = getGenAiConfig(connector)?.apiProvider;
|
||||
const connectorOptions = useMemo(
|
||||
() =>
|
||||
aiConnectors.map((connector) => {
|
||||
const connectorTypeTitle =
|
||||
getGenAiConfig(connector)?.apiProvider ?? connector.connectorTypeTitle;
|
||||
const connectorDetails = connector.isPreconfigured
|
||||
? i18n.PRECONFIGURED_CONNECTOR
|
||||
: apiProvider;
|
||||
: connectorTypeTitle;
|
||||
return {
|
||||
value: connector.id,
|
||||
inputDisplay: connector.name,
|
||||
inputDisplay: displayFancy ? displayFancy(connector.name) : connector.name,
|
||||
dropdownDisplay: (
|
||||
<React.Fragment key={connector.id}>
|
||||
<strong>{connector.name}</strong>
|
||||
|
@ -106,9 +117,9 @@ export const ConnectorSelector: React.FC<Props> = React.memo(
|
|||
</React.Fragment>
|
||||
),
|
||||
};
|
||||
}) ?? []
|
||||
);
|
||||
}, [connectors]);
|
||||
}),
|
||||
[aiConnectors, displayFancy]
|
||||
);
|
||||
|
||||
// Only include add new connector option if user has privilege
|
||||
const allConnectorOptions = useMemo(
|
||||
|
@ -120,22 +131,39 @@ export const ConnectorSelector: React.FC<Props> = React.memo(
|
|||
);
|
||||
|
||||
const cleanupAndCloseModal = useCallback(() => {
|
||||
onConnectorModalVisibilityChange?.(false);
|
||||
setIsOpen?.(false);
|
||||
setIsConnectorModalVisible(false);
|
||||
}, [onConnectorModalVisibilityChange]);
|
||||
setSelectedActionType(null);
|
||||
}, [setIsOpen]);
|
||||
|
||||
const [modalForceOpen, setModalForceOpen] = useState(isOpen);
|
||||
|
||||
const onChange = useCallback(
|
||||
(connectorId: string) => {
|
||||
if (connectorId === ADD_NEW_CONNECTOR) {
|
||||
onConnectorModalVisibilityChange?.(true);
|
||||
setModalForceOpen(false);
|
||||
setIsConnectorModalVisible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const connector = connectors?.find((c) => c.id === connectorId);
|
||||
onConnectorSelectionChange(connector);
|
||||
const connector = aiConnectors.find((c) => c.id === connectorId);
|
||||
if (connector) {
|
||||
onConnectorSelectionChange(connector);
|
||||
}
|
||||
},
|
||||
[connectors, onConnectorSelectionChange, onConnectorModalVisibilityChange]
|
||||
[aiConnectors, onConnectorSelectionChange]
|
||||
);
|
||||
|
||||
const onSaveConnector = useCallback(
|
||||
(connector: ActionConnector) => {
|
||||
onConnectorSelectionChange({
|
||||
...connector,
|
||||
connectorTypeTitle: getActionTypeTitle(actionTypeRegistry.get(connector.actionTypeId)),
|
||||
});
|
||||
refetchConnectors?.();
|
||||
cleanupAndCloseModal();
|
||||
},
|
||||
[actionTypeRegistry, cleanupAndCloseModal, onConnectorSelectionChange, refetchConnectors]
|
||||
);
|
||||
|
||||
return (
|
||||
|
@ -146,19 +174,24 @@ export const ConnectorSelector: React.FC<Props> = React.memo(
|
|||
disabled={localIsDisabled}
|
||||
hasDividers={true}
|
||||
isLoading={isLoading}
|
||||
isOpen={modalForceOpen}
|
||||
onChange={onChange}
|
||||
options={allConnectorOptions}
|
||||
valueOfSelected={selectedConnectorId ?? ''}
|
||||
/>
|
||||
{isConnectorModalVisible && (
|
||||
{isConnectorModalVisible && !selectedActionType && (
|
||||
<ActionTypeSelectorModal
|
||||
actionTypes={actionTypes}
|
||||
actionTypeRegistry={actionTypeRegistry}
|
||||
onClose={() => setIsConnectorModalVisible(false)}
|
||||
onSelect={(actionType: ActionType) => setSelectedActionType(actionType)}
|
||||
/>
|
||||
)}
|
||||
{isConnectorModalVisible && selectedActionType && (
|
||||
<ConnectorAddModal
|
||||
actionType={actionType}
|
||||
actionType={selectedActionType}
|
||||
onClose={cleanupAndCloseModal}
|
||||
postSaveEventHandler={(connector: ActionConnector) => {
|
||||
onConnectorSelectionChange(connector);
|
||||
refetchConnectors?.();
|
||||
cleanupAndCloseModal();
|
||||
}}
|
||||
postSaveEventHandler={onSaveConnector}
|
||||
actionTypeRegistry={actionTypeRegistry}
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiIcon,
|
||||
EuiKeyPadMenuItem,
|
||||
EuiModal,
|
||||
EuiModalBody,
|
||||
EuiModalHeader,
|
||||
EuiModalHeaderTitle,
|
||||
} from '@elastic/eui';
|
||||
import { ActionType } from '@kbn/actions-plugin/common';
|
||||
import { ActionTypeRegistryContract } from '@kbn/triggers-actions-ui-plugin/public';
|
||||
import * as i18n from '../translations';
|
||||
|
||||
interface Props {
|
||||
actionTypes?: ActionType[];
|
||||
actionTypeRegistry: ActionTypeRegistryContract;
|
||||
onClose: () => void;
|
||||
onSelect: (actionType: ActionType) => void;
|
||||
}
|
||||
|
||||
export const ActionTypeSelectorModal = ({
|
||||
actionTypes,
|
||||
actionTypeRegistry,
|
||||
onClose,
|
||||
onSelect,
|
||||
}: Props) =>
|
||||
actionTypes && actionTypes.length > 0 ? (
|
||||
<EuiModal onClose={onClose}>
|
||||
<EuiModalHeader>
|
||||
<EuiModalHeaderTitle>{i18n.INLINE_CONNECTOR_PLACEHOLDER}</EuiModalHeaderTitle>
|
||||
</EuiModalHeader>
|
||||
|
||||
<EuiModalBody>
|
||||
<EuiFlexGroup>
|
||||
{actionTypes.map((actionType: ActionType) => {
|
||||
const fullAction = actionTypeRegistry.get(actionType.id);
|
||||
return (
|
||||
<EuiFlexItem key={actionType.id} grow={false}>
|
||||
<EuiKeyPadMenuItem
|
||||
key={actionType.id}
|
||||
isDisabled={!actionType.enabled}
|
||||
label={actionType.name}
|
||||
onClick={() => onSelect(actionType)}
|
||||
>
|
||||
<EuiIcon size="xl" type={fullAction.iconClass} />
|
||||
</EuiKeyPadMenuItem>
|
||||
</EuiFlexItem>
|
||||
);
|
||||
})}
|
||||
</EuiFlexGroup>
|
||||
</EuiModalBody>
|
||||
</EuiModal>
|
||||
) : null;
|
|
@ -8,7 +8,6 @@
|
|||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
import { noop } from 'lodash/fp';
|
||||
import { TestProviders } from '../../mock/test_providers/test_providers';
|
||||
import { ConnectorSelectorInline } from './connector_selector_inline';
|
||||
import * as i18n from '../translations';
|
||||
|
@ -64,7 +63,6 @@ describe('ConnectorSelectorInline', () => {
|
|||
<TestProviders>
|
||||
<ConnectorSelectorInline
|
||||
isDisabled={false}
|
||||
onConnectorModalVisibilityChange={noop}
|
||||
selectedConnectorId={undefined}
|
||||
selectedConversation={undefined}
|
||||
/>
|
||||
|
@ -83,7 +81,6 @@ describe('ConnectorSelectorInline', () => {
|
|||
<TestProviders>
|
||||
<ConnectorSelectorInline
|
||||
isDisabled={false}
|
||||
onConnectorModalVisibilityChange={noop}
|
||||
selectedConnectorId={'missing-connector-id'}
|
||||
selectedConversation={conversation}
|
||||
/>
|
||||
|
@ -102,7 +99,6 @@ describe('ConnectorSelectorInline', () => {
|
|||
<TestProviders>
|
||||
<ConnectorSelectorInline
|
||||
isDisabled={false}
|
||||
onConnectorModalVisibilityChange={noop}
|
||||
selectedConnectorId={mockConnectors[0].id}
|
||||
selectedConversation={conversation}
|
||||
/>
|
||||
|
|
|
@ -5,31 +5,24 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiSuperSelect, EuiText } from '@elastic/eui';
|
||||
import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { ActionConnector } from '@kbn/triggers-actions-ui-plugin/public';
|
||||
|
||||
import { ConnectorAddModal } from '@kbn/triggers-actions-ui-plugin/public/common/constants';
|
||||
import {
|
||||
GEN_AI_CONNECTOR_ID,
|
||||
OpenAiProviderType,
|
||||
} from '@kbn/stack-connectors-plugin/public/common';
|
||||
import { css } from '@emotion/css/dist/emotion-css.cjs';
|
||||
import { AIConnector, ConnectorSelector } from '../connector_selector';
|
||||
import { Conversation } from '../../..';
|
||||
import { useLoadConnectors } from '../use_load_connectors';
|
||||
import * as i18n from '../translations';
|
||||
import { useLoadActionTypes } from '../use_load_action_types';
|
||||
import { useAssistantContext } from '../../assistant_context';
|
||||
import { useConversation } from '../../assistant/use_conversation';
|
||||
import { getGenAiConfig } from '../helpers';
|
||||
import { getActionTypeTitle, getGenAiConfig } from '../helpers';
|
||||
|
||||
export const ADD_NEW_CONNECTOR = 'ADD_NEW_CONNECTOR';
|
||||
|
||||
interface Props {
|
||||
isDisabled?: boolean;
|
||||
selectedConnectorId?: string;
|
||||
selectedConversation?: Conversation;
|
||||
onConnectorModalVisibilityChange?: (isVisible: boolean) => void;
|
||||
}
|
||||
|
||||
const inputContainerClassName = css`
|
||||
|
@ -69,131 +62,55 @@ const placeholderButtonClassName = css`
|
|||
`;
|
||||
|
||||
/**
|
||||
* A minimal and connected version of the ConnectorSelector component used in the Settings modal.
|
||||
* A compact wrapper of the ConnectorSelector component used in the Settings modal.
|
||||
*/
|
||||
export const ConnectorSelectorInline: React.FC<Props> = React.memo(
|
||||
({
|
||||
isDisabled = false,
|
||||
onConnectorModalVisibilityChange,
|
||||
selectedConnectorId,
|
||||
selectedConversation,
|
||||
}) => {
|
||||
({ isDisabled = false, selectedConnectorId, selectedConversation }) => {
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const { actionTypeRegistry, assistantAvailability, http } = useAssistantContext();
|
||||
const { setApiConfig } = useConversation();
|
||||
// Connector Modal State
|
||||
const [isConnectorModalVisible, setIsConnectorModalVisible] = useState<boolean>(false);
|
||||
const { data: actionTypes } = useLoadActionTypes({ http });
|
||||
const actionType = actionTypes?.find((at) => at.id === GEN_AI_CONNECTOR_ID) ?? {
|
||||
enabledInConfig: true,
|
||||
enabledInLicense: true,
|
||||
minimumLicenseRequired: 'platinum',
|
||||
supportedFeatureIds: ['general'],
|
||||
isSystemActionType: false,
|
||||
id: '.gen-ai',
|
||||
name: 'Generative AI',
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const {
|
||||
data: connectors,
|
||||
isLoading: isLoadingActionTypes,
|
||||
isFetching: isFetchingActionTypes,
|
||||
refetch: refetchConnectors,
|
||||
} = useLoadConnectors({ http });
|
||||
const isLoading = isLoadingActionTypes || isFetchingActionTypes;
|
||||
const selectedConnectorName =
|
||||
connectors?.find((c) => c.id === selectedConnectorId)?.name ??
|
||||
i18n.INLINE_CONNECTOR_PLACEHOLDER;
|
||||
const localIsDisabled = isDisabled || !assistantAvailability.hasConnectorsReadPrivilege;
|
||||
const { data: connectorsWithoutActionContext } = useLoadConnectors({ http });
|
||||
|
||||
const addNewConnectorOption = useMemo(() => {
|
||||
return {
|
||||
value: ADD_NEW_CONNECTOR,
|
||||
inputDisplay: i18n.ADD_NEW_CONNECTOR,
|
||||
dropdownDisplay: (
|
||||
<EuiFlexGroup gutterSize="none" key={ADD_NEW_CONNECTOR}>
|
||||
<EuiFlexItem grow={true}>
|
||||
<EuiButtonEmpty data-test-subj="addNewConnectorButton" iconType="plus" size="xs">
|
||||
{i18n.ADD_NEW_CONNECTOR}
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
{/* Right offset to compensate for 'selected' icon of EuiSuperSelect since native footers aren't supported*/}
|
||||
<div style={{ width: '24px' }} />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
),
|
||||
};
|
||||
}, []);
|
||||
|
||||
const connectorOptions = useMemo(() => {
|
||||
return (
|
||||
connectors?.map((connector) => {
|
||||
const apiProvider = getGenAiConfig(connector)?.apiProvider;
|
||||
const connectorDetails = connector.isPreconfigured
|
||||
? i18n.PRECONFIGURED_CONNECTOR
|
||||
: apiProvider;
|
||||
return {
|
||||
value: connector.id,
|
||||
inputDisplay: (
|
||||
<EuiText className={inputDisplayClassName} size="xs">
|
||||
{connector.name}
|
||||
</EuiText>
|
||||
),
|
||||
dropdownDisplay: (
|
||||
<React.Fragment key={connector.id}>
|
||||
<strong>{connector.name}</strong>
|
||||
{connectorDetails && (
|
||||
<EuiText size="xs" color="subdued">
|
||||
<p>{connectorDetails}</p>
|
||||
</EuiText>
|
||||
)}
|
||||
</React.Fragment>
|
||||
),
|
||||
};
|
||||
}) ?? []
|
||||
);
|
||||
}, [connectors]);
|
||||
|
||||
// Only include add new connector option if user has privilege
|
||||
const allConnectorOptions = useMemo(
|
||||
const aiConnectors: AIConnector[] = useMemo(
|
||||
() =>
|
||||
assistantAvailability.hasConnectorsAllPrivilege
|
||||
? [...connectorOptions, addNewConnectorOption]
|
||||
: [...connectorOptions],
|
||||
[addNewConnectorOption, assistantAvailability.hasConnectorsAllPrivilege, connectorOptions]
|
||||
connectorsWithoutActionContext
|
||||
? connectorsWithoutActionContext.map((c) => ({
|
||||
...c,
|
||||
connectorTypeTitle: getActionTypeTitle(actionTypeRegistry.get(c.actionTypeId)),
|
||||
}))
|
||||
: [],
|
||||
[actionTypeRegistry, connectorsWithoutActionContext]
|
||||
);
|
||||
|
||||
const cleanupAndCloseModal = useCallback(() => {
|
||||
onConnectorModalVisibilityChange?.(false);
|
||||
setIsConnectorModalVisible(false);
|
||||
}, [onConnectorModalVisibilityChange]);
|
||||
const selectedConnectorName =
|
||||
aiConnectors.find((c) => c.id === selectedConnectorId)?.name ??
|
||||
i18n.INLINE_CONNECTOR_PLACEHOLDER;
|
||||
const localIsDisabled = isDisabled || !assistantAvailability.hasConnectorsReadPrivilege;
|
||||
|
||||
const onConnectorClick = useCallback(() => {
|
||||
setIsOpen(!isOpen);
|
||||
}, [isOpen]);
|
||||
|
||||
const handleOnBlur = useCallback(() => setIsOpen(false), []);
|
||||
|
||||
const onChange = useCallback(
|
||||
(connectorId: string, apiProvider?: OpenAiProviderType, model?: string) => {
|
||||
setIsOpen(false);
|
||||
|
||||
(connector: AIConnector) => {
|
||||
const connectorId = connector.id;
|
||||
if (connectorId === ADD_NEW_CONNECTOR) {
|
||||
onConnectorModalVisibilityChange?.(true);
|
||||
setIsConnectorModalVisible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const connector = connectors?.find((c) => c.id === connectorId);
|
||||
const config = getGenAiConfig(connector);
|
||||
const apiProvider = config?.apiProvider;
|
||||
const model = config?.defaultModel;
|
||||
setIsOpen(false);
|
||||
|
||||
if (selectedConversation != null) {
|
||||
setApiConfig({
|
||||
conversationId: selectedConversation.id,
|
||||
apiConfig: {
|
||||
...selectedConversation.apiConfig,
|
||||
connectorId,
|
||||
connectorTypeTitle: connector.connectorTypeTitle,
|
||||
// With the inline component, prefer config args to handle 'new connector' case
|
||||
provider: apiProvider ?? config?.apiProvider,
|
||||
model: model ?? config?.defaultModel,
|
||||
|
@ -201,16 +118,7 @@ export const ConnectorSelectorInline: React.FC<Props> = React.memo(
|
|||
});
|
||||
}
|
||||
},
|
||||
[connectors, selectedConversation, onConnectorModalVisibilityChange, setApiConfig]
|
||||
);
|
||||
|
||||
const placeholderComponent = useMemo(
|
||||
() => (
|
||||
<EuiText color="default" size={'xs'}>
|
||||
{i18n.INLINE_CONNECTOR_PLACEHOLDER}
|
||||
</EuiText>
|
||||
),
|
||||
[]
|
||||
[selectedConversation, setApiConfig]
|
||||
);
|
||||
|
||||
return (
|
||||
|
@ -229,18 +137,17 @@ export const ConnectorSelectorInline: React.FC<Props> = React.memo(
|
|||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
{isOpen ? (
|
||||
<EuiSuperSelect
|
||||
aria-label={i18n.CONNECTOR_SELECTOR_TITLE}
|
||||
compressed={true}
|
||||
disabled={localIsDisabled}
|
||||
hasDividers={true}
|
||||
isLoading={isLoading}
|
||||
isOpen={isOpen}
|
||||
onBlur={handleOnBlur}
|
||||
onChange={onChange}
|
||||
options={allConnectorOptions}
|
||||
placeholder={placeholderComponent}
|
||||
valueOfSelected={selectedConnectorId}
|
||||
<ConnectorSelector
|
||||
displayFancy={(displayText) => (
|
||||
<EuiText className={inputDisplayClassName} size="xs">
|
||||
{displayText}
|
||||
</EuiText>
|
||||
)}
|
||||
isOpen
|
||||
isDisabled={localIsDisabled}
|
||||
selectedConnectorId={selectedConnectorId}
|
||||
setIsOpen={setIsOpen}
|
||||
onConnectorSelectionChange={onChange}
|
||||
/>
|
||||
) : (
|
||||
<span>
|
||||
|
@ -258,19 +165,6 @@ export const ConnectorSelectorInline: React.FC<Props> = React.memo(
|
|||
</EuiButtonEmpty>
|
||||
</span>
|
||||
)}
|
||||
{isConnectorModalVisible && (
|
||||
<ConnectorAddModal
|
||||
actionType={actionType}
|
||||
onClose={cleanupAndCloseModal}
|
||||
postSaveEventHandler={(connector: ActionConnector) => {
|
||||
const config = getGenAiConfig(connector);
|
||||
onChange(connector.id, config?.apiProvider, config?.defaultModel);
|
||||
refetchConnectors?.();
|
||||
cleanupAndCloseModal();
|
||||
}}
|
||||
actionTypeRegistry={actionTypeRegistry}
|
||||
/>
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
|
|
|
@ -10,11 +10,13 @@ import type { EuiCommentProps } from '@elastic/eui';
|
|||
import { EuiAvatar, EuiBadge, EuiMarkdownFormat, EuiText, EuiTextAlign } from '@elastic/eui';
|
||||
// eslint-disable-next-line @kbn/eslint/module_migration
|
||||
import styled from 'styled-components';
|
||||
import { ConnectorAddModal } from '@kbn/triggers-actions-ui-plugin/public/common/constants';
|
||||
import type { ActionConnector } from '@kbn/triggers-actions-ui-plugin/public';
|
||||
import {
|
||||
ActionConnector,
|
||||
ConnectorAddModal,
|
||||
} from '@kbn/triggers-actions-ui-plugin/public/common/constants';
|
||||
|
||||
import { ActionType } from '@kbn/triggers-actions-ui-plugin/public';
|
||||
import { GEN_AI_CONNECTOR_ID } from '@kbn/stack-connectors-plugin/public/common';
|
||||
import { ActionTypeSelectorModal } from '../connector_selector_inline/action_type_selector_modal';
|
||||
import { WELCOME_CONVERSATION } from '../../assistant/use_conversation/sample_conversations';
|
||||
import { Conversation, Message } from '../../..';
|
||||
import { useLoadActionTypes } from '../use_load_action_types';
|
||||
|
@ -26,7 +28,7 @@ import * as i18n from '../translations';
|
|||
import { useAssistantContext } from '../../assistant_context';
|
||||
import { useLoadConnectors } from '../use_load_connectors';
|
||||
import { AssistantAvatar } from '../../assistant/assistant_avatar/assistant_avatar';
|
||||
import { getGenAiConfig } from '../helpers';
|
||||
import { getActionTypeTitle, getGenAiConfig } from '../helpers';
|
||||
|
||||
const ConnectorButtonWrapper = styled.div`
|
||||
margin-bottom: 10px;
|
||||
|
@ -65,20 +67,8 @@ export const useConnectorSetup = ({
|
|||
return conversationHasNoPresentationData(conversation);
|
||||
});
|
||||
const { data: actionTypes } = useLoadActionTypes({ http });
|
||||
const actionType: ActionType = useMemo(
|
||||
() =>
|
||||
actionTypes?.find((at) => at.id === GEN_AI_CONNECTOR_ID) ?? {
|
||||
enabledInConfig: true,
|
||||
enabledInLicense: true,
|
||||
isSystemActionType: false,
|
||||
minimumLicenseRequired: 'platinum',
|
||||
supportedFeatureIds: ['general'],
|
||||
id: '.gen-ai',
|
||||
name: 'Generative AI',
|
||||
enabled: true,
|
||||
},
|
||||
[actionTypes]
|
||||
);
|
||||
|
||||
const [selectedActionType, setSelectedActionType] = useState<ActionType | null>(null);
|
||||
|
||||
// User constants
|
||||
const userName = useMemo(
|
||||
|
@ -190,6 +180,45 @@ export const useConnectorSetup = ({
|
|||
[assistantName, commentBody, conversation.messages, currentMessageIndex, userName]
|
||||
);
|
||||
|
||||
const onSaveConnector = useCallback(
|
||||
(connector: ActionConnector) => {
|
||||
const config = getGenAiConfig(connector);
|
||||
// add action type title to new connector
|
||||
const connectorTypeTitle = getActionTypeTitle(actionTypeRegistry.get(connector.actionTypeId));
|
||||
Object.values(conversations).forEach((c) => {
|
||||
setApiConfig({
|
||||
conversationId: c.id,
|
||||
apiConfig: {
|
||||
...c.apiConfig,
|
||||
connectorId: connector.id,
|
||||
connectorTypeTitle,
|
||||
provider: config?.apiProvider,
|
||||
model: config?.defaultModel,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
refetchConnectors?.();
|
||||
setIsConnectorModalVisible(false);
|
||||
appendMessage({
|
||||
conversationId: conversation.id,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: 'Connector setup complete!',
|
||||
timestamp: new Date().toLocaleString(),
|
||||
},
|
||||
});
|
||||
},
|
||||
[
|
||||
actionTypeRegistry,
|
||||
appendMessage,
|
||||
conversation.id,
|
||||
conversations,
|
||||
refetchConnectors,
|
||||
setApiConfig,
|
||||
]
|
||||
);
|
||||
|
||||
return {
|
||||
comments,
|
||||
prompt: (
|
||||
|
@ -212,36 +241,19 @@ export const useConnectorSetup = ({
|
|||
</EuiTextAlign>
|
||||
</SkipEuiText>
|
||||
)}
|
||||
{isConnectorModalVisible && (
|
||||
<ConnectorAddModal
|
||||
actionType={actionType}
|
||||
{isConnectorModalVisible && !selectedActionType && (
|
||||
<ActionTypeSelectorModal
|
||||
actionTypes={actionTypes}
|
||||
actionTypeRegistry={actionTypeRegistry}
|
||||
onClose={() => setIsConnectorModalVisible(false)}
|
||||
postSaveEventHandler={(connector: ActionConnector) => {
|
||||
const config = getGenAiConfig(connector);
|
||||
// Add connector to all conversations
|
||||
Object.values(conversations).forEach((c) => {
|
||||
setApiConfig({
|
||||
conversationId: c.id,
|
||||
apiConfig: {
|
||||
...c.apiConfig,
|
||||
connectorId: connector.id,
|
||||
provider: config?.apiProvider,
|
||||
model: config?.defaultModel,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
refetchConnectors?.();
|
||||
setIsConnectorModalVisible(false);
|
||||
appendMessage({
|
||||
conversationId: conversation.id,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: 'Connector setup complete!',
|
||||
timestamp: new Date().toLocaleString(),
|
||||
},
|
||||
});
|
||||
}}
|
||||
onSelect={(actionType: ActionType) => setSelectedActionType(actionType)}
|
||||
/>
|
||||
)}
|
||||
{isConnectorModalVisible && selectedActionType && (
|
||||
<ConnectorAddModal
|
||||
actionType={selectedActionType}
|
||||
onClose={() => setIsConnectorModalVisible(false)}
|
||||
postSaveEventHandler={onSaveConnector}
|
||||
actionTypeRegistry={actionTypeRegistry}
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
import type { ActionConnector } from '@kbn/triggers-actions-ui-plugin/public';
|
||||
import { ActionConnectorProps } from '@kbn/triggers-actions-ui-plugin/public/types';
|
||||
import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/gen_ai/constants';
|
||||
import { ActionTypeModel } from '@kbn/triggers-actions-ui-plugin/public';
|
||||
|
||||
interface GenAiConfig {
|
||||
apiProvider?: OpenAiProviderType;
|
||||
|
@ -29,3 +30,8 @@ export const getGenAiConfig = (connector: ActionConnector | undefined): GenAiCon
|
|||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const getActionTypeTitle = (actionType: ActionTypeModel): string => {
|
||||
// This is for types, it is always defined for the AI connectors
|
||||
return actionType.actionTypeTitle ?? actionType.id;
|
||||
};
|
||||
|
|
|
@ -34,11 +34,10 @@ export const useLoadConnectors = ({
|
|||
QUERY_KEY,
|
||||
async () => {
|
||||
const queryResult = await loadConnectors({ http });
|
||||
const filteredData = queryResult.filter(
|
||||
(connector) => !connector.isMissingSecrets && connector.actionTypeId === '.gen-ai'
|
||||
return queryResult.filter(
|
||||
(connector) =>
|
||||
!connector.isMissingSecrets && ['.bedrock', '.gen-ai'].includes(connector.actionTypeId)
|
||||
);
|
||||
|
||||
return filteredData;
|
||||
},
|
||||
{
|
||||
retry: false,
|
||||
|
|
|
@ -44,6 +44,12 @@ export const TestProvidersComponent: React.FC<Props> = ({
|
|||
providerContext,
|
||||
}) => {
|
||||
const actionTypeRegistry = actionTypeRegistryMock.create();
|
||||
actionTypeRegistry.get = jest.fn().mockReturnValue({
|
||||
id: '12345',
|
||||
actionTypeId: '.gen-ai',
|
||||
actionTypeTitle: 'OpenAI',
|
||||
iconClass: 'logoGenAI',
|
||||
});
|
||||
const mockGetComments = jest.fn(() => []);
|
||||
const mockHttp = httpServiceMock.createStartContract({ basePath: '/test' });
|
||||
const queryClient = new QueryClient({
|
||||
|
|
|
@ -5,32 +5,4 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A mock `data` property from an `actionResult` response, which is returned
|
||||
* from the `execute` method of the Actions plugin.
|
||||
*
|
||||
* Given the following example:
|
||||
*
|
||||
* ```ts
|
||||
* const actionResult = await actionsClient.execute(requestBody);
|
||||
* ```
|
||||
*
|
||||
* In the above example, `actionResult.data` would be this mock data.
|
||||
*/
|
||||
export const mockActionResultData = {
|
||||
id: 'chatcmpl-7sFVvksgFtMUac3pY5bTypFAKaGX1',
|
||||
object: 'chat.completion',
|
||||
created: 1693163703,
|
||||
model: 'gpt-4',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
finish_reason: 'stop',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: 'Yes, your name is Andrew. How can I assist you further, Andrew?',
|
||||
},
|
||||
},
|
||||
],
|
||||
usage: { completion_tokens: 16, prompt_tokens: 140, total_tokens: 156 },
|
||||
};
|
||||
export const mockActionResponse = 'Yes, your name is Andrew. How can I assist you further, Andrew?';
|
||||
|
|
|
@ -8,9 +8,9 @@
|
|||
import { KibanaRequest } from '@kbn/core/server';
|
||||
import { PluginStartContract as ActionsPluginStart } from '@kbn/actions-plugin/server';
|
||||
|
||||
import { ResponseBody } from '../helpers';
|
||||
import { ResponseBody } from '../types';
|
||||
import { ActionsClientLlm } from '../llm/actions_client_llm';
|
||||
import { mockActionResultData } from '../../../__mocks__/action_result_data';
|
||||
import { mockActionResponse } from '../../../__mocks__/action_result_data';
|
||||
import { langChainMessages } from '../../../__mocks__/lang_chain_messages';
|
||||
import { callAgentExecutor } from '.';
|
||||
import { loggerMock } from '@kbn/logging-mocks';
|
||||
|
@ -55,7 +55,7 @@ describe('callAgentExecutor', () => {
|
|||
|
||||
ActionsClientLlm.prototype.getActionResultData = jest
|
||||
.fn()
|
||||
.mockReturnValueOnce(mockActionResultData);
|
||||
.mockReturnValueOnce(mockActionResponse);
|
||||
});
|
||||
|
||||
it('creates an instance of ActionsClientLlm with the expected context from the request', async () => {
|
||||
|
@ -120,7 +120,7 @@ describe('callAgentExecutor', () => {
|
|||
|
||||
expect(result).toEqual({
|
||||
connector_id: 'mock-connector-id',
|
||||
data: mockActionResultData,
|
||||
data: mockActionResponse,
|
||||
status: 'ok',
|
||||
});
|
||||
});
|
||||
|
|
|
@ -14,7 +14,7 @@ import { BaseMessage } from 'langchain/schema';
|
|||
import { ChainTool, Tool } from 'langchain/tools';
|
||||
|
||||
import { ElasticsearchStore } from '../elasticsearch_store/elasticsearch_store';
|
||||
import { ResponseBody } from '../helpers';
|
||||
import { RequestBody, ResponseBody } from '../types';
|
||||
import { ActionsClientLlm } from '../llm/actions_client_llm';
|
||||
import { KNOWLEDGE_BASE_INDEX_PATTERN } from '../../../routes/knowledge_base/constants';
|
||||
|
||||
|
@ -31,8 +31,7 @@ export const callAgentExecutor = async ({
|
|||
esClient: ElasticsearchClient;
|
||||
langChainMessages: BaseMessage[];
|
||||
logger: Logger;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
request: KibanaRequest<unknown, unknown, any, any>;
|
||||
request: KibanaRequest<unknown, unknown, RequestBody>;
|
||||
}): Promise<ResponseBody> => {
|
||||
const llm = new ActionsClientLlm({ actions, connectorId, request, logger });
|
||||
|
||||
|
|
|
@ -8,12 +8,7 @@
|
|||
import type { Message } from '@kbn/elastic-assistant';
|
||||
import { AIMessage, BaseMessage, HumanMessage, SystemMessage } from 'langchain/schema';
|
||||
|
||||
import {
|
||||
getLangChainMessage,
|
||||
getLangChainMessages,
|
||||
getMessageContentAndRole,
|
||||
unsafeGetAssistantMessagesFromRequest,
|
||||
} from './helpers';
|
||||
import { getLangChainMessage, getLangChainMessages, getMessageContentAndRole } from './helpers';
|
||||
import { langChainMessages } from '../../__mocks__/lang_chain_messages';
|
||||
|
||||
describe('helpers', () => {
|
||||
|
@ -110,76 +105,4 @@ describe('helpers', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('unsafeGetAssistantMessagesFromRequest', () => {
|
||||
const rawSubActionParamsBody = {
|
||||
messages: [
|
||||
{ role: 'user', content: '\n\n\n\nWhat is my name?' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content:
|
||||
"Hello! Since we are communicating through text, I do not have the information about your name. Please feel free to share your name with me, if you'd like.",
|
||||
},
|
||||
{ role: 'user', content: '\n\nMy name is Andrew' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content:
|
||||
"Hi, Andrew! It's nice to meet you. How can I help you or what would you like to talk about today?",
|
||||
},
|
||||
{ role: 'user', content: '\n\nDo you know my name?' },
|
||||
],
|
||||
};
|
||||
|
||||
it('returns the expected assistant messages from a conversation', () => {
|
||||
const result = unsafeGetAssistantMessagesFromRequest(JSON.stringify(rawSubActionParamsBody));
|
||||
|
||||
const expected = [
|
||||
{ role: 'user', content: '\n\n\n\nWhat is my name?' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content:
|
||||
"Hello! Since we are communicating through text, I do not have the information about your name. Please feel free to share your name with me, if you'd like.",
|
||||
},
|
||||
{ role: 'user', content: '\n\nMy name is Andrew' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content:
|
||||
"Hi, Andrew! It's nice to meet you. How can I help you or what would you like to talk about today?",
|
||||
},
|
||||
{ role: 'user', content: '\n\nDo you know my name?' },
|
||||
];
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('returns an empty array when the rawSubActionParamsBody is undefined', () => {
|
||||
const result = unsafeGetAssistantMessagesFromRequest(undefined);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an empty array when the rawSubActionParamsBody messages[] array is empty', () => {
|
||||
const hasEmptyMessages = {
|
||||
messages: [],
|
||||
};
|
||||
|
||||
const result = unsafeGetAssistantMessagesFromRequest(JSON.stringify(hasEmptyMessages));
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an empty array when the rawSubActionParamsBody shape is unexpected', () => {
|
||||
const unexpected = { invalidKey: 'some_value' };
|
||||
|
||||
const result = unsafeGetAssistantMessagesFromRequest(JSON.stringify(unexpected));
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an empty array when the rawSubActionParamsBody is invalid JSON', () => {
|
||||
const result = unsafeGetAssistantMessagesFromRequest('[]');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -31,27 +31,3 @@ export const getMessageContentAndRole = (prompt: string): Pick<Message, 'content
|
|||
content: prompt,
|
||||
role: 'user',
|
||||
});
|
||||
|
||||
export interface ResponseBody {
|
||||
status: string;
|
||||
data: Record<string, unknown>;
|
||||
connector_id: string;
|
||||
}
|
||||
|
||||
/** An unsafe, temporary stub that parses assistant messages from the request with no validation */
|
||||
export const unsafeGetAssistantMessagesFromRequest = (
|
||||
rawSubActionParamsBody: string | undefined
|
||||
): Array<Pick<Message, 'content' | 'role'>> => {
|
||||
try {
|
||||
if (rawSubActionParamsBody == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const subActionParamsBody = JSON.parse(rawSubActionParamsBody); // TODO: unsafe, no validation
|
||||
const messages = subActionParamsBody?.messages;
|
||||
|
||||
return Array.isArray(messages) ? messages : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
|
|
@ -10,12 +10,13 @@ import type { PluginStartContract as ActionsPluginStart } from '@kbn/actions-plu
|
|||
import { loggerMock } from '@kbn/logging-mocks';
|
||||
|
||||
import { ActionsClientLlm } from './actions_client_llm';
|
||||
import { mockActionResultData } from '../../../__mocks__/action_result_data';
|
||||
import { mockActionResponse } from '../../../__mocks__/action_result_data';
|
||||
import { RequestBody } from '../types';
|
||||
|
||||
const connectorId = 'mock-connector-id';
|
||||
|
||||
const mockExecute = jest.fn().mockImplementation(() => ({
|
||||
data: mockActionResultData,
|
||||
data: mockActionResponse,
|
||||
status: 'ok',
|
||||
}));
|
||||
|
||||
|
@ -27,19 +28,31 @@ const mockActions = {
|
|||
})),
|
||||
} as unknown as ActionsPluginStart;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const mockRequest: KibanaRequest<unknown, unknown, any, any> = {
|
||||
const mockRequest: KibanaRequest<unknown, unknown, RequestBody> = {
|
||||
params: { connectorId },
|
||||
body: {
|
||||
params: {
|
||||
subActionParams: {
|
||||
body: '{"messages":[{"role":"user","content":"\\n\\n\\n\\nWhat is my name?"},{"role":"assistant","content":"I\'m sorry, but I don\'t have the information about your name. You can tell me your name if you\'d like, and we can continue our conversation from there."},{"role":"user","content":"\\n\\nMy name is Andrew"},{"role":"assistant","content":"Hello, Andrew! It\'s nice to meet you. What would you like to talk about today?"},{"role":"user","content":"\\n\\nDo you know my name?"}]}',
|
||||
messages: [
|
||||
{ role: 'user', content: '\\n\\n\\n\\nWhat is my name?' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content:
|
||||
"I'm sorry, but I don't have the information about your name. You can tell me your name if you'd like, and we can continue our conversation from there.",
|
||||
},
|
||||
{ role: 'user', content: '\\n\\nMy name is Andrew' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content:
|
||||
"Hello, Andrew! It's nice to meet you. What would you like to talk about today?",
|
||||
},
|
||||
{ role: 'user', content: '\\n\\nDo you know my name?' },
|
||||
],
|
||||
},
|
||||
subAction: 'test',
|
||||
subAction: 'invokeAI',
|
||||
},
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as KibanaRequest<unknown, unknown, any, any>;
|
||||
} as KibanaRequest<unknown, unknown, RequestBody>;
|
||||
|
||||
const prompt = 'Do you know my name?';
|
||||
|
||||
|
@ -59,7 +72,7 @@ describe('ActionsClientLlm', () => {
|
|||
|
||||
await actionsClientLlm._call(prompt); // ignore the result
|
||||
|
||||
expect(actionsClientLlm.getActionResultData()).toEqual(mockActionResultData);
|
||||
expect(actionsClientLlm.getActionResultData()).toEqual(mockActionResponse);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -116,23 +129,7 @@ describe('ActionsClientLlm', () => {
|
|||
});
|
||||
|
||||
it('rejects with the expected error the message has invalid content', async () => {
|
||||
const invalidContent = {
|
||||
id: 'chatcmpl-7sFVvksgFtMUac3pY5bTypFAKaGX1',
|
||||
object: 'chat.completion',
|
||||
created: 1693163703,
|
||||
model: 'gpt-4',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
finish_reason: 'stop',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: 1234, // <-- invalid content
|
||||
},
|
||||
},
|
||||
],
|
||||
usage: { completion_tokens: 16, prompt_tokens: 140, total_tokens: 156 },
|
||||
};
|
||||
const invalidContent = 1234;
|
||||
|
||||
mockExecute.mockImplementation(() => ({
|
||||
data: invalidContent,
|
||||
|
@ -147,34 +144,7 @@ describe('ActionsClientLlm', () => {
|
|||
});
|
||||
|
||||
expect(actionsClientLlm._call(prompt)).rejects.toThrowError(
|
||||
'ActionsClientLlm: choices[0] message content should be a string, but it had an unexpected type: number'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects with the expected error when choices is empty', async () => {
|
||||
const invalidContent = {
|
||||
id: 'chatcmpl-7sFVvksgFtMUac3pY5bTypFAKaGX1',
|
||||
object: 'chat.completion',
|
||||
created: 1693163703,
|
||||
model: 'gpt-4',
|
||||
choices: [], // <-- empty choices
|
||||
usage: { completion_tokens: 16, prompt_tokens: 140, total_tokens: 156 },
|
||||
};
|
||||
|
||||
mockExecute.mockImplementation(() => ({
|
||||
data: invalidContent,
|
||||
status: 'ok',
|
||||
}));
|
||||
|
||||
const actionsClientLlm = new ActionsClientLlm({
|
||||
actions: mockActions,
|
||||
connectorId,
|
||||
logger: mockLogger,
|
||||
request: mockRequest,
|
||||
});
|
||||
|
||||
expect(actionsClientLlm._call(prompt)).rejects.toThrowError(
|
||||
'ActionsClientLlm: choices is expected to be an non-empty array'
|
||||
'ActionsClientLlm: content should be a string, but it had an unexpected type: number'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -11,6 +11,7 @@ import { LLM } from 'langchain/llms/base';
|
|||
import { get } from 'lodash/fp';
|
||||
|
||||
import { getMessageContentAndRole } from '../helpers';
|
||||
import { RequestBody } from '../types';
|
||||
|
||||
const LLM_TYPE = 'ActionsClientLlm';
|
||||
|
||||
|
@ -18,10 +19,8 @@ export class ActionsClientLlm extends LLM {
|
|||
#actions: ActionsPluginStart;
|
||||
#connectorId: string;
|
||||
#logger: Logger;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
#request: KibanaRequest<unknown, unknown, any, any>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
#actionResultData: Record<string, any>;
|
||||
#request: KibanaRequest<unknown, unknown, RequestBody>;
|
||||
#actionResultData: string;
|
||||
|
||||
constructor({
|
||||
actions,
|
||||
|
@ -32,8 +31,7 @@ export class ActionsClientLlm extends LLM {
|
|||
actions: ActionsPluginStart;
|
||||
connectorId: string;
|
||||
logger: Logger;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
request: KibanaRequest<unknown, unknown, any, any>;
|
||||
request: KibanaRequest<unknown, unknown, RequestBody>;
|
||||
}) {
|
||||
super({});
|
||||
|
||||
|
@ -41,11 +39,10 @@ export class ActionsClientLlm extends LLM {
|
|||
this.#connectorId = connectorId;
|
||||
this.#logger = logger;
|
||||
this.#request = request;
|
||||
this.#actionResultData = {};
|
||||
this.#actionResultData = '';
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
getActionResultData(): Record<string, any> {
|
||||
getActionResultData(): string {
|
||||
return this.#actionResultData;
|
||||
}
|
||||
|
||||
|
@ -59,7 +56,6 @@ export class ActionsClientLlm extends LLM {
|
|||
this.#logger.debug(
|
||||
`ActionsClientLlm#_call assistantMessage:\n ${JSON.stringify(assistantMessage)} `
|
||||
);
|
||||
|
||||
// create a new connector request body with the assistant message:
|
||||
const requestBody = {
|
||||
actionId: this.#connectorId,
|
||||
|
@ -67,7 +63,7 @@ export class ActionsClientLlm extends LLM {
|
|||
...this.#request.body.params, // the original request body params
|
||||
subActionParams: {
|
||||
...this.#request.body.params.subActionParams, // the original request body params.subActionParams
|
||||
body: JSON.stringify({ messages: [assistantMessage] }),
|
||||
messages: [assistantMessage], // the assistant message
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -83,24 +79,16 @@ export class ActionsClientLlm extends LLM {
|
|||
);
|
||||
}
|
||||
|
||||
const choices = get('data.choices', actionResult);
|
||||
// TODO: handle errors from the connector
|
||||
const content = get('data', actionResult);
|
||||
|
||||
if (Array.isArray(choices) && choices.length > 0) {
|
||||
// get the raw content from the first choice, because _call must return a string
|
||||
const content: string | undefined = choices[0]?.message?.content;
|
||||
|
||||
if (typeof content !== 'string') {
|
||||
throw new Error(
|
||||
`${LLM_TYPE}: choices[0] message content should be a string, but it had an unexpected type: ${typeof content}`
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
this.#actionResultData = actionResult.data as Record<string, any>; // save the raw response from the connector, because that's what the assistant expects
|
||||
|
||||
return content; // per the contact of _call, return a string
|
||||
} else {
|
||||
throw new Error(`${LLM_TYPE}: choices is expected to be an non-empty array`);
|
||||
if (typeof content !== 'string') {
|
||||
throw new Error(
|
||||
`${LLM_TYPE}: content should be a string, but it had an unexpected type: ${typeof content}`
|
||||
);
|
||||
}
|
||||
this.#actionResultData = content; // save the raw response from the connector, because that's what the assistant expects
|
||||
|
||||
return content; // per the contact of _call, return a string
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { PostActionsConnectorExecuteBodyInputs } from '../../schemas/post_actions_connector_execute';
|
||||
|
||||
export type RequestBody = PostActionsConnectorExecuteBodyInputs;
|
||||
|
||||
export interface ResponseBody {
|
||||
status: string;
|
||||
data: string;
|
||||
connector_id: string;
|
||||
}
|
|
@ -9,7 +9,7 @@ import { ElasticsearchClient, IRouter, KibanaRequest, Logger } from '@kbn/core/s
|
|||
import type { PluginStartContract as ActionsPluginStart } from '@kbn/actions-plugin/server';
|
||||
import { BaseMessage } from 'langchain/schema';
|
||||
|
||||
import { mockActionResultData } from '../__mocks__/action_result_data';
|
||||
import { mockActionResponse } from '../__mocks__/action_result_data';
|
||||
import { postActionsConnectorExecuteRoute } from './post_actions_connector_execute';
|
||||
import { ElasticAssistantRequestHandlerContext } from '../types';
|
||||
import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks';
|
||||
|
@ -35,7 +35,7 @@ jest.mock('../lib/langchain/execute_custom_llm_chain', () => ({
|
|||
if (connectorId === 'mock-connector-id') {
|
||||
return {
|
||||
connector_id: 'mock-connector-id',
|
||||
data: mockActionResultData,
|
||||
data: mockActionResponse,
|
||||
status: 'ok',
|
||||
};
|
||||
} else {
|
||||
|
@ -62,9 +62,23 @@ const mockRequest = {
|
|||
body: {
|
||||
params: {
|
||||
subActionParams: {
|
||||
body: '{"messages":[{"role":"user","content":"\\n\\n\\n\\nWhat is my name?"},{"role":"assistant","content":"I\'m sorry, but I don\'t have the information about your name. You can tell me your name if you\'d like, and we can continue our conversation from there."},{"role":"user","content":"\\n\\nMy name is Andrew"},{"role":"assistant","content":"Hello, Andrew! It\'s nice to meet you. What would you like to talk about today?"},{"role":"user","content":"\\n\\nDo you know my name?"}]}',
|
||||
messages: [
|
||||
{ role: 'user', content: '\\n\\n\\n\\nWhat is my name?' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content:
|
||||
"I'm sorry, but I don't have the information about your name. You can tell me your name if you'd like, and we can continue our conversation from there.",
|
||||
},
|
||||
{ role: 'user', content: '\\n\\nMy name is Andrew' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content:
|
||||
"Hello, Andrew! It's nice to meet you. What would you like to talk about today?",
|
||||
},
|
||||
{ role: 'user', content: '\\n\\nDo you know my name?' },
|
||||
],
|
||||
},
|
||||
subAction: 'test',
|
||||
subAction: 'invokeAI',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -87,7 +101,7 @@ describe('postActionsConnectorExecuteRoute', () => {
|
|||
expect(result).toEqual({
|
||||
body: {
|
||||
connector_id: 'mock-connector-id',
|
||||
data: mockActionResultData,
|
||||
data: mockActionResponse,
|
||||
status: 'ok',
|
||||
},
|
||||
});
|
||||
|
|
|
@ -9,10 +9,7 @@ import { IRouter, Logger } from '@kbn/core/server';
|
|||
import { transformError } from '@kbn/securitysolution-es-utils';
|
||||
|
||||
import { POST_ACTIONS_CONNECTOR_EXECUTE } from '../../common/constants';
|
||||
import {
|
||||
getLangChainMessages,
|
||||
unsafeGetAssistantMessagesFromRequest,
|
||||
} from '../lib/langchain/helpers';
|
||||
import { getLangChainMessages } from '../lib/langchain/helpers';
|
||||
import { buildResponse } from '../lib/build_response';
|
||||
import { buildRouteValidation } from '../schemas/common';
|
||||
import {
|
||||
|
@ -39,7 +36,6 @@ export const postActionsConnectorExecuteRoute = (
|
|||
|
||||
try {
|
||||
const connectorId = decodeURIComponent(request.params.connectorId);
|
||||
const rawSubActionParamsBody = request.body.params.subActionParams.body;
|
||||
|
||||
// get the actions plugin start contract from the request context:
|
||||
const actions = (await context.elasticAssistant).actions;
|
||||
|
@ -47,11 +43,10 @@ export const postActionsConnectorExecuteRoute = (
|
|||
// get a scoped esClient for assistant memory
|
||||
const esClient = (await context.core).elasticsearch.client.asCurrentUser;
|
||||
|
||||
// get the assistant messages from the request body:
|
||||
const assistantMessages = unsafeGetAssistantMessagesFromRequest(rawSubActionParamsBody);
|
||||
|
||||
// convert the assistant messages to LangChain messages:
|
||||
const langChainMessages = getLangChainMessages(assistantMessages);
|
||||
const langChainMessages = getLangChainMessages(
|
||||
request.body.params.subActionParams.messages
|
||||
);
|
||||
|
||||
const langChainResponseBody = await callAgentExecutor({
|
||||
actions,
|
||||
|
|
|
@ -15,9 +15,23 @@ export const PostActionsConnectorExecutePathParams = t.type({
|
|||
/** Validates the body of a POST request to the `/actions/connector/{connector_id}/_execute` endpoint */
|
||||
export const PostActionsConnectorExecuteBody = t.type({
|
||||
params: t.type({
|
||||
subActionParams: t.type({
|
||||
body: t.string,
|
||||
}),
|
||||
subActionParams: t.intersection([
|
||||
t.type({
|
||||
messages: t.array(
|
||||
t.type({
|
||||
// must match ConversationRole from '@kbn/elastic-assistant
|
||||
role: t.union([t.literal('system'), t.literal('user'), t.literal('assistant')]),
|
||||
content: t.string,
|
||||
})
|
||||
),
|
||||
}),
|
||||
t.partial({
|
||||
model: t.string,
|
||||
n: t.number,
|
||||
stop: t.union([t.string, t.array(t.string), t.null]),
|
||||
temperature: t.number,
|
||||
}),
|
||||
]),
|
||||
subAction: t.string,
|
||||
}),
|
||||
});
|
||||
|
|
25
x-pack/plugins/stack_connectors/common/bedrock/constants.ts
Normal file
25
x-pack/plugins/stack_connectors/common/bedrock/constants.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export const BEDROCK_TITLE = i18n.translate(
|
||||
'xpack.stackConnectors.components.bedrock.connectorTypeTitle',
|
||||
{
|
||||
defaultMessage: 'AWS Bedrock',
|
||||
}
|
||||
);
|
||||
export const BEDROCK_CONNECTOR_ID = '.bedrock';
|
||||
export enum SUB_ACTION {
|
||||
RUN = 'run',
|
||||
INVOKE_AI = 'invokeAI',
|
||||
TEST = 'test',
|
||||
}
|
||||
|
||||
export const DEFAULT_BEDROCK_MODEL = 'anthropic.claude-v2';
|
||||
|
||||
export const DEFAULT_BEDROCK_URL = `https://bedrock.us-east-1.amazonaws.com` as const;
|
45
x-pack/plugins/stack_connectors/common/bedrock/schema.ts
Normal file
45
x-pack/plugins/stack_connectors/common/bedrock/schema.ts
Normal file
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import { DEFAULT_BEDROCK_MODEL } from './constants';
|
||||
|
||||
// Connector schema
|
||||
export const ConfigSchema = schema.object({
|
||||
apiUrl: schema.string(),
|
||||
defaultModel: schema.string({ defaultValue: DEFAULT_BEDROCK_MODEL }),
|
||||
});
|
||||
|
||||
export const SecretsSchema = schema.object({
|
||||
accessKey: schema.string(),
|
||||
secret: schema.string(),
|
||||
});
|
||||
|
||||
export const RunActionParamsSchema = schema.object({
|
||||
body: schema.string(),
|
||||
model: schema.maybe(schema.string()),
|
||||
});
|
||||
|
||||
export const InvokeAIActionParamsSchema = schema.object({
|
||||
messages: schema.arrayOf(
|
||||
schema.object({
|
||||
role: schema.string(),
|
||||
content: schema.string(),
|
||||
})
|
||||
),
|
||||
model: schema.maybe(schema.string()),
|
||||
});
|
||||
|
||||
export const InvokeAIActionResponseSchema = schema.string();
|
||||
|
||||
export const RunActionResponseSchema = schema.object(
|
||||
{
|
||||
completion: schema.string(),
|
||||
stop_reason: schema.maybe(schema.string()),
|
||||
},
|
||||
{ unknowns: 'ignore' }
|
||||
);
|
23
x-pack/plugins/stack_connectors/common/bedrock/types.ts
Normal file
23
x-pack/plugins/stack_connectors/common/bedrock/types.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { TypeOf } from '@kbn/config-schema';
|
||||
import {
|
||||
ConfigSchema,
|
||||
SecretsSchema,
|
||||
RunActionParamsSchema,
|
||||
RunActionResponseSchema,
|
||||
InvokeAIActionParamsSchema,
|
||||
InvokeAIActionResponseSchema,
|
||||
} from './schema';
|
||||
|
||||
export type Config = TypeOf<typeof ConfigSchema>;
|
||||
export type Secrets = TypeOf<typeof SecretsSchema>;
|
||||
export type RunActionParams = TypeOf<typeof RunActionParamsSchema>;
|
||||
export type InvokeAIActionParams = TypeOf<typeof InvokeAIActionParamsSchema>;
|
||||
export type InvokeAIActionResponse = TypeOf<typeof InvokeAIActionResponseSchema>;
|
||||
export type RunActionResponse = TypeOf<typeof RunActionResponseSchema>;
|
|
@ -7,15 +7,16 @@
|
|||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export const GEN_AI_TITLE = i18n.translate(
|
||||
export const OPEN_AI_TITLE = i18n.translate(
|
||||
'xpack.stackConnectors.components.genAi.connectorTypeTitle',
|
||||
{
|
||||
defaultMessage: 'Generative AI',
|
||||
defaultMessage: 'OpenAI',
|
||||
}
|
||||
);
|
||||
export const GEN_AI_CONNECTOR_ID = '.gen-ai';
|
||||
export enum SUB_ACTION {
|
||||
RUN = 'run',
|
||||
INVOKE_AI = 'invokeAI',
|
||||
STREAM = 'stream',
|
||||
DASHBOARD = 'getDashboard',
|
||||
TEST = 'test',
|
||||
|
|
|
@ -9,7 +9,7 @@ import { schema } from '@kbn/config-schema';
|
|||
import { DEFAULT_OPENAI_MODEL, OpenAiProviderType } from './constants';
|
||||
|
||||
// Connector schema
|
||||
export const GenAiConfigSchema = schema.oneOf([
|
||||
export const ConfigSchema = schema.oneOf([
|
||||
schema.object({
|
||||
apiProvider: schema.oneOf([schema.literal(OpenAiProviderType.AzureAi)]),
|
||||
apiUrl: schema.string(),
|
||||
|
@ -21,22 +21,40 @@ export const GenAiConfigSchema = schema.oneOf([
|
|||
}),
|
||||
]);
|
||||
|
||||
export const GenAiSecretsSchema = schema.object({ apiKey: schema.string() });
|
||||
export const SecretsSchema = schema.object({ apiKey: schema.string() });
|
||||
|
||||
// Run action schema
|
||||
export const GenAiRunActionParamsSchema = schema.object({
|
||||
export const RunActionParamsSchema = schema.object({
|
||||
body: schema.string(),
|
||||
});
|
||||
|
||||
// Run action schema
|
||||
export const InvokeAIActionParamsSchema = schema.object({
|
||||
messages: schema.arrayOf(
|
||||
schema.object({
|
||||
role: schema.string(),
|
||||
content: schema.string(),
|
||||
})
|
||||
),
|
||||
model: schema.maybe(schema.string()),
|
||||
n: schema.maybe(schema.number()),
|
||||
stop: schema.maybe(
|
||||
schema.nullable(schema.oneOf([schema.string(), schema.arrayOf(schema.string())]))
|
||||
),
|
||||
temperature: schema.maybe(schema.number()),
|
||||
});
|
||||
|
||||
export const InvokeAIActionResponseSchema = schema.string();
|
||||
|
||||
// Execute action schema
|
||||
export const GenAiStreamActionParamsSchema = schema.object({
|
||||
export const StreamActionParamsSchema = schema.object({
|
||||
body: schema.string(),
|
||||
stream: schema.boolean({ defaultValue: false }),
|
||||
});
|
||||
|
||||
export const GenAiStreamingResponseSchema = schema.any();
|
||||
export const StreamingResponseSchema = schema.any();
|
||||
|
||||
export const GenAiRunActionResponseSchema = schema.object(
|
||||
export const RunActionResponseSchema = schema.object(
|
||||
{
|
||||
id: schema.maybe(schema.string()),
|
||||
object: schema.maybe(schema.string()),
|
||||
|
@ -71,10 +89,10 @@ export const GenAiRunActionResponseSchema = schema.object(
|
|||
);
|
||||
|
||||
// Run action schema
|
||||
export const GenAiDashboardActionParamsSchema = schema.object({
|
||||
export const DashboardActionParamsSchema = schema.object({
|
||||
dashboardId: schema.string(),
|
||||
});
|
||||
|
||||
export const GenAiDashboardActionResponseSchema = schema.object({
|
||||
export const DashboardActionResponseSchema = schema.object({
|
||||
available: schema.boolean(),
|
||||
});
|
||||
|
|
|
@ -7,19 +7,23 @@
|
|||
|
||||
import { TypeOf } from '@kbn/config-schema';
|
||||
import {
|
||||
GenAiConfigSchema,
|
||||
GenAiSecretsSchema,
|
||||
GenAiRunActionParamsSchema,
|
||||
GenAiRunActionResponseSchema,
|
||||
GenAiDashboardActionParamsSchema,
|
||||
GenAiDashboardActionResponseSchema,
|
||||
GenAiStreamActionParamsSchema,
|
||||
ConfigSchema,
|
||||
SecretsSchema,
|
||||
RunActionParamsSchema,
|
||||
RunActionResponseSchema,
|
||||
DashboardActionParamsSchema,
|
||||
DashboardActionResponseSchema,
|
||||
StreamActionParamsSchema,
|
||||
InvokeAIActionParamsSchema,
|
||||
InvokeAIActionResponseSchema,
|
||||
} from './schema';
|
||||
|
||||
export type GenAiConfig = TypeOf<typeof GenAiConfigSchema>;
|
||||
export type GenAiSecrets = TypeOf<typeof GenAiSecretsSchema>;
|
||||
export type GenAiRunActionParams = TypeOf<typeof GenAiRunActionParamsSchema>;
|
||||
export type GenAiRunActionResponse = TypeOf<typeof GenAiRunActionResponseSchema>;
|
||||
export type GenAiDashboardActionParams = TypeOf<typeof GenAiDashboardActionParamsSchema>;
|
||||
export type GenAiDashboardActionResponse = TypeOf<typeof GenAiDashboardActionResponseSchema>;
|
||||
export type GenAiStreamActionParams = TypeOf<typeof GenAiStreamActionParamsSchema>;
|
||||
export type Config = TypeOf<typeof ConfigSchema>;
|
||||
export type Secrets = TypeOf<typeof SecretsSchema>;
|
||||
export type RunActionParams = TypeOf<typeof RunActionParamsSchema>;
|
||||
export type InvokeAIActionParams = TypeOf<typeof InvokeAIActionParamsSchema>;
|
||||
export type InvokeAIActionResponse = TypeOf<typeof InvokeAIActionResponseSchema>;
|
||||
export type RunActionResponse = TypeOf<typeof RunActionResponseSchema>;
|
||||
export type DashboardActionParams = TypeOf<typeof DashboardActionParamsSchema>;
|
||||
export type DashboardActionResponse = TypeOf<typeof DashboardActionResponseSchema>;
|
||||
export type StreamActionParams = TypeOf<typeof StreamActionParamsSchema>;
|
||||
|
|
|
@ -0,0 +1,83 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { TypeRegistry } from '@kbn/triggers-actions-ui-plugin/public/application/type_registry';
|
||||
import { registerConnectorTypes } from '..';
|
||||
import type { ActionTypeModel } from '@kbn/triggers-actions-ui-plugin/public/types';
|
||||
import { registrationServicesMock } from '../../mocks';
|
||||
import { SUB_ACTION } from '../../../common/bedrock/constants';
|
||||
|
||||
const ACTION_TYPE_ID = '.bedrock';
|
||||
let actionTypeModel: ActionTypeModel;
|
||||
|
||||
beforeAll(() => {
|
||||
const connectorTypeRegistry = new TypeRegistry<ActionTypeModel>();
|
||||
registerConnectorTypes({ connectorTypeRegistry, services: registrationServicesMock });
|
||||
const getResult = connectorTypeRegistry.get(ACTION_TYPE_ID);
|
||||
if (getResult !== null) {
|
||||
actionTypeModel = getResult;
|
||||
}
|
||||
});
|
||||
|
||||
describe('actionTypeRegistry.get() works', () => {
|
||||
test('connector type static data is as expected', () => {
|
||||
expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID);
|
||||
expect(actionTypeModel.selectMessage).toBe('Send a request to AWS Bedrock systems.');
|
||||
expect(actionTypeModel.actionTypeTitle).toBe('AWS Bedrock');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bedrock action params validation', () => {
|
||||
test('action params validation succeeds when action params is valid', async () => {
|
||||
const actionParams = {
|
||||
subAction: SUB_ACTION.RUN,
|
||||
subActionParams: { body: '{"message": "test"}' },
|
||||
};
|
||||
|
||||
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
|
||||
errors: { body: [], subAction: [] },
|
||||
});
|
||||
});
|
||||
|
||||
test('params validation fails when body is not an object', async () => {
|
||||
const actionParams = {
|
||||
subAction: SUB_ACTION.RUN,
|
||||
subActionParams: { body: 'message {test}' },
|
||||
};
|
||||
|
||||
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
|
||||
errors: { body: ['Body does not have a valid JSON format.'], subAction: [] },
|
||||
});
|
||||
});
|
||||
|
||||
test('params validation fails when subAction is missing', async () => {
|
||||
const actionParams = {
|
||||
subActionParams: { body: '{"message": "test"}' },
|
||||
};
|
||||
|
||||
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
|
||||
errors: {
|
||||
body: [],
|
||||
subAction: ['Action is required.'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('params validation fails when subActionParams is missing', async () => {
|
||||
const actionParams = {
|
||||
subAction: SUB_ACTION.RUN,
|
||||
subActionParams: {},
|
||||
};
|
||||
|
||||
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
|
||||
errors: {
|
||||
body: ['Body is required.'],
|
||||
subAction: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,61 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { lazy } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import type { GenericValidationResult } from '@kbn/triggers-actions-ui-plugin/public/types';
|
||||
import { SUB_ACTION } from '../../../common/bedrock/constants';
|
||||
import { BEDROCK_CONNECTOR_ID, BEDROCK_TITLE } from '../../../common/bedrock/constants';
|
||||
import { BedrockActionParams, BedrockConnector } from './types';
|
||||
|
||||
interface ValidationErrors {
|
||||
subAction: string[];
|
||||
body: string[];
|
||||
}
|
||||
export function getConnectorType(): BedrockConnector {
|
||||
return {
|
||||
id: BEDROCK_CONNECTOR_ID,
|
||||
iconClass: lazy(() => import('./logo')),
|
||||
selectMessage: i18n.translate('xpack.stackConnectors.components.bedrock.selectMessageText', {
|
||||
defaultMessage: 'Send a request to AWS Bedrock systems.',
|
||||
}),
|
||||
actionTypeTitle: BEDROCK_TITLE,
|
||||
validateParams: async (
|
||||
actionParams: BedrockActionParams
|
||||
): Promise<GenericValidationResult<ValidationErrors>> => {
|
||||
const { subAction, subActionParams } = actionParams;
|
||||
const translations = await import('./translations');
|
||||
const errors: ValidationErrors = {
|
||||
body: [],
|
||||
subAction: [],
|
||||
};
|
||||
|
||||
if (subAction === SUB_ACTION.TEST || subAction === SUB_ACTION.RUN) {
|
||||
if (!subActionParams.body?.length) {
|
||||
errors.body.push(translations.BODY_REQUIRED);
|
||||
} else {
|
||||
try {
|
||||
JSON.parse(subActionParams.body);
|
||||
} catch {
|
||||
errors.body.push(translations.BODY_INVALID);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (errors.body.length) return { errors };
|
||||
|
||||
// The internal "subAction" param should always be valid, ensure it is only if "subActionParams" are valid
|
||||
if (!subAction) {
|
||||
errors.subAction.push(translations.ACTION_REQUIRED);
|
||||
} else if (subAction !== SUB_ACTION.RUN && subAction !== SUB_ACTION.TEST) {
|
||||
errors.subAction.push(translations.INVALID_ACTION);
|
||||
}
|
||||
return { errors };
|
||||
},
|
||||
actionConnectorFields: lazy(() => import('./connector')),
|
||||
actionParamsFields: lazy(() => import('./params')),
|
||||
};
|
||||
}
|
|
@ -0,0 +1,161 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import BedrockConnectorFields from './connector';
|
||||
import { ConnectorFormTestProvider } from '../lib/test_utils';
|
||||
import { act, render, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useKibana } from '@kbn/triggers-actions-ui-plugin/public';
|
||||
import { DEFAULT_BEDROCK_MODEL } from '../../../common/bedrock/constants';
|
||||
|
||||
jest.mock('@kbn/triggers-actions-ui-plugin/public/common/lib/kibana');
|
||||
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
|
||||
const bedrockConnector = {
|
||||
actionTypeId: '.bedrock',
|
||||
name: 'bedrock',
|
||||
id: '123',
|
||||
config: {
|
||||
apiUrl: 'https://bedrockurl.com',
|
||||
defaultModel: DEFAULT_BEDROCK_MODEL,
|
||||
},
|
||||
secrets: {
|
||||
accessKey: 'thats-a-nice-looking-key',
|
||||
secret: 'thats-a-nice-looking-secret',
|
||||
},
|
||||
isDeprecated: false,
|
||||
};
|
||||
|
||||
const navigateToUrl = jest.fn();
|
||||
|
||||
describe('BedrockConnectorFields renders', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useKibanaMock().services.application.navigateToUrl = navigateToUrl;
|
||||
});
|
||||
test('Bedrock connector fields are rendered', async () => {
|
||||
const { getAllByTestId } = render(
|
||||
<ConnectorFormTestProvider connector={bedrockConnector}>
|
||||
<BedrockConnectorFields
|
||||
readOnly={false}
|
||||
isEdit={false}
|
||||
registerPreSubmitValidator={() => {}}
|
||||
/>
|
||||
</ConnectorFormTestProvider>
|
||||
);
|
||||
|
||||
expect(getAllByTestId('config.apiUrl-input')[0]).toBeInTheDocument();
|
||||
expect(getAllByTestId('config.apiUrl-input')[0]).toHaveValue(bedrockConnector.config.apiUrl);
|
||||
expect(getAllByTestId('config.defaultModel-input')[0]).toBeInTheDocument();
|
||||
expect(getAllByTestId('config.defaultModel-input')[0]).toHaveValue(
|
||||
bedrockConnector.config.defaultModel
|
||||
);
|
||||
expect(getAllByTestId('bedrock-api-doc')[0]).toBeInTheDocument();
|
||||
expect(getAllByTestId('bedrock-api-model-doc')[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('Validation', () => {
|
||||
const onSubmit = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('connector validation succeeds when connector config is valid', async () => {
|
||||
const { getByTestId } = render(
|
||||
<ConnectorFormTestProvider connector={bedrockConnector} onSubmit={onSubmit}>
|
||||
<BedrockConnectorFields
|
||||
readOnly={false}
|
||||
isEdit={false}
|
||||
registerPreSubmitValidator={() => {}}
|
||||
/>
|
||||
</ConnectorFormTestProvider>
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
userEvent.click(getByTestId('form-test-provide-submit'));
|
||||
});
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(onSubmit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(onSubmit).toBeCalledWith({
|
||||
data: bedrockConnector,
|
||||
isValid: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('validates correctly if the apiUrl is empty', async () => {
|
||||
const connector = {
|
||||
...bedrockConnector,
|
||||
config: {
|
||||
...bedrockConnector.config,
|
||||
apiUrl: '',
|
||||
},
|
||||
};
|
||||
|
||||
const res = render(
|
||||
<ConnectorFormTestProvider connector={connector} onSubmit={onSubmit}>
|
||||
<BedrockConnectorFields
|
||||
readOnly={false}
|
||||
isEdit={false}
|
||||
registerPreSubmitValidator={() => {}}
|
||||
/>
|
||||
</ConnectorFormTestProvider>
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
userEvent.click(res.getByTestId('form-test-provide-submit'));
|
||||
});
|
||||
await waitFor(async () => {
|
||||
expect(onSubmit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith({ data: {}, isValid: false });
|
||||
});
|
||||
|
||||
const tests: Array<[string, string]> = [
|
||||
['config.apiUrl-input', 'not-valid'],
|
||||
['secrets.accessKey-input', ''],
|
||||
];
|
||||
it.each(tests)('validates correctly %p', async (field, value) => {
|
||||
const connector = {
|
||||
...bedrockConnector,
|
||||
config: {
|
||||
...bedrockConnector.config,
|
||||
headers: [],
|
||||
},
|
||||
};
|
||||
|
||||
const res = render(
|
||||
<ConnectorFormTestProvider connector={connector} onSubmit={onSubmit}>
|
||||
<BedrockConnectorFields
|
||||
readOnly={false}
|
||||
isEdit={false}
|
||||
registerPreSubmitValidator={() => {}}
|
||||
/>
|
||||
</ConnectorFormTestProvider>
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.type(res.getByTestId(field), `{selectall}{backspace}${value}`, {
|
||||
delay: 10,
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
userEvent.click(res.getByTestId('form-test-provide-submit'));
|
||||
});
|
||||
await waitFor(async () => {
|
||||
expect(onSubmit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith({ data: {}, isValid: false });
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
ActionConnectorFieldsProps,
|
||||
SimpleConnectorForm,
|
||||
} from '@kbn/triggers-actions-ui-plugin/public';
|
||||
import { bedrockConfig, bedrockSecrets } from './constants';
|
||||
|
||||
const BedrockConnectorFields: React.FC<ActionConnectorFieldsProps> = ({ readOnly, isEdit }) => {
|
||||
return (
|
||||
<SimpleConnectorForm
|
||||
isEdit={isEdit}
|
||||
readOnly={readOnly}
|
||||
configFormSchema={bedrockConfig}
|
||||
secretsFormSchema={bedrockSecrets}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export { BedrockConnectorFields as default };
|
|
@ -0,0 +1,117 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { ConfigFieldSchema, SecretsFieldSchema } from '@kbn/triggers-actions-ui-plugin/public';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { EuiLink } from '@elastic/eui';
|
||||
import { DEFAULT_BEDROCK_MODEL, DEFAULT_BEDROCK_URL } from '../../../common/bedrock/constants';
|
||||
import * as i18n from './translations';
|
||||
|
||||
const human = '\n\nHuman:';
|
||||
const assistant = '\n\nAssistant:';
|
||||
|
||||
export const DEFAULT_BODY = JSON.stringify({
|
||||
prompt: `${human} Hello world! ${assistant}`,
|
||||
max_tokens_to_sample: 300,
|
||||
stop_sequences: [human],
|
||||
});
|
||||
|
||||
export const bedrockConfig: ConfigFieldSchema[] = [
|
||||
{
|
||||
id: 'apiUrl',
|
||||
label: i18n.API_URL_LABEL,
|
||||
isUrlField: true,
|
||||
defaultValue: DEFAULT_BEDROCK_URL,
|
||||
helpText: (
|
||||
<FormattedMessage
|
||||
defaultMessage="The AWS Bedrock API endpoint URL. For more information on the URL, refer to the {bedrockAPIUrlDocs}."
|
||||
id="xpack.stackConnectors.components.bedrock.bedrockDocumentation"
|
||||
values={{
|
||||
bedrockAPIUrlDocs: (
|
||||
<EuiLink
|
||||
data-test-subj="bedrock-api-doc"
|
||||
href="https://docs.aws.amazon.com/bedrock/latest/APIReference/welcome.html"
|
||||
target="_blank"
|
||||
>
|
||||
{`${i18n.BEDROCK} ${i18n.DOCUMENTATION}`}
|
||||
</EuiLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'defaultModel',
|
||||
label: i18n.DEFAULT_MODEL_LABEL,
|
||||
helpText: (
|
||||
<FormattedMessage
|
||||
defaultMessage='Current support is for the Anthropic Claude models. The model can be set on a per request basis by including a "model" parameter alongside the request body. If no model is provided, the fallback will be the default model - Claude 2. For more information, refer to the {bedrockAPIModelDocs}.'
|
||||
id="xpack.stackConnectors.components.bedrock.bedrockDocumentationModel"
|
||||
values={{
|
||||
bedrockAPIModelDocs: (
|
||||
<EuiLink
|
||||
data-test-subj="bedrock-api-model-doc"
|
||||
href="https://aws.amazon.com/bedrock/claude/"
|
||||
target="_blank"
|
||||
>
|
||||
{`${i18n.BEDROCK} ${i18n.DOCUMENTATION}`}
|
||||
</EuiLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
),
|
||||
defaultValue: DEFAULT_BEDROCK_MODEL,
|
||||
},
|
||||
];
|
||||
|
||||
export const bedrockSecrets: SecretsFieldSchema[] = [
|
||||
{
|
||||
id: 'accessKey',
|
||||
label: i18n.ACCESS_KEY_LABEL,
|
||||
isPasswordField: true,
|
||||
helpText: (
|
||||
<FormattedMessage
|
||||
defaultMessage="The AWS access key for HTTP Basic authentication. For more details about generating AWS security credentials, refer to the {bedrockAPIKeyDocs}."
|
||||
id="xpack.stackConnectors.components.bedrock.bedrockApiKeyDocumentation"
|
||||
values={{
|
||||
bedrockAPIKeyDocs: (
|
||||
<EuiLink
|
||||
data-test-subj="aws-api-keys-doc"
|
||||
href="https://docs.aws.amazon.com/IAM/latest/UserGuide/security-creds.html"
|
||||
target="_blank"
|
||||
>
|
||||
{`${i18n.BEDROCK} ${i18n.DOCUMENTATION}`}
|
||||
</EuiLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'secret',
|
||||
label: i18n.SECRET,
|
||||
isPasswordField: true,
|
||||
helpText: (
|
||||
<FormattedMessage
|
||||
defaultMessage="The AWS secret for HTTP Basic authentication. For more details about generating AWS security credentials, refer to the {bedrockAPIKeyDocs}."
|
||||
id="xpack.stackConnectors.components.bedrock.bedrockSecretDocumentation"
|
||||
values={{
|
||||
bedrockAPIKeyDocs: (
|
||||
<EuiLink
|
||||
data-test-subj="aws-api-keys-doc"
|
||||
href="https://docs.aws.amazon.com/IAM/latest/UserGuide/security-creds.html"
|
||||
target="_blank"
|
||||
>
|
||||
{`${i18n.BEDROCK} ${i18n.DOCUMENTATION}`}
|
||||
</EuiLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
export { getConnectorType as getBedrockConnectorType } from './bedrock';
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { LogoProps } from '../types';
|
||||
|
||||
const Logo = (props: LogoProps) => (
|
||||
<svg
|
||||
{...props}
|
||||
width="64"
|
||||
height="64"
|
||||
viewBox="0 0 64 64"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M56 30.9999C54.897 30.9999 54 30.1029 54 28.9999C54 27.8969 54.897 26.9999 56 26.9999C57.103 26.9999 58 27.8969 58 28.9999C58 30.1029 57.103 30.9999 56 30.9999ZM24.113 57.9079L20.865 56.0139L27.53 51.8479L26.47 50.1519L18.913 54.8749L13 51.4259V42.5349L18.555 38.8319L17.445 37.1679L11.959 40.8249L6 37.4199V32.5799L12.496 28.8679L11.504 27.1319L6 30.2769V26.5799L12 23.1519L18 26.5799V30.4339L13.485 33.1429L14.515 34.8569L19 32.1659L23.485 34.8569L24.515 33.1429L20 30.4339V26.5349L25.555 22.8319C25.833 22.6459 26 22.3339 26 21.9999V14.9999H24V21.4649L18.959 24.8249L13 21.4199V12.5739L18 9.65789V17.9999H20V8.49089L24.113 6.09189L32 8.72089V37.4339L17.485 46.1429L18.515 47.8569L32 39.7659V55.2789L24.113 57.9079ZM54 41.9999C54 43.1029 53.103 43.9999 52 43.9999C50.897 43.9999 50 43.1029 50 41.9999C50 40.8969 50.897 39.9999 52 39.9999C53.103 39.9999 54 40.8969 54 41.9999ZM44 51.9999C44 53.1029 43.103 53.9999 42 53.9999C40.897 53.9999 40 53.1029 40 51.9999C40 50.8969 40.897 49.9999 42 49.9999C43.103 49.9999 44 50.8969 44 51.9999ZM43 11.9999C43 10.8969 43.897 9.99989 45 9.99989C46.103 9.99989 47 10.8969 47 11.9999C47 13.1029 46.103 13.9999 45 13.9999C43.897 13.9999 43 13.1029 43 11.9999ZM56 24.9999C54.141 24.9999 52.589 26.2799 52.142 27.9999H34V22.9999H45C45.553 22.9999 46 22.5519 46 21.9999V15.8579C47.72 15.4109 49 13.8579 49 11.9999C49 9.79389 47.206 7.99989 45 7.99989C42.794 7.99989 41 9.79389 41 11.9999C41 13.8579 42.28 15.4109 44 15.8579V20.9999H34V7.99989C34 7.56889 33.725 7.18789 33.316 7.05089L24.316 4.05089C24.042 3.96089 23.744 3.99089 23.496 4.13589L11.496 11.1359C11.188 11.3149 11 11.6449 11 11.9999V21.4199L4.504 25.1319C4.192 25.3099 4 25.6409 4 25.9999V37.9999C4 38.3589 4.192 38.6899 4.504 38.8679L11 42.5799V51.9999C11 52.3549 11.188 52.6849 11.496 52.8639L23.496 59.8639C23.65 59.9539 23.825 59.9999 24 59.9999C24.106 59.9999 24.213 59.9829 24.316 59.9489L33.316 56.9489C33.725 56.8119 34 56.4309 34 55.9999V43.9999H41V48.1419C39.28 48.5889 38 50.1419 38 51.9999C38 54.2059 39.794 55.9999 42 55.9999C44.206 55.9999 46 54.2059 46 51.9999C46 50.1419 44.72 48.5889 43 48.1419V42.9999C43 42.4479 42.553 41.9999 42 41.9999H34V36.9999H46.5L48.638 39.8499C48.239 40.4719 48 41.2069 48 41.9999C48 44.2059 49.794 45.9999 52 45.9999C54.206 45.9999 56 44.2059 56 41.9999C56 39.7939 54.206 37.9999 52 37.9999C51.316 37.9999 50.682 38.1879 50.119 38.4919L47.8 35.3999C47.611 35.1479 47.314 34.9999 47 34.9999H34V29.9999H52.142C52.589 31.7199 54.141 32.9999 56 32.9999C58.206 32.9999 60 31.2059 60 28.9999C60 26.7939 58.206 24.9999 56 24.9999Z"
|
||||
fill="#232F3E"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export { Logo as default };
|
|
@ -0,0 +1,177 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { fireEvent, render } from '@testing-library/react';
|
||||
import BedrockParamsFields from './params';
|
||||
import { MockCodeEditor } from '@kbn/triggers-actions-ui-plugin/public/application/code_editor.mock';
|
||||
import { DEFAULT_BEDROCK_URL, SUB_ACTION } from '../../../common/bedrock/constants';
|
||||
import { I18nProvider } from '@kbn/i18n-react';
|
||||
|
||||
const kibanaReactPath = '../../../../../../src/plugins/kibana_react/public';
|
||||
|
||||
jest.mock(kibanaReactPath, () => {
|
||||
const original = jest.requireActual(kibanaReactPath);
|
||||
return {
|
||||
...original,
|
||||
CodeEditor: (props: any) => {
|
||||
return <MockCodeEditor {...props} />;
|
||||
},
|
||||
};
|
||||
});
|
||||
const messageVariables = [
|
||||
{
|
||||
name: 'myVar',
|
||||
description: 'My variable description',
|
||||
useWithTripleBracesInTemplates: true,
|
||||
},
|
||||
];
|
||||
|
||||
describe('Bedrock Params Fields renders', () => {
|
||||
test('all params fields are rendered', () => {
|
||||
const { getByTestId } = render(
|
||||
<BedrockParamsFields
|
||||
actionParams={{
|
||||
subAction: SUB_ACTION.RUN,
|
||||
subActionParams: { body: '{"message": "test"}' },
|
||||
}}
|
||||
errors={{ body: [] }}
|
||||
editAction={() => {}}
|
||||
index={0}
|
||||
messageVariables={messageVariables}
|
||||
/>,
|
||||
{
|
||||
wrapper: ({ children }) => <I18nProvider>{children}</I18nProvider>,
|
||||
}
|
||||
);
|
||||
expect(getByTestId('bodyJsonEditor')).toBeInTheDocument();
|
||||
expect(getByTestId('bodyJsonEditor')).toHaveProperty('value', '{"message": "test"}');
|
||||
expect(getByTestId('bodyAddVariableButton')).toBeInTheDocument();
|
||||
expect(getByTestId('bedrock-model')).toBeInTheDocument();
|
||||
});
|
||||
test('useEffect handles the case when subAction and subActionParams are undefined', () => {
|
||||
const actionParams = {
|
||||
subAction: undefined,
|
||||
subActionParams: undefined,
|
||||
};
|
||||
const editAction = jest.fn();
|
||||
const errors = {};
|
||||
const actionConnector = {
|
||||
secrets: {
|
||||
accessKey: 'accessKey',
|
||||
secret: 'secret',
|
||||
},
|
||||
id: 'test',
|
||||
actionTypeId: '.bedrock',
|
||||
isPreconfigured: false,
|
||||
isSystemAction: false as const,
|
||||
isDeprecated: false,
|
||||
name: 'My Bedrock Connector',
|
||||
config: {
|
||||
apiUrl: DEFAULT_BEDROCK_URL,
|
||||
},
|
||||
};
|
||||
render(
|
||||
<BedrockParamsFields
|
||||
actionParams={actionParams}
|
||||
actionConnector={actionConnector}
|
||||
editAction={editAction}
|
||||
index={0}
|
||||
messageVariables={messageVariables}
|
||||
errors={errors}
|
||||
/>,
|
||||
{
|
||||
wrapper: ({ children }) => <I18nProvider>{children}</I18nProvider>,
|
||||
}
|
||||
);
|
||||
expect(editAction).toHaveBeenCalledTimes(2);
|
||||
expect(editAction).toHaveBeenCalledWith('subAction', SUB_ACTION.RUN, 0);
|
||||
});
|
||||
|
||||
it('handles the case when subAction only is undefined', () => {
|
||||
const actionParams = {
|
||||
subAction: undefined,
|
||||
subActionParams: {
|
||||
body: '{"key": "value"}',
|
||||
},
|
||||
};
|
||||
const editAction = jest.fn();
|
||||
const errors = {};
|
||||
render(
|
||||
<BedrockParamsFields
|
||||
actionParams={actionParams}
|
||||
editAction={editAction}
|
||||
index={0}
|
||||
messageVariables={messageVariables}
|
||||
errors={errors}
|
||||
/>,
|
||||
{
|
||||
wrapper: ({ children }) => <I18nProvider>{children}</I18nProvider>,
|
||||
}
|
||||
);
|
||||
expect(editAction).toHaveBeenCalledTimes(1);
|
||||
expect(editAction).toHaveBeenCalledWith('subAction', SUB_ACTION.RUN, 0);
|
||||
});
|
||||
|
||||
it('calls editAction function with the body argument', () => {
|
||||
const editAction = jest.fn();
|
||||
const errors = {};
|
||||
const { getByTestId } = render(
|
||||
<BedrockParamsFields
|
||||
actionParams={{
|
||||
subAction: SUB_ACTION.RUN,
|
||||
subActionParams: {
|
||||
body: '{"key": "value"}',
|
||||
},
|
||||
}}
|
||||
editAction={editAction}
|
||||
index={0}
|
||||
messageVariables={messageVariables}
|
||||
errors={errors}
|
||||
/>,
|
||||
{
|
||||
wrapper: ({ children }) => <I18nProvider>{children}</I18nProvider>,
|
||||
}
|
||||
);
|
||||
const jsonEditor = getByTestId('bodyJsonEditor');
|
||||
fireEvent.change(jsonEditor, { target: { value: '{"new_key": "new_value"}' } });
|
||||
expect(editAction).toHaveBeenCalledWith(
|
||||
'subActionParams',
|
||||
{ body: '{"new_key": "new_value"}' },
|
||||
0
|
||||
);
|
||||
});
|
||||
|
||||
it('calls editAction function with the model argument', () => {
|
||||
const editAction = jest.fn();
|
||||
const errors = {};
|
||||
const { getByTestId } = render(
|
||||
<BedrockParamsFields
|
||||
actionParams={{
|
||||
subAction: SUB_ACTION.RUN,
|
||||
subActionParams: {
|
||||
body: '{"key": "value"}',
|
||||
},
|
||||
}}
|
||||
editAction={editAction}
|
||||
index={0}
|
||||
messageVariables={messageVariables}
|
||||
errors={errors}
|
||||
/>,
|
||||
{
|
||||
wrapper: ({ children }) => <I18nProvider>{children}</I18nProvider>,
|
||||
}
|
||||
);
|
||||
const model = getByTestId('bedrock-model');
|
||||
fireEvent.change(model, { target: { value: 'not-the-default' } });
|
||||
expect(editAction).toHaveBeenCalledWith(
|
||||
'subActionParams',
|
||||
{ body: '{"key": "value"}', model: 'not-the-default' },
|
||||
0
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,118 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||
import type { ActionParamsProps } from '@kbn/triggers-actions-ui-plugin/public';
|
||||
import {
|
||||
ActionConnectorMode,
|
||||
JsonEditorWithMessageVariables,
|
||||
} from '@kbn/triggers-actions-ui-plugin/public';
|
||||
import { EuiFieldText, EuiFormRow, EuiLink } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import * as i18n from './translations';
|
||||
import { DEFAULT_BODY } from './constants';
|
||||
import { SUB_ACTION } from '../../../common/bedrock/constants';
|
||||
import { BedrockActionParams } from './types';
|
||||
|
||||
const BedrockParamsFields: React.FunctionComponent<ActionParamsProps<BedrockActionParams>> = ({
|
||||
actionParams,
|
||||
editAction,
|
||||
index,
|
||||
messageVariables,
|
||||
executionMode,
|
||||
errors,
|
||||
}) => {
|
||||
const { subAction, subActionParams } = actionParams;
|
||||
|
||||
const { body, model } = subActionParams ?? {};
|
||||
|
||||
const isTest = useMemo(() => executionMode === ActionConnectorMode.Test, [executionMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!subAction) {
|
||||
editAction('subAction', isTest ? SUB_ACTION.TEST : SUB_ACTION.RUN, index);
|
||||
}
|
||||
}, [editAction, index, isTest, subAction]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!subActionParams) {
|
||||
editAction('subActionParams', { body: DEFAULT_BODY }, index);
|
||||
}
|
||||
}, [editAction, index, subActionParams]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// some bedrock specific formatting gets messed up if we do not reset
|
||||
// subActionParams on dismount (switching tabs between test and config)
|
||||
editAction('subActionParams', undefined, index);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const editSubActionParams = useCallback(
|
||||
(params: Partial<BedrockActionParams['subActionParams']>) => {
|
||||
editAction('subActionParams', { ...subActionParams, ...params }, index);
|
||||
},
|
||||
[editAction, index, subActionParams]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<JsonEditorWithMessageVariables
|
||||
messageVariables={messageVariables}
|
||||
paramsProperty={'body'}
|
||||
inputTargetValue={body}
|
||||
label={i18n.BODY}
|
||||
aria-label={i18n.BODY_DESCRIPTION}
|
||||
errors={errors.body as string[]}
|
||||
onDocumentsChange={(json: string) => {
|
||||
editSubActionParams({ body: json });
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (!body) {
|
||||
editSubActionParams({ body: '' });
|
||||
}
|
||||
}}
|
||||
data-test-subj="bedrock-bodyJsonEditor"
|
||||
/>
|
||||
<EuiFormRow
|
||||
fullWidth
|
||||
label={i18n.MODEL}
|
||||
helpText={
|
||||
<FormattedMessage
|
||||
defaultMessage="Optionally overwrite default model per request. Current support is for the Anthropic Claude models. For more information, refer to the {bedrockAPIModelDocs}."
|
||||
id="xpack.stackConnectors.components.bedrock.modelHelpText"
|
||||
values={{
|
||||
bedrockAPIModelDocs: (
|
||||
<EuiLink
|
||||
data-test-subj="bedrock-api-model-doc"
|
||||
href="https://aws.amazon.com/bedrock/claude/"
|
||||
target="_blank"
|
||||
>
|
||||
{`${i18n.BEDROCK} ${i18n.DOCUMENTATION}`}
|
||||
</EuiLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiFieldText
|
||||
data-test-subj="bedrock-model"
|
||||
placeholder={'anthropic.claude-v2'}
|
||||
value={model}
|
||||
onChange={(ev) => {
|
||||
editSubActionParams({ model: ev.target.value });
|
||||
}}
|
||||
fullWidth
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export { BedrockParamsFields as default };
|
|
@ -0,0 +1,99 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export const API_URL_LABEL = i18n.translate(
|
||||
'xpack.stackConnectors.components.bedrock.apiUrlTextFieldLabel',
|
||||
{
|
||||
defaultMessage: 'URL',
|
||||
}
|
||||
);
|
||||
|
||||
export const ACCESS_KEY_LABEL = i18n.translate(
|
||||
'xpack.stackConnectors.components.bedrock.accessKeySecret',
|
||||
{
|
||||
defaultMessage: 'Access Key',
|
||||
}
|
||||
);
|
||||
|
||||
export const DEFAULT_MODEL_LABEL = i18n.translate(
|
||||
'xpack.stackConnectors.components.bedrock.defaultModelTextFieldLabel',
|
||||
{
|
||||
defaultMessage: 'Default model',
|
||||
}
|
||||
);
|
||||
|
||||
export const REGION_LABEL = i18n.translate(
|
||||
'xpack.stackConnectors.components.bedrock.defaultRegionTextFieldLabel',
|
||||
{
|
||||
defaultMessage: 'AWS Region',
|
||||
}
|
||||
);
|
||||
|
||||
export const SECRET = i18n.translate('xpack.stackConnectors.components.bedrock.secret', {
|
||||
defaultMessage: 'Secret',
|
||||
});
|
||||
|
||||
export const BEDROCK = i18n.translate('xpack.stackConnectors.components.bedrock.title', {
|
||||
defaultMessage: 'AWS Bedrock',
|
||||
});
|
||||
|
||||
export const DOCUMENTATION = i18n.translate(
|
||||
'xpack.stackConnectors.components.bedrock.documentation',
|
||||
{
|
||||
defaultMessage: 'documentation',
|
||||
}
|
||||
);
|
||||
|
||||
export const URL_LABEL = i18n.translate(
|
||||
'xpack.stackConnectors.components.bedrock.urlTextFieldLabel',
|
||||
{
|
||||
defaultMessage: 'URL',
|
||||
}
|
||||
);
|
||||
|
||||
export const BODY_REQUIRED = i18n.translate(
|
||||
'xpack.stackConnectors.components.bedrock.error.requiredBedrockBodyText',
|
||||
{
|
||||
defaultMessage: 'Body is required.',
|
||||
}
|
||||
);
|
||||
export const BODY_INVALID = i18n.translate(
|
||||
'xpack.stackConnectors.security.bedrock.params.error.invalidBodyText',
|
||||
{
|
||||
defaultMessage: 'Body does not have a valid JSON format.',
|
||||
}
|
||||
);
|
||||
|
||||
export const ACTION_REQUIRED = i18n.translate(
|
||||
'xpack.stackConnectors.security.bedrock.params.error.requiredActionText',
|
||||
{
|
||||
defaultMessage: 'Action is required.',
|
||||
}
|
||||
);
|
||||
|
||||
export const INVALID_ACTION = i18n.translate(
|
||||
'xpack.stackConnectors.security.bedrock.params.error.invalidActionText',
|
||||
{
|
||||
defaultMessage: 'Invalid action name.',
|
||||
}
|
||||
);
|
||||
|
||||
export const BODY = i18n.translate('xpack.stackConnectors.components.bedrock.bodyFieldLabel', {
|
||||
defaultMessage: 'Body',
|
||||
});
|
||||
export const BODY_DESCRIPTION = i18n.translate(
|
||||
'xpack.stackConnectors.components.bedrock.bodyCodeEditorAriaLabel',
|
||||
{
|
||||
defaultMessage: 'Code editor',
|
||||
}
|
||||
);
|
||||
|
||||
export const MODEL = i18n.translate('xpack.stackConnectors.components.bedrock.model', {
|
||||
defaultMessage: 'Model',
|
||||
});
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { ActionTypeModel as ConnectorTypeModel } from '@kbn/triggers-actions-ui-plugin/public';
|
||||
import { SUB_ACTION } from '../../../common/bedrock/constants';
|
||||
import { RunActionParams } from '../../../common/bedrock/types';
|
||||
|
||||
export interface BedrockActionParams {
|
||||
subAction: SUB_ACTION.RUN | SUB_ACTION.TEST;
|
||||
subActionParams: RunActionParams;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
apiUrl: string;
|
||||
defaultModel: string;
|
||||
}
|
||||
|
||||
export interface Secrets {
|
||||
accessKey: string;
|
||||
secret: string;
|
||||
}
|
||||
|
||||
export type BedrockConnector = ConnectorTypeModel<Config, Secrets, BedrockActionParams>;
|
|
@ -27,7 +27,7 @@ describe('actionTypeRegistry.get() works', () => {
|
|||
test('connector type static data is as expected', () => {
|
||||
expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID);
|
||||
expect(actionTypeModel.selectMessage).toBe('Send a request to generative AI systems.');
|
||||
expect(actionTypeModel.actionTypeTitle).toBe('Generative AI');
|
||||
expect(actionTypeModel.actionTypeTitle).toBe('OpenAI');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@ import { lazy } from 'react';
|
|||
import { i18n } from '@kbn/i18n';
|
||||
import type { GenericValidationResult } from '@kbn/triggers-actions-ui-plugin/public/types';
|
||||
import { SUB_ACTION } from '../../../common/gen_ai/constants';
|
||||
import { GEN_AI_CONNECTOR_ID, GEN_AI_TITLE } from '../../../common/gen_ai/constants';
|
||||
import { GEN_AI_CONNECTOR_ID, OPEN_AI_TITLE } from '../../../common/gen_ai/constants';
|
||||
import { GenerativeAiActionParams, GenerativeAiConnector } from './types';
|
||||
|
||||
interface ValidationErrors {
|
||||
|
@ -23,7 +23,7 @@ export function getConnectorType(): GenerativeAiConnector {
|
|||
selectMessage: i18n.translate('xpack.stackConnectors.components.genAi.selectMessageText', {
|
||||
defaultMessage: 'Send a request to generative AI systems.',
|
||||
}),
|
||||
actionTypeTitle: GEN_AI_TITLE,
|
||||
actionTypeTitle: OPEN_AI_TITLE,
|
||||
validateParams: async (
|
||||
actionParams: GenerativeAiActionParams
|
||||
): Promise<GenericValidationResult<ValidationErrors>> => {
|
||||
|
|
|
@ -8,11 +8,11 @@
|
|||
import { ActionTypeModel as ConnectorTypeModel } from '@kbn/triggers-actions-ui-plugin/public';
|
||||
import { UserConfiguredActionConnector } from '@kbn/triggers-actions-ui-plugin/public/types';
|
||||
import { OpenAiProviderType, SUB_ACTION } from '../../../common/gen_ai/constants';
|
||||
import { GenAiRunActionParams } from '../../../common/gen_ai/types';
|
||||
import { RunActionParams } from '../../../common/gen_ai/types';
|
||||
|
||||
export interface GenerativeAiActionParams {
|
||||
subAction: SUB_ACTION.RUN | SUB_ACTION.TEST;
|
||||
subActionParams: GenAiRunActionParams;
|
||||
subActionParams: RunActionParams;
|
||||
}
|
||||
|
||||
export interface GenerativeAiConfig {
|
||||
|
|
|
@ -12,6 +12,7 @@ import { getEmailConnectorType } from './email';
|
|||
import { getIndexConnectorType } from './es_index';
|
||||
import { getJiraConnectorType } from './jira';
|
||||
import { getGenerativeAiConnectorType } from './gen_ai';
|
||||
import { getBedrockConnectorType } from './bedrock';
|
||||
import { getOpsgenieConnectorType } from './opsgenie';
|
||||
import { getPagerDutyConnectorType } from './pagerduty';
|
||||
import { getResilientConnectorType } from './resilient';
|
||||
|
@ -60,6 +61,7 @@ export function registerConnectorTypes({
|
|||
connectorTypeRegistry.register(getResilientConnectorType());
|
||||
connectorTypeRegistry.register(getOpsgenieConnectorType());
|
||||
connectorTypeRegistry.register(getGenerativeAiConnectorType());
|
||||
connectorTypeRegistry.register(getBedrockConnectorType());
|
||||
connectorTypeRegistry.register(getTeamsConnectorType());
|
||||
connectorTypeRegistry.register(getTorqConnectorType());
|
||||
connectorTypeRegistry.register(getTinesConnectorType());
|
||||
|
|
|
@ -0,0 +1,155 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { BedrockConnector } from './bedrock';
|
||||
import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.mock';
|
||||
import { loggingSystemMock } from '@kbn/core-logging-server-mocks';
|
||||
import { actionsMock } from '@kbn/actions-plugin/server/mocks';
|
||||
import { RunActionResponseSchema } from '../../../common/bedrock/schema';
|
||||
import {
|
||||
BEDROCK_CONNECTOR_ID,
|
||||
DEFAULT_BEDROCK_MODEL,
|
||||
DEFAULT_BEDROCK_URL,
|
||||
} from '../../../common/bedrock/constants';
|
||||
import { DEFAULT_BODY } from '../../../public/connector_types/bedrock/constants';
|
||||
|
||||
jest.mock('aws4', () => ({
|
||||
sign: () => ({ signed: true }),
|
||||
}));
|
||||
|
||||
describe('BedrockConnector', () => {
|
||||
let mockRequest: jest.Mock;
|
||||
let mockError: jest.Mock;
|
||||
const mockResponseString = 'Hello! How can I assist you today?';
|
||||
const mockResponse = {
|
||||
headers: {},
|
||||
data: {
|
||||
completion: mockResponseString,
|
||||
stop_reason: 'stop_sequence',
|
||||
},
|
||||
};
|
||||
beforeEach(() => {
|
||||
mockRequest = jest.fn().mockResolvedValue(mockResponse);
|
||||
mockError = jest.fn().mockImplementation(() => {
|
||||
throw new Error('API Error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bedrock', () => {
|
||||
const connector = new BedrockConnector({
|
||||
configurationUtilities: actionsConfigMock.create(),
|
||||
connector: { id: '1', type: BEDROCK_CONNECTOR_ID },
|
||||
config: {
|
||||
apiUrl: DEFAULT_BEDROCK_URL,
|
||||
defaultModel: DEFAULT_BEDROCK_MODEL,
|
||||
},
|
||||
secrets: { accessKey: '123', secret: 'secret' },
|
||||
logger: loggingSystemMock.createLogger(),
|
||||
services: actionsMock.createServices(),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// @ts-ignore
|
||||
connector.request = mockRequest;
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('runApi', () => {
|
||||
it('the Bedrock API call is successful with correct parameters', async () => {
|
||||
const response = await connector.runApi({ body: DEFAULT_BODY });
|
||||
expect(mockRequest).toBeCalledTimes(1);
|
||||
expect(mockRequest).toHaveBeenCalledWith({
|
||||
signed: true,
|
||||
url: `${DEFAULT_BEDROCK_URL}/model/${DEFAULT_BEDROCK_MODEL}/invoke`,
|
||||
method: 'post',
|
||||
responseSchema: RunActionResponseSchema,
|
||||
data: DEFAULT_BODY,
|
||||
});
|
||||
expect(response).toEqual(mockResponse.data);
|
||||
});
|
||||
|
||||
it('errors during API calls are properly handled', async () => {
|
||||
// @ts-ignore
|
||||
connector.request = mockError;
|
||||
|
||||
await expect(connector.runApi({ body: DEFAULT_BODY })).rejects.toThrow('API Error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('invokeAI', () => {
|
||||
const aiAssistantBody = {
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Hello world',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('the API call is successful with correct parameters', async () => {
|
||||
const response = await connector.invokeAI(aiAssistantBody);
|
||||
expect(mockRequest).toBeCalledTimes(1);
|
||||
expect(mockRequest).toHaveBeenCalledWith({
|
||||
signed: true,
|
||||
url: `${DEFAULT_BEDROCK_URL}/model/${DEFAULT_BEDROCK_MODEL}/invoke`,
|
||||
method: 'post',
|
||||
responseSchema: RunActionResponseSchema,
|
||||
data: JSON.stringify({
|
||||
prompt: '\n\nHuman:Hello world \n\nAssistant:',
|
||||
max_tokens_to_sample: 300,
|
||||
stop_sequences: ['\n\nHuman:'],
|
||||
}),
|
||||
});
|
||||
expect(response).toEqual(mockResponseString);
|
||||
});
|
||||
|
||||
it('Properly formats messages from user, assistant, and system', async () => {
|
||||
const response = await connector.invokeAI({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Hello world',
|
||||
},
|
||||
{
|
||||
role: 'system',
|
||||
content: 'Be a good chatbot',
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'Hi, I am a good chatbot',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: 'What is 2+2?',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(mockRequest).toBeCalledTimes(1);
|
||||
expect(mockRequest).toHaveBeenCalledWith({
|
||||
signed: true,
|
||||
url: `${DEFAULT_BEDROCK_URL}/model/${DEFAULT_BEDROCK_MODEL}/invoke`,
|
||||
method: 'post',
|
||||
responseSchema: RunActionResponseSchema,
|
||||
data: JSON.stringify({
|
||||
prompt:
|
||||
'\n\nHuman:Hello world\n\nHuman:Be a good chatbot\n\nAssistant:Hi, I am a good chatbot\n\nHuman:What is 2+2? \n\nAssistant:',
|
||||
max_tokens_to_sample: 300,
|
||||
stop_sequences: ['\n\nHuman:'],
|
||||
}),
|
||||
});
|
||||
expect(response).toEqual(mockResponseString);
|
||||
});
|
||||
|
||||
it('errors during API calls are properly handled', async () => {
|
||||
// @ts-ignore
|
||||
connector.request = mockError;
|
||||
|
||||
await expect(connector.invokeAI(aiAssistantBody)).rejects.toThrow('API Error');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,150 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { ServiceParams, SubActionConnector } from '@kbn/actions-plugin/server';
|
||||
import aws from 'aws4';
|
||||
import type { AxiosError } from 'axios';
|
||||
import {
|
||||
RunActionParamsSchema,
|
||||
RunActionResponseSchema,
|
||||
InvokeAIActionParamsSchema,
|
||||
} from '../../../common/bedrock/schema';
|
||||
import type {
|
||||
Config,
|
||||
Secrets,
|
||||
RunActionParams,
|
||||
RunActionResponse,
|
||||
InvokeAIActionParams,
|
||||
InvokeAIActionResponse,
|
||||
} from '../../../common/bedrock/types';
|
||||
import { SUB_ACTION } from '../../../common/bedrock/constants';
|
||||
|
||||
interface SignedRequest {
|
||||
host: string;
|
||||
headers: Record<string, string>;
|
||||
body: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export class BedrockConnector extends SubActionConnector<Config, Secrets> {
|
||||
private url;
|
||||
private model;
|
||||
|
||||
constructor(params: ServiceParams<Config, Secrets>) {
|
||||
super(params);
|
||||
|
||||
this.url = this.config.apiUrl;
|
||||
this.model = this.config.defaultModel;
|
||||
|
||||
this.registerSubActions();
|
||||
}
|
||||
|
||||
private registerSubActions() {
|
||||
this.registerSubAction({
|
||||
name: SUB_ACTION.RUN,
|
||||
method: 'runApi',
|
||||
schema: RunActionParamsSchema,
|
||||
});
|
||||
|
||||
this.registerSubAction({
|
||||
name: SUB_ACTION.TEST,
|
||||
method: 'runApi',
|
||||
schema: RunActionParamsSchema,
|
||||
});
|
||||
|
||||
this.registerSubAction({
|
||||
name: SUB_ACTION.INVOKE_AI,
|
||||
method: 'invokeAI',
|
||||
schema: InvokeAIActionParamsSchema,
|
||||
});
|
||||
}
|
||||
|
||||
protected getResponseErrorMessage(error: AxiosError<{ error?: { message?: string } }>): string {
|
||||
if (!error.response?.status) {
|
||||
return `Unexpected API Error: ${error.code} - ${error.message}`;
|
||||
}
|
||||
if (error.response.status === 401) {
|
||||
return 'Unauthorized API Error';
|
||||
}
|
||||
return `API Error: ${error.response?.status} - ${error.response?.statusText}${
|
||||
error.response?.data?.error?.message ? ` - ${error.response.data.error?.message}` : ''
|
||||
}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* provides the AWS signature to the external API endpoint
|
||||
* @param body The request body to be signed.
|
||||
* @param path The path of the request URL.
|
||||
*/
|
||||
private signRequest(body: string, path: string) {
|
||||
const { host } = new URL(this.url);
|
||||
return aws.sign(
|
||||
{
|
||||
host,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: '*/*',
|
||||
},
|
||||
body,
|
||||
path,
|
||||
},
|
||||
{
|
||||
secretAccessKey: this.secrets.secret,
|
||||
accessKeyId: this.secrets.accessKey,
|
||||
}
|
||||
) as SignedRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* responsible for making a POST request to the external API endpoint and returning the response data
|
||||
* @param body The stringified request body to be sent in the POST request.
|
||||
* @param model Optional model to be used for the API request. If not provided, the default model from the connector will be used.
|
||||
*/
|
||||
public async runApi({ body, model: reqModel }: RunActionParams): Promise<RunActionResponse> {
|
||||
// set model on per request basis
|
||||
const model = reqModel ? reqModel : this.model;
|
||||
const signed = this.signRequest(body, `/model/${model}/invoke`);
|
||||
const response = await this.request({
|
||||
...signed,
|
||||
url: `${this.url}/model/${model}/invoke`,
|
||||
method: 'post',
|
||||
responseSchema: RunActionResponseSchema,
|
||||
data: body,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* takes in an array of messages and a model as input, and returns a promise that resolves to a string.
|
||||
* The method combines the messages into a single prompt formatted for bedrock,sends a request to the
|
||||
* runApi method with the prompt and model, and returns the trimmed completion from the response.
|
||||
* @param messages An array of message objects, where each object has a role (string) and content (string) property.
|
||||
* @param model Optional model to be used for the API request. If not provided, the default model from the connector will be used.
|
||||
*/
|
||||
public async invokeAI({
|
||||
messages,
|
||||
model,
|
||||
}: InvokeAIActionParams): Promise<InvokeAIActionResponse> {
|
||||
const combinedMessages = messages.reduce((acc: string, message) => {
|
||||
const { role, content } = message;
|
||||
// Bedrock only has Assistant and Human, so 'system' and 'user' will be converted to Human
|
||||
const bedrockRole = role === 'assistant' ? '\n\nAssistant:' : '\n\nHuman:';
|
||||
return `${acc}${bedrockRole}${content}`;
|
||||
}, '');
|
||||
|
||||
const req = {
|
||||
// end prompt in "Assistant:" to avoid the model starting its message with "Assistant:"
|
||||
prompt: `${combinedMessages} \n\nAssistant:`,
|
||||
max_tokens_to_sample: 300,
|
||||
// prevent model from talking to itself
|
||||
stop_sequences: ['\n\nHuman:'],
|
||||
};
|
||||
|
||||
const res = await this.runApi({ body: JSON.stringify(req), model });
|
||||
return res.completion.trim();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,82 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.mock';
|
||||
import { ActionsConfigurationUtilities } from '@kbn/actions-plugin/server/actions_config';
|
||||
import axios from 'axios';
|
||||
import { configValidator, getConnectorType } from '.';
|
||||
import { Config, Secrets } from '../../../common/bedrock/types';
|
||||
import { SubActionConnectorType } from '@kbn/actions-plugin/server/sub_action_framework/types';
|
||||
import { DEFAULT_BEDROCK_MODEL } from '../../../common/bedrock/constants';
|
||||
|
||||
jest.mock('axios');
|
||||
jest.mock('@kbn/actions-plugin/server/lib/axios_utils', () => {
|
||||
const originalUtils = jest.requireActual('@kbn/actions-plugin/server/lib/axios_utils');
|
||||
return {
|
||||
...originalUtils,
|
||||
request: jest.fn(),
|
||||
patch: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
axios.create = jest.fn(() => axios);
|
||||
|
||||
let connectorType: SubActionConnectorType<Config, Secrets>;
|
||||
let configurationUtilities: jest.Mocked<ActionsConfigurationUtilities>;
|
||||
|
||||
describe('Bedrock Connector', () => {
|
||||
beforeEach(() => {
|
||||
configurationUtilities = actionsConfigMock.create();
|
||||
connectorType = getConnectorType();
|
||||
});
|
||||
test('exposes the connector as `AWS Bedrock` with id `.bedrock`', () => {
|
||||
expect(connectorType.id).toEqual('.bedrock');
|
||||
expect(connectorType.name).toEqual('AWS Bedrock');
|
||||
});
|
||||
describe('config validation', () => {
|
||||
test('config validation passes when only required fields are provided', () => {
|
||||
const config: Config = {
|
||||
apiUrl: 'https://api.openai.com/v1/chat/completions',
|
||||
defaultModel: DEFAULT_BEDROCK_MODEL,
|
||||
};
|
||||
|
||||
expect(configValidator(config, { configurationUtilities })).toEqual(config);
|
||||
});
|
||||
|
||||
test('config validation failed when a url is invalid', () => {
|
||||
const config: Config = {
|
||||
apiUrl: 'example.com/do-something',
|
||||
defaultModel: DEFAULT_BEDROCK_MODEL,
|
||||
};
|
||||
expect(() => {
|
||||
configValidator(config, { configurationUtilities });
|
||||
}).toThrowErrorMatchingInlineSnapshot(
|
||||
'"Error configuring AWS Bedrock action: Error: URL Error: Invalid URL: example.com/do-something"'
|
||||
);
|
||||
});
|
||||
|
||||
test('config validation returns an error if the specified URL is not added to allowedHosts', () => {
|
||||
const configUtils = {
|
||||
...actionsConfigMock.create(),
|
||||
ensureUriAllowed: (_: string) => {
|
||||
throw new Error(`target url is not present in allowedHosts`);
|
||||
},
|
||||
};
|
||||
|
||||
const config: Config = {
|
||||
apiUrl: 'http://mylisteningserver.com:9200/endpoint',
|
||||
defaultModel: DEFAULT_BEDROCK_MODEL,
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
configValidator(config, { configurationUtilities: configUtils });
|
||||
}).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Error configuring AWS Bedrock action: Error: error validating url: target url is not present in allowedHosts"`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import {
|
||||
SubActionConnectorType,
|
||||
ValidatorType,
|
||||
} from '@kbn/actions-plugin/server/sub_action_framework/types';
|
||||
import { GeneralConnectorFeatureId } from '@kbn/actions-plugin/common';
|
||||
import { urlAllowListValidator } from '@kbn/actions-plugin/server';
|
||||
import { ValidatorServices } from '@kbn/actions-plugin/server/types';
|
||||
import { assertURL } from '@kbn/actions-plugin/server/sub_action_framework/helpers/validators';
|
||||
import { BEDROCK_CONNECTOR_ID, BEDROCK_TITLE } from '../../../common/bedrock/constants';
|
||||
import { ConfigSchema, SecretsSchema } from '../../../common/bedrock/schema';
|
||||
import { Config, Secrets } from '../../../common/bedrock/types';
|
||||
import { BedrockConnector } from './bedrock';
|
||||
import { renderParameterTemplates } from './render';
|
||||
|
||||
export const getConnectorType = (): SubActionConnectorType<Config, Secrets> => ({
|
||||
id: BEDROCK_CONNECTOR_ID,
|
||||
name: BEDROCK_TITLE,
|
||||
Service: BedrockConnector,
|
||||
schema: {
|
||||
config: ConfigSchema,
|
||||
secrets: SecretsSchema,
|
||||
},
|
||||
validators: [{ type: ValidatorType.CONFIG, validator: configValidator }],
|
||||
supportedFeatureIds: [GeneralConnectorFeatureId],
|
||||
minimumLicenseRequired: 'enterprise' as const,
|
||||
renderParameterTemplates,
|
||||
});
|
||||
|
||||
export const configValidator = (configObject: Config, validatorServices: ValidatorServices) => {
|
||||
try {
|
||||
assertURL(configObject.apiUrl);
|
||||
urlAllowListValidator('apiUrl')(configObject, validatorServices);
|
||||
|
||||
return configObject;
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
i18n.translate('xpack.stackConnectors.bedrock.configurationErrorApiProvider', {
|
||||
defaultMessage: 'Error configuring AWS Bedrock action: {err}',
|
||||
values: {
|
||||
err,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { renderParameterTemplates } from './render';
|
||||
import Mustache from 'mustache';
|
||||
|
||||
const params = {
|
||||
subAction: 'run',
|
||||
subActionParams: {
|
||||
body: '{"domain":"{{domain}}"}',
|
||||
},
|
||||
};
|
||||
|
||||
const variables = { domain: 'm0zepcuuu2' };
|
||||
|
||||
describe('Bedrock - renderParameterTemplates', () => {
|
||||
it('should not render body on test action', () => {
|
||||
const testParams = { subAction: 'test', subActionParams: { body: 'test_json' } };
|
||||
const result = renderParameterTemplates(testParams, variables);
|
||||
expect(result).toEqual(testParams);
|
||||
});
|
||||
|
||||
it('should rendered body with variables', () => {
|
||||
const result = renderParameterTemplates(params, variables);
|
||||
|
||||
expect(result.subActionParams.body).toEqual(
|
||||
JSON.stringify({
|
||||
...variables,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should render error body', () => {
|
||||
const errorMessage = 'test error';
|
||||
jest.spyOn(Mustache, 'render').mockImplementation(() => {
|
||||
throw new Error(errorMessage);
|
||||
});
|
||||
const result = renderParameterTemplates(params, variables);
|
||||
expect(result.subActionParams.body).toEqual(
|
||||
'error rendering mustache template "{"domain":"{{domain}}"}": test error'
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { ExecutorParams } from '@kbn/actions-plugin/server/sub_action_framework/types';
|
||||
import { renderMustacheString } from '@kbn/actions-plugin/server/lib/mustache_renderer';
|
||||
import { RenderParameterTemplates } from '@kbn/actions-plugin/server/types';
|
||||
import { SUB_ACTION } from '../../../common/bedrock/constants';
|
||||
|
||||
export const renderParameterTemplates: RenderParameterTemplates<ExecutorParams> = (
|
||||
params,
|
||||
variables
|
||||
) => {
|
||||
if (params?.subAction !== SUB_ACTION.RUN && params?.subAction !== SUB_ACTION.TEST) return params;
|
||||
|
||||
return {
|
||||
...params,
|
||||
subActionParams: {
|
||||
...params.subActionParams,
|
||||
body: renderMustacheString(params.subActionParams.body as string, variables, 'json'),
|
||||
},
|
||||
};
|
||||
};
|
|
@ -5,8 +5,8 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { initGenAiDashboard } from './create_dashboard';
|
||||
import { getGenAiDashboard } from './dashboard';
|
||||
import { initDashboard } from './create_dashboard';
|
||||
import { getDashboard } from './dashboard';
|
||||
import { savedObjectsClientMock } from '@kbn/core-saved-objects-api-server-mocks';
|
||||
import { loggingSystemMock } from '@kbn/core-logging-server-mocks';
|
||||
import { Logger } from '@kbn/logging';
|
||||
|
@ -24,7 +24,7 @@ describe('createDashboard', () => {
|
|||
});
|
||||
it('fetches the Gen Ai Dashboard saved object', async () => {
|
||||
const dashboardId = 'test-dashboard-id';
|
||||
const result = await initGenAiDashboard({ logger, savedObjectsClient, dashboardId });
|
||||
const result = await initDashboard({ logger, savedObjectsClient, dashboardId });
|
||||
expect(result.success).toBe(true);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
expect(savedObjectsClient.get).toHaveBeenCalledWith('dashboard', dashboardId);
|
||||
|
@ -46,12 +46,12 @@ describe('createDashboard', () => {
|
|||
},
|
||||
}),
|
||||
};
|
||||
const result = await initGenAiDashboard({ logger, savedObjectsClient: soClient, dashboardId });
|
||||
const result = await initDashboard({ logger, savedObjectsClient: soClient, dashboardId });
|
||||
|
||||
expect(soClient.get).toHaveBeenCalledWith('dashboard', dashboardId);
|
||||
expect(soClient.create).toHaveBeenCalledWith(
|
||||
'dashboard',
|
||||
getGenAiDashboard(dashboardId).attributes,
|
||||
getDashboard(dashboardId).attributes,
|
||||
{ overwrite: true, id: dashboardId }
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
|
@ -73,7 +73,7 @@ describe('createDashboard', () => {
|
|||
}),
|
||||
};
|
||||
const dashboardId = 'test-dashboard-id';
|
||||
const result = await initGenAiDashboard({ logger, savedObjectsClient: soClient, dashboardId });
|
||||
const result = await initDashboard({ logger, savedObjectsClient: soClient, dashboardId });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.message).toBe('Internal Server Error: Error happened');
|
||||
expect(result.error?.statusCode).toBe(500);
|
||||
|
|
|
@ -8,14 +8,14 @@ import type { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-ser
|
|||
|
||||
import { DashboardAttributes } from '@kbn/dashboard-plugin/common';
|
||||
import { Logger } from '@kbn/logging';
|
||||
import { getGenAiDashboard } from './dashboard';
|
||||
import { getDashboard } from './dashboard';
|
||||
|
||||
export interface OutputError {
|
||||
message: string;
|
||||
statusCode: number;
|
||||
}
|
||||
|
||||
export const initGenAiDashboard = async ({
|
||||
export const initDashboard = async ({
|
||||
logger,
|
||||
savedObjectsClient,
|
||||
dashboardId,
|
||||
|
@ -50,7 +50,7 @@ export const initGenAiDashboard = async ({
|
|||
try {
|
||||
await savedObjectsClient.create<DashboardAttributes>(
|
||||
'dashboard',
|
||||
getGenAiDashboard(dashboardId).attributes,
|
||||
getDashboard(dashboardId).attributes,
|
||||
{
|
||||
overwrite: true,
|
||||
id: dashboardId,
|
||||
|
|
|
@ -11,7 +11,7 @@ import { SavedObject } from '@kbn/core-saved-objects-common/src/server_types';
|
|||
|
||||
export const dashboardTitle = `Generative AI Token Usage`;
|
||||
|
||||
export const getGenAiDashboard = (dashboardId: string): SavedObject<DashboardAttributes> => {
|
||||
export const getDashboard = (dashboardId: string): SavedObject<DashboardAttributes> => {
|
||||
const ids: Record<string, string> = {
|
||||
genAiSavedObjectId: dashboardId,
|
||||
tokens: uuidv4(),
|
||||
|
|
|
@ -14,18 +14,31 @@ import {
|
|||
} from '../../../common/gen_ai/constants';
|
||||
import { loggingSystemMock } from '@kbn/core-logging-server-mocks';
|
||||
import { actionsMock } from '@kbn/actions-plugin/server/mocks';
|
||||
import {
|
||||
GenAiRunActionResponseSchema,
|
||||
GenAiStreamingResponseSchema,
|
||||
} from '../../../common/gen_ai/schema';
|
||||
import { initGenAiDashboard } from './create_dashboard';
|
||||
import { RunActionResponseSchema, StreamingResponseSchema } from '../../../common/gen_ai/schema';
|
||||
import { initDashboard } from './create_dashboard';
|
||||
jest.mock('./create_dashboard');
|
||||
|
||||
describe('GenAiConnector', () => {
|
||||
let mockRequest: jest.Mock;
|
||||
let mockError: jest.Mock;
|
||||
const mockResponseString = 'Hello! How can I assist you today?';
|
||||
const mockResponse = {
|
||||
headers: {},
|
||||
data: {
|
||||
result: 'success',
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: mockResponseString,
|
||||
},
|
||||
finish_reason: 'stop',
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
beforeEach(() => {
|
||||
const mockResponse = { headers: {}, data: { result: 'success' } };
|
||||
mockRequest = jest.fn().mockResolvedValue(mockResponse);
|
||||
mockError = jest.fn().mockImplementation(() => {
|
||||
throw new Error('API Error');
|
||||
|
@ -68,14 +81,14 @@ describe('GenAiConnector', () => {
|
|||
expect(mockRequest).toHaveBeenCalledWith({
|
||||
url: 'https://api.openai.com/v1/chat/completions',
|
||||
method: 'post',
|
||||
responseSchema: GenAiRunActionResponseSchema,
|
||||
responseSchema: RunActionResponseSchema,
|
||||
data: JSON.stringify({ ...sampleOpenAiBody, stream: false, model: DEFAULT_OPENAI_MODEL }),
|
||||
headers: {
|
||||
Authorization: 'Bearer 123',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
expect(response).toEqual({ result: 'success' });
|
||||
expect(response).toEqual(mockResponse.data);
|
||||
});
|
||||
|
||||
it('overrides the default model with the default model specified in the body', async () => {
|
||||
|
@ -85,14 +98,14 @@ describe('GenAiConnector', () => {
|
|||
expect(mockRequest).toHaveBeenCalledWith({
|
||||
url: 'https://api.openai.com/v1/chat/completions',
|
||||
method: 'post',
|
||||
responseSchema: GenAiRunActionResponseSchema,
|
||||
responseSchema: RunActionResponseSchema,
|
||||
data: JSON.stringify({ ...requestBody, stream: false }),
|
||||
headers: {
|
||||
Authorization: 'Bearer 123',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
expect(response).toEqual({ result: 'success' });
|
||||
expect(response).toEqual(mockResponse.data);
|
||||
});
|
||||
|
||||
it('the OpenAI API call is successful with correct parameters', async () => {
|
||||
|
@ -101,14 +114,14 @@ describe('GenAiConnector', () => {
|
|||
expect(mockRequest).toHaveBeenCalledWith({
|
||||
url: 'https://api.openai.com/v1/chat/completions',
|
||||
method: 'post',
|
||||
responseSchema: GenAiRunActionResponseSchema,
|
||||
responseSchema: RunActionResponseSchema,
|
||||
data: JSON.stringify({ ...sampleOpenAiBody, stream: false, model: DEFAULT_OPENAI_MODEL }),
|
||||
headers: {
|
||||
Authorization: 'Bearer 123',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
expect(response).toEqual({ result: 'success' });
|
||||
expect(response).toEqual(mockResponse.data);
|
||||
});
|
||||
|
||||
it('overrides stream parameter if set in the body', async () => {
|
||||
|
@ -131,7 +144,7 @@ describe('GenAiConnector', () => {
|
|||
expect(mockRequest).toHaveBeenCalledWith({
|
||||
url: 'https://api.openai.com/v1/chat/completions',
|
||||
method: 'post',
|
||||
responseSchema: GenAiRunActionResponseSchema,
|
||||
responseSchema: RunActionResponseSchema,
|
||||
data: JSON.stringify({
|
||||
...body,
|
||||
stream: false,
|
||||
|
@ -141,7 +154,7 @@ describe('GenAiConnector', () => {
|
|||
'content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
expect(response).toEqual({ result: 'success' });
|
||||
expect(response).toEqual(mockResponse.data);
|
||||
});
|
||||
|
||||
it('errors during API calls are properly handled', async () => {
|
||||
|
@ -164,14 +177,14 @@ describe('GenAiConnector', () => {
|
|||
expect(mockRequest).toHaveBeenCalledWith({
|
||||
url: 'https://api.openai.com/v1/chat/completions',
|
||||
method: 'post',
|
||||
responseSchema: GenAiRunActionResponseSchema,
|
||||
responseSchema: RunActionResponseSchema,
|
||||
data: JSON.stringify({ ...sampleOpenAiBody, stream: false, model: DEFAULT_OPENAI_MODEL }),
|
||||
headers: {
|
||||
Authorization: 'Bearer 123',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
expect(response).toEqual({ result: 'success' });
|
||||
expect(response).toEqual(mockResponse.data);
|
||||
});
|
||||
|
||||
it('the OpenAI API call is successful with correct parameters when stream = true', async () => {
|
||||
|
@ -184,7 +197,7 @@ describe('GenAiConnector', () => {
|
|||
responseType: 'stream',
|
||||
url: 'https://api.openai.com/v1/chat/completions',
|
||||
method: 'post',
|
||||
responseSchema: GenAiStreamingResponseSchema,
|
||||
responseSchema: StreamingResponseSchema,
|
||||
data: JSON.stringify({ ...sampleOpenAiBody, stream: true, model: DEFAULT_OPENAI_MODEL }),
|
||||
headers: {
|
||||
Authorization: 'Bearer 123',
|
||||
|
@ -193,7 +206,7 @@ describe('GenAiConnector', () => {
|
|||
});
|
||||
expect(response).toEqual({
|
||||
headers: { 'Content-Type': 'dont-compress-this' },
|
||||
result: 'success',
|
||||
...mockResponse.data,
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -219,7 +232,7 @@ describe('GenAiConnector', () => {
|
|||
responseType: 'stream',
|
||||
url: 'https://api.openai.com/v1/chat/completions',
|
||||
method: 'post',
|
||||
responseSchema: GenAiStreamingResponseSchema,
|
||||
responseSchema: StreamingResponseSchema,
|
||||
data: JSON.stringify({
|
||||
...body,
|
||||
stream: true,
|
||||
|
@ -231,7 +244,7 @@ describe('GenAiConnector', () => {
|
|||
});
|
||||
expect(response).toEqual({
|
||||
headers: { 'Content-Type': 'dont-compress-this' },
|
||||
result: 'success',
|
||||
...mockResponse.data,
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -244,6 +257,31 @@ describe('GenAiConnector', () => {
|
|||
).rejects.toThrow('API Error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('invokeAI', () => {
|
||||
it('the API call is successful with correct parameters', async () => {
|
||||
const response = await connector.invokeAI(sampleOpenAiBody);
|
||||
expect(mockRequest).toBeCalledTimes(1);
|
||||
expect(mockRequest).toHaveBeenCalledWith({
|
||||
url: 'https://api.openai.com/v1/chat/completions',
|
||||
method: 'post',
|
||||
responseSchema: RunActionResponseSchema,
|
||||
data: JSON.stringify({ ...sampleOpenAiBody, stream: false, model: DEFAULT_OPENAI_MODEL }),
|
||||
headers: {
|
||||
Authorization: 'Bearer 123',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
expect(response).toEqual(mockResponseString);
|
||||
});
|
||||
|
||||
it('errors during API calls are properly handled', async () => {
|
||||
// @ts-ignore
|
||||
connector.request = mockError;
|
||||
|
||||
await expect(connector.invokeAI(sampleOpenAiBody)).rejects.toThrow('API Error');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AzureAI', () => {
|
||||
|
@ -282,14 +320,14 @@ describe('GenAiConnector', () => {
|
|||
expect(mockRequest).toHaveBeenCalledWith({
|
||||
url: 'https://My-test-resource-123.openai.azure.com/openai/deployments/NEW-DEPLOYMENT-321/chat/completions?api-version=2023-05-15',
|
||||
method: 'post',
|
||||
responseSchema: GenAiRunActionResponseSchema,
|
||||
responseSchema: RunActionResponseSchema,
|
||||
data: JSON.stringify({ ...sampleAzureAiBody, stream: false }),
|
||||
headers: {
|
||||
'api-key': '123',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
expect(response).toEqual({ result: 'success' });
|
||||
expect(response).toEqual(mockResponse.data);
|
||||
});
|
||||
|
||||
it('overrides stream parameter if set in the body', async () => {
|
||||
|
@ -308,14 +346,14 @@ describe('GenAiConnector', () => {
|
|||
expect(mockRequest).toHaveBeenCalledWith({
|
||||
url: 'https://My-test-resource-123.openai.azure.com/openai/deployments/NEW-DEPLOYMENT-321/chat/completions?api-version=2023-05-15',
|
||||
method: 'post',
|
||||
responseSchema: GenAiRunActionResponseSchema,
|
||||
responseSchema: RunActionResponseSchema,
|
||||
data: JSON.stringify({ ...sampleAzureAiBody, stream: false }),
|
||||
headers: {
|
||||
'api-key': '123',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
expect(response).toEqual({ result: 'success' });
|
||||
expect(response).toEqual(mockResponse.data);
|
||||
});
|
||||
|
||||
it('errors during API calls are properly handled', async () => {
|
||||
|
@ -338,14 +376,14 @@ describe('GenAiConnector', () => {
|
|||
expect(mockRequest).toHaveBeenCalledWith({
|
||||
url: 'https://My-test-resource-123.openai.azure.com/openai/deployments/NEW-DEPLOYMENT-321/chat/completions?api-version=2023-05-15',
|
||||
method: 'post',
|
||||
responseSchema: GenAiRunActionResponseSchema,
|
||||
responseSchema: RunActionResponseSchema,
|
||||
data: JSON.stringify({ ...sampleAzureAiBody, stream: false }),
|
||||
headers: {
|
||||
'api-key': '123',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
expect(response).toEqual({ result: 'success' });
|
||||
expect(response).toEqual(mockResponse.data);
|
||||
});
|
||||
|
||||
it('the AzureAI API call is successful with correct parameters when stream = true', async () => {
|
||||
|
@ -358,7 +396,7 @@ describe('GenAiConnector', () => {
|
|||
responseType: 'stream',
|
||||
url: 'https://My-test-resource-123.openai.azure.com/openai/deployments/NEW-DEPLOYMENT-321/chat/completions?api-version=2023-05-15',
|
||||
method: 'post',
|
||||
responseSchema: GenAiStreamingResponseSchema,
|
||||
responseSchema: StreamingResponseSchema,
|
||||
data: JSON.stringify({ ...sampleAzureAiBody, stream: true }),
|
||||
headers: {
|
||||
'api-key': '123',
|
||||
|
@ -367,7 +405,7 @@ describe('GenAiConnector', () => {
|
|||
});
|
||||
expect(response).toEqual({
|
||||
headers: { 'Content-Type': 'dont-compress-this' },
|
||||
result: 'success',
|
||||
...mockResponse.data,
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -389,7 +427,7 @@ describe('GenAiConnector', () => {
|
|||
responseType: 'stream',
|
||||
url: 'https://My-test-resource-123.openai.azure.com/openai/deployments/NEW-DEPLOYMENT-321/chat/completions?api-version=2023-05-15',
|
||||
method: 'post',
|
||||
responseSchema: GenAiStreamingResponseSchema,
|
||||
responseSchema: StreamingResponseSchema,
|
||||
data: JSON.stringify({
|
||||
...body,
|
||||
stream: true,
|
||||
|
@ -401,7 +439,7 @@ describe('GenAiConnector', () => {
|
|||
});
|
||||
expect(response).toEqual({
|
||||
headers: { 'Content-Type': 'dont-compress-this' },
|
||||
result: 'success',
|
||||
...mockResponse.data,
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -425,7 +463,7 @@ describe('GenAiConnector', () => {
|
|||
logger: loggingSystemMock.createLogger(),
|
||||
services: actionsMock.createServices(),
|
||||
});
|
||||
const mockGenAi = initGenAiDashboard as jest.Mock;
|
||||
const mockGenAi = initDashboard as jest.Mock;
|
||||
beforeEach(() => {
|
||||
// @ts-ignore
|
||||
connector.esClient.transport.request = mockRequest;
|
||||
|
|
|
@ -7,25 +7,28 @@
|
|||
|
||||
import { ServiceParams, SubActionConnector } from '@kbn/actions-plugin/server';
|
||||
import type { AxiosError } from 'axios';
|
||||
import { initGenAiDashboard } from './create_dashboard';
|
||||
import { initDashboard } from './create_dashboard';
|
||||
import {
|
||||
GenAiRunActionParamsSchema,
|
||||
GenAiRunActionResponseSchema,
|
||||
GenAiDashboardActionParamsSchema,
|
||||
GenAiStreamActionParamsSchema,
|
||||
GenAiStreamingResponseSchema,
|
||||
RunActionParamsSchema,
|
||||
RunActionResponseSchema,
|
||||
DashboardActionParamsSchema,
|
||||
StreamActionParamsSchema,
|
||||
StreamingResponseSchema,
|
||||
InvokeAIActionParamsSchema,
|
||||
} from '../../../common/gen_ai/schema';
|
||||
import type {
|
||||
GenAiConfig,
|
||||
GenAiSecrets,
|
||||
GenAiRunActionParams,
|
||||
GenAiRunActionResponse,
|
||||
GenAiStreamActionParams,
|
||||
Config,
|
||||
Secrets,
|
||||
RunActionParams,
|
||||
RunActionResponse,
|
||||
StreamActionParams,
|
||||
} from '../../../common/gen_ai/types';
|
||||
import { SUB_ACTION } from '../../../common/gen_ai/constants';
|
||||
import {
|
||||
GenAiDashboardActionParams,
|
||||
GenAiDashboardActionResponse,
|
||||
DashboardActionParams,
|
||||
DashboardActionResponse,
|
||||
InvokeAIActionParams,
|
||||
InvokeAIActionResponse,
|
||||
} from '../../../common/gen_ai/types';
|
||||
import {
|
||||
getAxiosOptions,
|
||||
|
@ -34,12 +37,12 @@ import {
|
|||
sanitizeRequest,
|
||||
} from './lib/utils';
|
||||
|
||||
export class GenAiConnector extends SubActionConnector<GenAiConfig, GenAiSecrets> {
|
||||
export class GenAiConnector extends SubActionConnector<Config, Secrets> {
|
||||
private url;
|
||||
private provider;
|
||||
private key;
|
||||
|
||||
constructor(params: ServiceParams<GenAiConfig, GenAiSecrets>) {
|
||||
constructor(params: ServiceParams<Config, Secrets>) {
|
||||
super(params);
|
||||
|
||||
this.url = this.config.apiUrl;
|
||||
|
@ -53,25 +56,31 @@ export class GenAiConnector extends SubActionConnector<GenAiConfig, GenAiSecrets
|
|||
this.registerSubAction({
|
||||
name: SUB_ACTION.RUN,
|
||||
method: 'runApi',
|
||||
schema: GenAiRunActionParamsSchema,
|
||||
schema: RunActionParamsSchema,
|
||||
});
|
||||
|
||||
this.registerSubAction({
|
||||
name: SUB_ACTION.TEST,
|
||||
method: 'runApi',
|
||||
schema: GenAiRunActionParamsSchema,
|
||||
schema: RunActionParamsSchema,
|
||||
});
|
||||
|
||||
this.registerSubAction({
|
||||
name: SUB_ACTION.STREAM,
|
||||
method: 'streamApi',
|
||||
schema: GenAiStreamActionParamsSchema,
|
||||
schema: StreamActionParamsSchema,
|
||||
});
|
||||
|
||||
this.registerSubAction({
|
||||
name: SUB_ACTION.DASHBOARD,
|
||||
method: 'getDashboard',
|
||||
schema: GenAiDashboardActionParamsSchema,
|
||||
schema: DashboardActionParamsSchema,
|
||||
});
|
||||
|
||||
this.registerSubAction({
|
||||
name: SUB_ACTION.INVOKE_AI,
|
||||
method: 'invokeAI',
|
||||
schema: InvokeAIActionParamsSchema,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -86,8 +95,11 @@ export class GenAiConnector extends SubActionConnector<GenAiConfig, GenAiSecrets
|
|||
error.response?.data?.error?.message ? ` - ${error.response.data.error?.message}` : ''
|
||||
}`;
|
||||
}
|
||||
|
||||
public async runApi({ body }: GenAiRunActionParams): Promise<GenAiRunActionResponse> {
|
||||
/**
|
||||
* responsible for making a POST request to the external API endpoint and returning the response data
|
||||
* @param body The stringified request body to be sent in the POST request.
|
||||
*/
|
||||
public async runApi({ body }: RunActionParams): Promise<RunActionResponse> {
|
||||
const sanitizedBody = sanitizeRequest(
|
||||
this.provider,
|
||||
this.url,
|
||||
|
@ -98,17 +110,22 @@ export class GenAiConnector extends SubActionConnector<GenAiConfig, GenAiSecrets
|
|||
const response = await this.request({
|
||||
url: this.url,
|
||||
method: 'post',
|
||||
responseSchema: GenAiRunActionResponseSchema,
|
||||
responseSchema: RunActionResponseSchema,
|
||||
data: sanitizedBody,
|
||||
...axiosOptions,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
public async streamApi({
|
||||
body,
|
||||
stream,
|
||||
}: GenAiStreamActionParams): Promise<GenAiRunActionResponse> {
|
||||
/**
|
||||
* responsible for making a POST request to a specified URL with a given request body.
|
||||
* The method can handle both regular API requests and streaming requests based on the stream parameter.
|
||||
* It uses helper functions getRequestWithStreamOption and getAxiosOptions to prepare the request body and headers respectively.
|
||||
* The response is then processed based on whether it is a streaming response or a regular response.
|
||||
* @param body request body for the API request
|
||||
* @param stream flag indicating whether it is a streaming request or not
|
||||
*/
|
||||
public async streamApi({ body, stream }: StreamActionParams): Promise<RunActionResponse> {
|
||||
const executeBody = getRequestWithStreamOption(
|
||||
this.provider,
|
||||
this.url,
|
||||
|
@ -121,16 +138,21 @@ export class GenAiConnector extends SubActionConnector<GenAiConfig, GenAiSecrets
|
|||
const response = await this.request({
|
||||
url: this.url,
|
||||
method: 'post',
|
||||
responseSchema: stream ? GenAiStreamingResponseSchema : GenAiRunActionResponseSchema,
|
||||
responseSchema: stream ? StreamingResponseSchema : RunActionResponseSchema,
|
||||
data: executeBody,
|
||||
...axiosOptions,
|
||||
});
|
||||
return stream ? pipeStreamingResponse(response) : response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* retrieves a dashboard from the Kibana server and checks if the
|
||||
* user has the necessary privileges to access it.
|
||||
* @param dashboardId The ID of the dashboard to retrieve.
|
||||
*/
|
||||
public async getDashboard({
|
||||
dashboardId,
|
||||
}: GenAiDashboardActionParams): Promise<GenAiDashboardActionResponse> {
|
||||
}: DashboardActionParams): Promise<DashboardActionResponse> {
|
||||
const privilege = (await this.esClient.transport.request({
|
||||
path: '/_security/user/_has_privileges',
|
||||
method: 'POST',
|
||||
|
@ -149,7 +171,7 @@ export class GenAiConnector extends SubActionConnector<GenAiConfig, GenAiSecrets
|
|||
return { available: false };
|
||||
}
|
||||
|
||||
const response = await initGenAiDashboard({
|
||||
const response = await initDashboard({
|
||||
logger: this.logger,
|
||||
savedObjectsClient: this.savedObjectsClient,
|
||||
dashboardId,
|
||||
|
@ -157,4 +179,22 @@ export class GenAiConnector extends SubActionConnector<GenAiConfig, GenAiSecrets
|
|||
|
||||
return { available: response.success };
|
||||
}
|
||||
|
||||
/**
|
||||
* takes an array of messages and a model as input and returns a promise that resolves to a string.
|
||||
* Sends the stringified input to the runApi method. Returns the trimmed completion from the response.
|
||||
* @param body An object containing array of message objects, and possible other OpenAI properties
|
||||
*/
|
||||
public async invokeAI(body: InvokeAIActionParams): Promise<InvokeAIActionResponse> {
|
||||
const res = await this.runApi({ body: JSON.stringify(body) });
|
||||
|
||||
if (res.choices && res.choices.length > 0 && res.choices[0].message?.content) {
|
||||
const result = res.choices[0].message.content.trim();
|
||||
return result;
|
||||
}
|
||||
|
||||
// TO DO: Pass actual error
|
||||
// tracked here https://github.com/elastic/security-team/issues/7373
|
||||
return 'An error occurred sending your message. If the problem persists, please test the connector configuration.';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@ import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.moc
|
|||
import { ActionsConfigurationUtilities } from '@kbn/actions-plugin/server/actions_config';
|
||||
import axios from 'axios';
|
||||
import { configValidator, getConnectorType } from '.';
|
||||
import { GenAiConfig, GenAiSecrets } from '../../../common/gen_ai/types';
|
||||
import { Config, Secrets } from '../../../common/gen_ai/types';
|
||||
import { SubActionConnectorType } from '@kbn/actions-plugin/server/sub_action_framework/types';
|
||||
import { DEFAULT_OPENAI_MODEL, OpenAiProviderType } from '../../../common/gen_ai/constants';
|
||||
|
||||
|
@ -27,7 +27,7 @@ axios.create = jest.fn(() => axios);
|
|||
|
||||
axios.create = jest.fn(() => axios);
|
||||
|
||||
let connectorType: SubActionConnectorType<GenAiConfig, GenAiSecrets>;
|
||||
let connectorType: SubActionConnectorType<Config, Secrets>;
|
||||
let configurationUtilities: jest.Mocked<ActionsConfigurationUtilities>;
|
||||
|
||||
describe('Generative AI Connector', () => {
|
||||
|
@ -37,11 +37,11 @@ describe('Generative AI Connector', () => {
|
|||
});
|
||||
test('exposes the connector as `Generative AI` with id `.gen-ai`', () => {
|
||||
expect(connectorType.id).toEqual('.gen-ai');
|
||||
expect(connectorType.name).toEqual('Generative AI');
|
||||
expect(connectorType.name).toEqual('OpenAI');
|
||||
});
|
||||
describe('config validation', () => {
|
||||
test('config validation passes when only required fields are provided', () => {
|
||||
const config: GenAiConfig = {
|
||||
const config: Config = {
|
||||
apiUrl: 'https://api.openai.com/v1/chat/completions',
|
||||
apiProvider: OpenAiProviderType.OpenAi,
|
||||
defaultModel: DEFAULT_OPENAI_MODEL,
|
||||
|
@ -51,7 +51,7 @@ describe('Generative AI Connector', () => {
|
|||
});
|
||||
|
||||
test('config validation failed when a url is invalid', () => {
|
||||
const config: GenAiConfig = {
|
||||
const config: Config = {
|
||||
apiUrl: 'example.com/do-something',
|
||||
apiProvider: OpenAiProviderType.OpenAi,
|
||||
defaultModel: DEFAULT_OPENAI_MODEL,
|
||||
|
@ -64,7 +64,7 @@ describe('Generative AI Connector', () => {
|
|||
});
|
||||
|
||||
test('config validation failed when the OpenAI API provider is empty', () => {
|
||||
const config: GenAiConfig = {
|
||||
const config: Config = {
|
||||
apiUrl: 'https://api.openai.com/v1/chat/completions',
|
||||
apiProvider: '' as OpenAiProviderType,
|
||||
defaultModel: DEFAULT_OPENAI_MODEL,
|
||||
|
@ -77,7 +77,7 @@ describe('Generative AI Connector', () => {
|
|||
});
|
||||
|
||||
test('config validation failed when the OpenAI API provider is invalid', () => {
|
||||
const config: GenAiConfig = {
|
||||
const config: Config = {
|
||||
apiUrl: 'https://api.openai.com/v1/chat/completions',
|
||||
apiProvider: 'bad-one' as OpenAiProviderType,
|
||||
defaultModel: DEFAULT_OPENAI_MODEL,
|
||||
|
@ -97,7 +97,7 @@ describe('Generative AI Connector', () => {
|
|||
},
|
||||
};
|
||||
|
||||
const config: GenAiConfig = {
|
||||
const config: Config = {
|
||||
apiUrl: 'http://mylisteningserver.com:9200/endpoint',
|
||||
apiProvider: OpenAiProviderType.OpenAi,
|
||||
defaultModel: DEFAULT_OPENAI_MODEL,
|
||||
|
|
|
@ -16,21 +16,21 @@ import { ValidatorServices } from '@kbn/actions-plugin/server/types';
|
|||
import { assertURL } from '@kbn/actions-plugin/server/sub_action_framework/helpers/validators';
|
||||
import {
|
||||
GEN_AI_CONNECTOR_ID,
|
||||
GEN_AI_TITLE,
|
||||
OPEN_AI_TITLE,
|
||||
OpenAiProviderType,
|
||||
} from '../../../common/gen_ai/constants';
|
||||
import { GenAiConfigSchema, GenAiSecretsSchema } from '../../../common/gen_ai/schema';
|
||||
import { GenAiConfig, GenAiSecrets } from '../../../common/gen_ai/types';
|
||||
import { ConfigSchema, SecretsSchema } from '../../../common/gen_ai/schema';
|
||||
import { Config, Secrets } from '../../../common/gen_ai/types';
|
||||
import { GenAiConnector } from './gen_ai';
|
||||
import { renderParameterTemplates } from './render';
|
||||
|
||||
export const getConnectorType = (): SubActionConnectorType<GenAiConfig, GenAiSecrets> => ({
|
||||
export const getConnectorType = (): SubActionConnectorType<Config, Secrets> => ({
|
||||
id: GEN_AI_CONNECTOR_ID,
|
||||
name: GEN_AI_TITLE,
|
||||
name: OPEN_AI_TITLE,
|
||||
Service: GenAiConnector,
|
||||
schema: {
|
||||
config: GenAiConfigSchema,
|
||||
secrets: GenAiSecretsSchema,
|
||||
config: ConfigSchema,
|
||||
secrets: SecretsSchema,
|
||||
},
|
||||
validators: [{ type: ValidatorType.CONFIG, validator: configValidator }],
|
||||
supportedFeatureIds: [GeneralConnectorFeatureId],
|
||||
|
@ -38,10 +38,7 @@ export const getConnectorType = (): SubActionConnectorType<GenAiConfig, GenAiSec
|
|||
renderParameterTemplates,
|
||||
});
|
||||
|
||||
export const configValidator = (
|
||||
configObject: GenAiConfig,
|
||||
validatorServices: ValidatorServices
|
||||
) => {
|
||||
export const configValidator = (configObject: Config, validatorServices: ValidatorServices) => {
|
||||
try {
|
||||
assertURL(configObject.apiUrl);
|
||||
urlAllowListValidator('apiUrl')(configObject, validatorServices);
|
||||
|
|
|
@ -18,6 +18,7 @@ import { getActionType as getTorqConnectorType } from './torq';
|
|||
import { getConnectorType as getEmailConnectorType } from './email';
|
||||
import { getConnectorType as getIndexConnectorType } from './es_index';
|
||||
import { getConnectorType as getGenerativeAiConnectorType } from './gen_ai';
|
||||
import { getConnectorType as getBedrockConnectorType } from './bedrock';
|
||||
import { getConnectorType as getPagerDutyConnectorType } from './pagerduty';
|
||||
import { getConnectorType as getSwimlaneConnectorType } from './swimlane';
|
||||
import { getConnectorType as getServerLogConnectorType } from './server_log';
|
||||
|
@ -101,5 +102,6 @@ export function registerConnectorTypes({
|
|||
actions.registerSubActionConnectorType(getOpsgenieConnectorType());
|
||||
actions.registerSubActionConnectorType(getTinesConnectorType());
|
||||
actions.registerSubActionConnectorType(getGenerativeAiConnectorType());
|
||||
actions.registerSubActionConnectorType(getBedrockConnectorType());
|
||||
actions.registerSubActionConnectorType(getD3SecurityConnectorType());
|
||||
}
|
||||
|
|
|
@ -138,7 +138,7 @@ describe('Stack Connectors Plugin', () => {
|
|||
name: 'Torq',
|
||||
})
|
||||
);
|
||||
expect(actionsSetup.registerSubActionConnectorType).toHaveBeenCalledTimes(4);
|
||||
expect(actionsSetup.registerSubActionConnectorType).toHaveBeenCalledTimes(5);
|
||||
expect(actionsSetup.registerSubActionConnectorType).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
|
@ -157,11 +157,18 @@ describe('Stack Connectors Plugin', () => {
|
|||
3,
|
||||
expect.objectContaining({
|
||||
id: '.gen-ai',
|
||||
name: 'Generative AI',
|
||||
name: 'OpenAI',
|
||||
})
|
||||
);
|
||||
expect(actionsSetup.registerSubActionConnectorType).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
expect.objectContaining({
|
||||
id: '.bedrock',
|
||||
name: 'AWS Bedrock',
|
||||
})
|
||||
);
|
||||
expect(actionsSetup.registerSubActionConnectorType).toHaveBeenNthCalledWith(
|
||||
5,
|
||||
expect.objectContaining({
|
||||
id: '.d3security',
|
||||
name: 'D3 Security',
|
||||
|
|
|
@ -33,6 +33,7 @@ interface CreateTestConfigOptions {
|
|||
|
||||
// test.not-enabled is specifically not enabled
|
||||
const enabledActionTypes = [
|
||||
'.bedrock',
|
||||
'.cases-webhook',
|
||||
'.email',
|
||||
'.index',
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import http from 'http';
|
||||
|
||||
import { ProxyArgs, Simulator } from './simulator';
|
||||
|
||||
export class BedrockSimulator extends Simulator {
|
||||
private readonly returnError: boolean;
|
||||
|
||||
constructor({ returnError = false, proxy }: { returnError?: boolean; proxy?: ProxyArgs }) {
|
||||
super(proxy);
|
||||
|
||||
this.returnError = returnError;
|
||||
}
|
||||
|
||||
public async handler(
|
||||
request: http.IncomingMessage,
|
||||
response: http.ServerResponse,
|
||||
data: Record<string, unknown>
|
||||
) {
|
||||
if (this.returnError) {
|
||||
return BedrockSimulator.sendErrorResponse(response);
|
||||
}
|
||||
|
||||
return BedrockSimulator.sendResponse(response);
|
||||
}
|
||||
|
||||
private static sendResponse(response: http.ServerResponse) {
|
||||
response.statusCode = 202;
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(JSON.stringify(bedrockSuccessResponse, null, 4));
|
||||
}
|
||||
|
||||
private static sendErrorResponse(response: http.ServerResponse) {
|
||||
response.statusCode = 422;
|
||||
response.setHeader('Content-Type', 'application/json;charset=UTF-8');
|
||||
response.end(JSON.stringify(bedrockFailedResponse, null, 4));
|
||||
}
|
||||
}
|
||||
|
||||
export const bedrockSuccessResponse = {
|
||||
stop_reason: 'max_tokens',
|
||||
completion: 'Hello there! How may I assist you today?',
|
||||
};
|
||||
|
||||
export const bedrockFailedResponse = {
|
||||
message:
|
||||
'Malformed input request: extraneous key [ooooo] is not permitted, please reformat your input and try again.',
|
||||
};
|
|
@ -0,0 +1,450 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import expect from '@kbn/expect';
|
||||
|
||||
import {
|
||||
BedrockSimulator,
|
||||
bedrockSuccessResponse,
|
||||
} from '@kbn/actions-simulators-plugin/server/bedrock_simulation';
|
||||
import { FtrProviderContext } from '../../../../../common/ftr_provider_context';
|
||||
import { getUrlPrefix, ObjectRemover } from '../../../../../common/lib';
|
||||
|
||||
const connectorTypeId = '.bedrock';
|
||||
const name = 'A bedrock action';
|
||||
const secrets = {
|
||||
accessKey: 'bedrockAccessKey',
|
||||
secret: 'bedrockSecret',
|
||||
};
|
||||
|
||||
const defaultConfig = {
|
||||
defaultModel: 'anthropic.claude-v2',
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default function bedrockTest({ getService }: FtrProviderContext) {
|
||||
const supertest = getService('supertest');
|
||||
const objectRemover = new ObjectRemover(supertest);
|
||||
const configService = getService('config');
|
||||
const createConnector = async (apiUrl: string, spaceId?: string) => {
|
||||
const result = await supertest
|
||||
.post(`${getUrlPrefix(spaceId ?? 'default')}/api/actions/connector`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
name,
|
||||
connector_type_id: connectorTypeId,
|
||||
config: { ...defaultConfig, apiUrl },
|
||||
secrets,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const { body } = result;
|
||||
|
||||
objectRemover.add(spaceId ?? 'default', body.id, 'connector', 'actions');
|
||||
|
||||
return body.id;
|
||||
};
|
||||
|
||||
describe('Bedrock', () => {
|
||||
after(() => {
|
||||
objectRemover.removeAll();
|
||||
});
|
||||
describe('action creation', () => {
|
||||
const simulator = new BedrockSimulator({
|
||||
returnError: false,
|
||||
proxy: {
|
||||
config: configService.get('kbnTestServer.serverArgs'),
|
||||
},
|
||||
});
|
||||
const config = { ...defaultConfig, apiUrl: '' };
|
||||
|
||||
before(async () => {
|
||||
config.apiUrl = await simulator.start();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
simulator.close();
|
||||
});
|
||||
|
||||
it('should return 200 when creating the connector', async () => {
|
||||
const { body: createdAction } = await supertest
|
||||
.post('/api/actions/connector')
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
name,
|
||||
connector_type_id: connectorTypeId,
|
||||
config,
|
||||
secrets,
|
||||
})
|
||||
.expect(200);
|
||||
expect(createdAction).to.eql({
|
||||
id: createdAction.id,
|
||||
is_preconfigured: false,
|
||||
is_system_action: false,
|
||||
is_deprecated: false,
|
||||
name,
|
||||
connector_type_id: connectorTypeId,
|
||||
is_missing_secrets: false,
|
||||
config,
|
||||
});
|
||||
});
|
||||
|
||||
it('Falls back to default model when connector is created without the model', async () => {
|
||||
const { defaultModel: _, ...rest } = config;
|
||||
const { body: createdAction } = await supertest
|
||||
.post('/api/actions/connector')
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
name,
|
||||
connector_type_id: connectorTypeId,
|
||||
config: rest,
|
||||
secrets,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(createdAction).to.eql({
|
||||
id: createdAction.id,
|
||||
is_preconfigured: false,
|
||||
is_system_action: false,
|
||||
is_deprecated: false,
|
||||
name,
|
||||
connector_type_id: connectorTypeId,
|
||||
is_missing_secrets: false,
|
||||
config,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 Bad Request when creating the connector without the apiUrl', async () => {
|
||||
const { apiUrl: _, ...rest } = config;
|
||||
await supertest
|
||||
.post('/api/actions/connector')
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
name,
|
||||
connector_type_id: connectorTypeId,
|
||||
config: rest,
|
||||
secrets,
|
||||
})
|
||||
.expect(400)
|
||||
.then((resp: any) => {
|
||||
expect(resp.body).to.eql({
|
||||
statusCode: 400,
|
||||
error: 'Bad Request',
|
||||
message:
|
||||
'error validating action type config: [apiUrl]: expected value of type [string] but got [undefined]',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 Bad Request when creating the connector with a apiUrl that is not allowed', async () => {
|
||||
await supertest
|
||||
.post('/api/actions/connector')
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
name,
|
||||
connector_type_id: connectorTypeId,
|
||||
config: {
|
||||
...defaultConfig,
|
||||
apiUrl: 'http://bedrock.mynonexistent.com',
|
||||
},
|
||||
secrets,
|
||||
})
|
||||
.expect(400)
|
||||
.then((resp: any) => {
|
||||
expect(resp.body).to.eql({
|
||||
statusCode: 400,
|
||||
error: 'Bad Request',
|
||||
message:
|
||||
'error validating action type config: Error configuring AWS Bedrock action: Error: error validating url: target url "http://bedrock.mynonexistent.com" is not added to the Kibana config xpack.actions.allowedHosts',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 Bad Request when creating the connector without secrets', async () => {
|
||||
await supertest
|
||||
.post('/api/actions/connector')
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
name,
|
||||
connector_type_id: connectorTypeId,
|
||||
config,
|
||||
})
|
||||
.expect(400)
|
||||
.then((resp: any) => {
|
||||
expect(resp.body).to.eql({
|
||||
statusCode: 400,
|
||||
error: 'Bad Request',
|
||||
message:
|
||||
'error validating action type secrets: [accessKey]: expected value of type [string] but got [undefined]',
|
||||
});
|
||||
});
|
||||
});
|
||||
it('should return 400 Bad Request when creating the connector without accessKey secret', async () => {
|
||||
await supertest
|
||||
.post('/api/actions/connector')
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
name,
|
||||
connector_type_id: connectorTypeId,
|
||||
config,
|
||||
secrets: {
|
||||
secret: 'secret',
|
||||
},
|
||||
})
|
||||
.expect(400)
|
||||
.then((resp: any) => {
|
||||
expect(resp.body).to.eql({
|
||||
statusCode: 400,
|
||||
error: 'Bad Request',
|
||||
message:
|
||||
'error validating action type secrets: [accessKey]: expected value of type [string] but got [undefined]',
|
||||
});
|
||||
});
|
||||
});
|
||||
it('should return 400 Bad Request when creating the connector without secret secret', async () => {
|
||||
await supertest
|
||||
.post('/api/actions/connector')
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
name,
|
||||
connector_type_id: connectorTypeId,
|
||||
config,
|
||||
secrets: {
|
||||
accessKey: 'accessKey',
|
||||
},
|
||||
})
|
||||
.expect(400)
|
||||
.then((resp: any) => {
|
||||
expect(resp.body).to.eql({
|
||||
statusCode: 400,
|
||||
error: 'Bad Request',
|
||||
message:
|
||||
'error validating action type secrets: [secret]: expected value of type [string] but got [undefined]',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('executor', () => {
|
||||
describe('validation', () => {
|
||||
const simulator = new BedrockSimulator({
|
||||
proxy: {
|
||||
config: configService.get('kbnTestServer.serverArgs'),
|
||||
},
|
||||
});
|
||||
let bedrockActionId: string;
|
||||
|
||||
before(async () => {
|
||||
const apiUrl = await simulator.start();
|
||||
bedrockActionId = await createConnector(apiUrl);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
simulator.close();
|
||||
});
|
||||
|
||||
it('should fail when the params is empty', async () => {
|
||||
const { body } = await supertest
|
||||
.post(`/api/actions/connector/${bedrockActionId}/_execute`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
params: {},
|
||||
});
|
||||
expect(200);
|
||||
|
||||
expect(body).to.eql({
|
||||
status: 'error',
|
||||
connector_id: bedrockActionId,
|
||||
message:
|
||||
'error validating action params: [subAction]: expected value of type [string] but got [undefined]',
|
||||
retry: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should fail when the subAction is invalid', async () => {
|
||||
const { body } = await supertest
|
||||
.post(`/api/actions/connector/${bedrockActionId}/_execute`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
params: { subAction: 'invalidAction' },
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(body).to.eql({
|
||||
connector_id: bedrockActionId,
|
||||
status: 'error',
|
||||
retry: true,
|
||||
message: 'an error occurred while running the action',
|
||||
service_message: `Sub action "invalidAction" is not registered. Connector id: ${bedrockActionId}. Connector name: AWS Bedrock. Connector type: .bedrock`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('execution', () => {
|
||||
describe('successful response simulator', () => {
|
||||
const simulator = new BedrockSimulator({
|
||||
proxy: {
|
||||
config: configService.get('kbnTestServer.serverArgs'),
|
||||
},
|
||||
});
|
||||
let apiUrl: string;
|
||||
let bedrockActionId: string;
|
||||
|
||||
before(async () => {
|
||||
apiUrl = await simulator.start();
|
||||
bedrockActionId = await createConnector(apiUrl);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
simulator.close();
|
||||
});
|
||||
|
||||
it('should send a stringified JSON object', async () => {
|
||||
const DEFAULT_BODY = {
|
||||
prompt: `Hello world!`,
|
||||
max_tokens_to_sample: 300,
|
||||
stop_sequences: ['\n\nHuman:'],
|
||||
};
|
||||
const { body } = await supertest
|
||||
.post(`/api/actions/connector/${bedrockActionId}/_execute`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
params: {
|
||||
subAction: 'test',
|
||||
subActionParams: {
|
||||
body: JSON.stringify(DEFAULT_BODY),
|
||||
},
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(simulator.requestData).to.eql(DEFAULT_BODY);
|
||||
expect(simulator.requestUrl).to.eql(
|
||||
`${apiUrl}/model/${defaultConfig.defaultModel}/invoke`
|
||||
);
|
||||
expect(body).to.eql({
|
||||
status: 'ok',
|
||||
connector_id: bedrockActionId,
|
||||
data: bedrockSuccessResponse,
|
||||
});
|
||||
});
|
||||
|
||||
it('should overwrite the model when a model argument is provided', async () => {
|
||||
const DEFAULT_BODY = {
|
||||
prompt: `Hello world!`,
|
||||
max_tokens_to_sample: 300,
|
||||
stop_sequences: ['\n\nHuman:'],
|
||||
};
|
||||
const { body } = await supertest
|
||||
.post(`/api/actions/connector/${bedrockActionId}/_execute`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
params: {
|
||||
subAction: 'test',
|
||||
subActionParams: {
|
||||
body: JSON.stringify(DEFAULT_BODY),
|
||||
model: 'some-other-model',
|
||||
},
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(simulator.requestData).to.eql(DEFAULT_BODY);
|
||||
expect(simulator.requestUrl).to.eql(`${apiUrl}/model/some-other-model/invoke`);
|
||||
expect(body).to.eql({
|
||||
status: 'ok',
|
||||
connector_id: bedrockActionId,
|
||||
data: bedrockSuccessResponse,
|
||||
});
|
||||
});
|
||||
|
||||
it('should invoke AI with assistant AI body argument formatted to bedrock expectations', async () => {
|
||||
const { body } = await supertest
|
||||
.post(`/api/actions/connector/${bedrockActionId}/_execute`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
params: {
|
||||
subAction: 'invokeAI',
|
||||
subActionParams: {
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Hello world',
|
||||
},
|
||||
{
|
||||
role: 'system',
|
||||
content: 'Be a good chatbot',
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'Hi, I am a good chatbot',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: 'What is 2+2?',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(simulator.requestData).to.eql({
|
||||
prompt:
|
||||
'\n\nHuman:Hello world\n\nHuman:Be a good chatbot\n\nAssistant:Hi, I am a good chatbot\n\nHuman:What is 2+2? \n\nAssistant:',
|
||||
max_tokens_to_sample: 300,
|
||||
stop_sequences: ['\n\nHuman:'],
|
||||
});
|
||||
expect(body).to.eql({
|
||||
status: 'ok',
|
||||
connector_id: bedrockActionId,
|
||||
data: bedrockSuccessResponse.completion,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('error response simulator', () => {
|
||||
const simulator = new BedrockSimulator({
|
||||
returnError: true,
|
||||
proxy: {
|
||||
config: configService.get('kbnTestServer.serverArgs'),
|
||||
},
|
||||
});
|
||||
|
||||
let bedrockActionId: string;
|
||||
|
||||
before(async () => {
|
||||
const apiUrl = await simulator.start();
|
||||
bedrockActionId = await createConnector(apiUrl);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
simulator.close();
|
||||
});
|
||||
|
||||
it('should return a failure when error happens', async () => {
|
||||
const { body } = await supertest
|
||||
.post(`/api/actions/connector/${bedrockActionId}/_execute`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
params: {},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(body).to.eql({
|
||||
status: 'error',
|
||||
connector_id: bedrockActionId,
|
||||
message:
|
||||
'error validating action params: [subAction]: expected value of type [string] but got [undefined]',
|
||||
retry: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -265,7 +265,7 @@ export default function genAiTest({ getService }: FtrProviderContext) {
|
|||
status: 'error',
|
||||
retry: true,
|
||||
message: 'an error occurred while running the action',
|
||||
service_message: `Sub action "invalidAction" is not registered. Connector id: ${genAiActionId}. Connector name: Generative AI. Connector type: .gen-ai`,
|
||||
service_message: `Sub action "invalidAction" is not registered. Connector id: ${genAiActionId}. Connector name: OpenAI. Connector type: .gen-ai`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -41,6 +41,7 @@ export default function connectorsTests({ loadTestFile, getService }: FtrProvide
|
|||
loadTestFile(require.resolve('./connector_types/torq'));
|
||||
loadTestFile(require.resolve('./connector_types/gen_ai'));
|
||||
loadTestFile(require.resolve('./connector_types/d3security'));
|
||||
loadTestFile(require.resolve('./connector_types/bedrock'));
|
||||
loadTestFile(require.resolve('./create'));
|
||||
loadTestFile(require.resolve('./delete'));
|
||||
loadTestFile(require.resolve('./execute'));
|
||||
|
|
|
@ -49,6 +49,7 @@ export default function createRegisteredConnectorTypeTests({ getService }: FtrPr
|
|||
'.torq',
|
||||
'.opsgenie',
|
||||
'.gen-ai',
|
||||
'.bedrock',
|
||||
].sort()
|
||||
);
|
||||
});
|
||||
|
|
|
@ -50,6 +50,7 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
'ML:saved-objects-sync',
|
||||
'Synthetics:Clean-Up-Package-Policies',
|
||||
'UPTIME:SyntheticsService:Sync-Saved-Monitor-Objects',
|
||||
'actions:.bedrock',
|
||||
'actions:.cases-webhook',
|
||||
'actions:.d3security',
|
||||
'actions:.email',
|
||||
|
|
12
yarn.lock
12
yarn.lock
|
@ -8544,6 +8544,13 @@
|
|||
resolved "https://registry.yarnpkg.com/@types/async/-/async-3.2.15.tgz#26d4768fdda0e466f18d6c9918ca28cc89a4e1fe"
|
||||
integrity sha512-PAmPfzvFA31mRoqZyTVsgJMsvbynR429UTTxhmfsUCrWGh3/fxOrzqBtaTPJsn4UtzTv4Vb0+/O7CARWb69N4g==
|
||||
|
||||
"@types/aws4@^1.5.0":
|
||||
version "1.11.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/aws4/-/aws4-1.11.3.tgz#a7856fe4e30a7b6411335a73d5440e8b91afc662"
|
||||
integrity sha512-Ka2xKf04xZUH0N7wIYpqcNdavgfPQnaJ1T6GieZs1ydo21vao93aCbHyrA6uKXnaTXzvBcMJkgMsBfT9XvypFQ==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/babel__core@*", "@types/babel__core@^7.1.14":
|
||||
version "7.1.20"
|
||||
resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.20.tgz#e168cdd612c92a2d335029ed62ac94c95b362359"
|
||||
|
@ -11435,6 +11442,11 @@ aws-sign2@~0.7.0:
|
|||
resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8"
|
||||
integrity sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=
|
||||
|
||||
aws4@^1.12.0:
|
||||
version "1.12.0"
|
||||
resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.12.0.tgz#ce1c9d143389679e253b314241ea9aa5cec980d3"
|
||||
integrity sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==
|
||||
|
||||
aws4@^1.8.0:
|
||||
version "1.8.0"
|
||||
resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.8.0.tgz#f0e003d9ca9e7f59c7a508945d7b2ef9a04a542f"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue