diff --git a/docs/management/action-types.asciidoc b/docs/management/action-types.asciidoc index 28aa218f3b6c8..67dd4e3fd0019 100644 --- a/docs/management/action-types.asciidoc +++ b/docs/management/action-types.asciidoc @@ -7,6 +7,10 @@ Connectors provide a central place to store connection information for services [cols="2"] |=== +a| <> + +| Send a request to AWS Bedrock. + a| <> | Send a request to D3 Security. @@ -15,10 +19,6 @@ a| <> | Send email from your server. -a| <> - -| Send a request to OpenAI. - a| <> | Create an incident in {ibm-r}. @@ -35,6 +35,10 @@ a| <> | Send a message to a Microsoft Teams channel. +a| <> + +| Send a request to OpenAI. + a| <> | Create or close an alert in {opsgenie}. diff --git a/docs/management/connectors/action-types/bedrock.asciidoc b/docs/management/connectors/action-types/bedrock.asciidoc new file mode 100644 index 0000000000000..afefc5914435f --- /dev/null +++ b/docs/management/connectors/action-types/bedrock.asciidoc @@ -0,0 +1,68 @@ +[[bedrock-action-type]] +== AWS Bedrock connector and action +++++ +AWS Bedrock +++++ +: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 <> 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 <> 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 <> 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. diff --git a/docs/management/connectors/images/bedrock-connector.png b/docs/management/connectors/images/bedrock-connector.png new file mode 100644 index 0000000000000..22a537183171d Binary files /dev/null and b/docs/management/connectors/images/bedrock-connector.png differ diff --git a/docs/management/connectors/images/bedrock-params.png b/docs/management/connectors/images/bedrock-params.png new file mode 100644 index 0000000000000..f6857e6d0ffee Binary files /dev/null and b/docs/management/connectors/images/bedrock-params.png differ diff --git a/docs/management/connectors/images/gen-ai-connector.png b/docs/management/connectors/images/gen-ai-connector.png index 4c737414d4e72..bbb59c25b909c 100644 Binary files a/docs/management/connectors/images/gen-ai-connector.png and b/docs/management/connectors/images/gen-ai-connector.png differ diff --git a/docs/management/connectors/index.asciidoc b/docs/management/connectors/index.asciidoc index a30bc26ca2511..e7ef07ba4f737 100644 --- a/docs/management/connectors/index.asciidoc +++ b/docs/management/connectors/index.asciidoc @@ -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] diff --git a/docs/settings/alert-action-settings.asciidoc b/docs/settings/alert-action-settings.asciidoc index 8b05c3108df00..fff867cc65a2a 100644 --- a/docs/settings/alert-action-settings.asciidoc +++ b/docs/settings/alert-action-settings.asciidoc @@ -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. diff --git a/package.json b/package.json index 1394e14a8373e..6f6f4352380e1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api.test.tsx index 2f46e99d12b07..0f5c06ba782fb 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api.test.tsx @@ -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 = { diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api.tsx index 6d3452b6f7880..b8b1455634a28 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api.tsx @@ -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(path, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - signal, - }); - - const data = response.data; - if (response.status !== 'ok') { + 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, + } + ); + + 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; } diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_title/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_title/index.tsx index ccf04c38f5c93..b3dcd0ae08429 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_title/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_title/index.tsx @@ -110,7 +110,6 @@ export const AssistantTitle: React.FC<{ {}} selectedConnectorId={selectedConnectorId} selectedConversation={selectedConversation} /> diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings/conversation_settings.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings/conversation_settings.tsx index a0c8226b3ea7e..e7f0ea06a7284 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings/conversation_settings.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/conversations/conversation_settings/conversation_settings.tsx @@ -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 = React.memo( ({ - actionTypeRegistry, allSystemPrompts, defaultConnectorId, defaultProvider, @@ -250,10 +247,7 @@ export const ConversationSettings: React.FC = React.m } > {}} onConnectorSelectionChange={handleOnConnectorSelectionChange} selectedConnectorId={selectedConnector?.id} /> diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings.tsx index 23712d8e74402..e8e1994e85ef7 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings.tsx @@ -76,13 +76,8 @@ export const AssistantSettings: React.FC = 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 = React.memo( conversationSettings={conversationSettings} setUpdatedConversationSettings={setUpdatedConversationSettings} allSystemPrompts={systemPromptSettings} - actionTypeRegistry={actionTypeRegistry} selectedConversation={selectedConversation} onSelectedConversationChange={onHandleSelectedConversationChange} http={http} diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/types.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/types.tsx index 94b78b459c934..573581091eebf 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/types.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/types.tsx @@ -45,6 +45,7 @@ export interface ConversationTheme { export interface Conversation { apiConfig: { connectorId?: string; + connectorTypeTitle?: string; defaultSystemPromptId?: string; provider?: OpenAiProviderType; model?: string; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_selector/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_selector/index.tsx index df3f0b54cd14f..da9daff7c080e 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_selector/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_selector/index.tsx @@ -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 = 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(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(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 = React.memo( dropdownDisplay: ( - + {i18n.ADD_NEW_CONNECTOR} @@ -85,16 +95,17 @@ export const ConnectorSelector: React.FC = 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: ( {connector.name} @@ -106,9 +117,9 @@ export const ConnectorSelector: React.FC = React.memo( ), }; - }) ?? [] - ); - }, [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 = 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); + } + }, + [aiConnectors, onConnectorSelectionChange] + ); + + const onSaveConnector = useCallback( + (connector: ActionConnector) => { + onConnectorSelectionChange({ + ...connector, + connectorTypeTitle: getActionTypeTitle(actionTypeRegistry.get(connector.actionTypeId)), + }); + refetchConnectors?.(); + cleanupAndCloseModal(); }, - [connectors, onConnectorSelectionChange, onConnectorModalVisibilityChange] + [actionTypeRegistry, cleanupAndCloseModal, onConnectorSelectionChange, refetchConnectors] ); return ( @@ -146,19 +174,24 @@ export const ConnectorSelector: React.FC = React.memo( disabled={localIsDisabled} hasDividers={true} isLoading={isLoading} + isOpen={modalForceOpen} onChange={onChange} options={allConnectorOptions} valueOfSelected={selectedConnectorId ?? ''} /> - {isConnectorModalVisible && ( + {isConnectorModalVisible && !selectedActionType && ( + setIsConnectorModalVisible(false)} + onSelect={(actionType: ActionType) => setSelectedActionType(actionType)} + /> + )} + {isConnectorModalVisible && selectedActionType && ( { - onConnectorSelectionChange(connector); - refetchConnectors?.(); - cleanupAndCloseModal(); - }} + postSaveEventHandler={onSaveConnector} actionTypeRegistry={actionTypeRegistry} /> )} diff --git a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_selector_inline/action_type_selector_modal.tsx b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_selector_inline/action_type_selector_modal.tsx new file mode 100644 index 0000000000000..c08de0facb3f1 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_selector_inline/action_type_selector_modal.tsx @@ -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 ? ( + + + {i18n.INLINE_CONNECTOR_PLACEHOLDER} + + + + + {actionTypes.map((actionType: ActionType) => { + const fullAction = actionTypeRegistry.get(actionType.id); + return ( + + onSelect(actionType)} + > + + + + ); + })} + + + + ) : null; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_selector_inline/connector_selector_inline.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_selector_inline/connector_selector_inline.test.tsx index 3b1ff0be86181..b6e56d6352373 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_selector_inline/connector_selector_inline.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_selector_inline/connector_selector_inline.test.tsx @@ -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', () => { @@ -83,7 +81,6 @@ describe('ConnectorSelectorInline', () => { @@ -102,7 +99,6 @@ describe('ConnectorSelectorInline', () => { diff --git a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_selector_inline/connector_selector_inline.tsx b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_selector_inline/connector_selector_inline.tsx index d7f4a50f90ae9..c6539520861cc 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_selector_inline/connector_selector_inline.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_selector_inline/connector_selector_inline.tsx @@ -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 = React.memo( - ({ - isDisabled = false, - onConnectorModalVisibilityChange, - selectedConnectorId, - selectedConversation, - }) => { + ({ isDisabled = false, selectedConnectorId, selectedConversation }) => { const [isOpen, setIsOpen] = useState(false); const { actionTypeRegistry, assistantAvailability, http } = useAssistantContext(); const { setApiConfig } = useConversation(); - // Connector Modal State - const [isConnectorModalVisible, setIsConnectorModalVisible] = useState(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 addNewConnectorOption = useMemo(() => { - return { - value: ADD_NEW_CONNECTOR, - inputDisplay: i18n.ADD_NEW_CONNECTOR, - dropdownDisplay: ( - - - - {i18n.ADD_NEW_CONNECTOR} - - - - {/* Right offset to compensate for 'selected' icon of EuiSuperSelect since native footers aren't supported*/} -
- - - ), - }; - }, []); - 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: ( - - {connector.name} - - ), - dropdownDisplay: ( - - {connector.name} - {connectorDetails && ( - -

{connectorDetails}

-
- )} -
- ), - }; - }) ?? [] - ); - }, [connectors]); + const { data: connectorsWithoutActionContext } = useLoadConnectors({ http }); - // 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 = React.memo( }); } }, - [connectors, selectedConversation, onConnectorModalVisibilityChange, setApiConfig] - ); - - const placeholderComponent = useMemo( - () => ( - - {i18n.INLINE_CONNECTOR_PLACEHOLDER} - - ), - [] + [selectedConversation, setApiConfig] ); return ( @@ -229,18 +137,17 @@ export const ConnectorSelectorInline: React.FC = React.memo( {isOpen ? ( - ( + + {displayText} + + )} + isOpen + isDisabled={localIsDisabled} + selectedConnectorId={selectedConnectorId} + setIsOpen={setIsOpen} + onConnectorSelectionChange={onChange} /> ) : ( @@ -258,19 +165,6 @@ export const ConnectorSelectorInline: React.FC = React.memo( )} - {isConnectorModalVisible && ( - { - const config = getGenAiConfig(connector); - onChange(connector.id, config?.apiProvider, config?.defaultModel); - refetchConnectors?.(); - cleanupAndCloseModal(); - }} - actionTypeRegistry={actionTypeRegistry} - /> - )} ); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_setup/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_setup/index.tsx index 9429ad9435ea7..32463004b1217 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_setup/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_setup/index.tsx @@ -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(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 = ({ )} - {isConnectorModalVisible && ( + {isConnectorModalVisible && !selectedActionType && ( + setIsConnectorModalVisible(false)} + onSelect={(actionType: ActionType) => setSelectedActionType(actionType)} + /> + )} + {isConnectorModalVisible && selectedActionType && ( 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(), - }, - }); - }} + postSaveEventHandler={onSaveConnector} actionTypeRegistry={actionTypeRegistry} /> )} diff --git a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/helpers.tsx b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/helpers.tsx index ffd9604ab328f..b5db94fe041d3 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/helpers.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/helpers.tsx @@ -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; +}; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/use_load_connectors/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/use_load_connectors/index.tsx index 9f67285ac3dc9..b0947cf24481c 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/use_load_connectors/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/use_load_connectors/index.tsx @@ -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, diff --git a/x-pack/packages/kbn-elastic-assistant/impl/mock/test_providers/test_providers.tsx b/x-pack/packages/kbn-elastic-assistant/impl/mock/test_providers/test_providers.tsx index 057db39f66ba2..c01e65dd4ed8c 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/mock/test_providers/test_providers.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/mock/test_providers/test_providers.tsx @@ -44,6 +44,12 @@ export const TestProvidersComponent: React.FC = ({ 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({ diff --git a/x-pack/plugins/elastic_assistant/server/__mocks__/action_result_data.ts b/x-pack/plugins/elastic_assistant/server/__mocks__/action_result_data.ts index 280a86d2ac326..17aa4b83ca67b 100644 --- a/x-pack/plugins/elastic_assistant/server/__mocks__/action_result_data.ts +++ b/x-pack/plugins/elastic_assistant/server/__mocks__/action_result_data.ts @@ -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?'; diff --git a/x-pack/plugins/elastic_assistant/server/lib/langchain/execute_custom_llm_chain/index.test.ts b/x-pack/plugins/elastic_assistant/server/lib/langchain/execute_custom_llm_chain/index.test.ts index 67fb3859b9943..d9e301d557190 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/langchain/execute_custom_llm_chain/index.test.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/langchain/execute_custom_llm_chain/index.test.ts @@ -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', }); }); diff --git a/x-pack/plugins/elastic_assistant/server/lib/langchain/execute_custom_llm_chain/index.ts b/x-pack/plugins/elastic_assistant/server/lib/langchain/execute_custom_llm_chain/index.ts index b6a768ad69598..e6df7ce72a24c 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/langchain/execute_custom_llm_chain/index.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/langchain/execute_custom_llm_chain/index.ts @@ -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; + request: KibanaRequest; }): Promise => { const llm = new ActionsClientLlm({ actions, connectorId, request, logger }); diff --git a/x-pack/plugins/elastic_assistant/server/lib/langchain/helpers.test.ts b/x-pack/plugins/elastic_assistant/server/lib/langchain/helpers.test.ts index 1c62fab9df6cc..1156a8e758c13 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/langchain/helpers.test.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/langchain/helpers.test.ts @@ -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([]); - }); - }); }); diff --git a/x-pack/plugins/elastic_assistant/server/lib/langchain/helpers.ts b/x-pack/plugins/elastic_assistant/server/lib/langchain/helpers.ts index 90364dcfe75db..c13977ddb1e7d 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/langchain/helpers.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/langchain/helpers.ts @@ -31,27 +31,3 @@ export const getMessageContentAndRole = (prompt: string): Pick; - connector_id: string; -} - -/** An unsafe, temporary stub that parses assistant messages from the request with no validation */ -export const unsafeGetAssistantMessagesFromRequest = ( - rawSubActionParamsBody: string | undefined -): Array> => { - 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 []; - } -}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/langchain/llm/actions_client_llm.test.ts b/x-pack/plugins/elastic_assistant/server/lib/langchain/llm/actions_client_llm.test.ts index b774d687c7ce1..8769e21770678 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/langchain/llm/actions_client_llm.test.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/langchain/llm/actions_client_llm.test.ts @@ -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 = { +const mockRequest: KibanaRequest = { 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; +} as KibanaRequest; 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,50 +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 }, - }; - - 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[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 }, - }; + const invalidContent = 1234; mockExecute.mockImplementation(() => ({ data: invalidContent, @@ -174,7 +144,7 @@ describe('ActionsClientLlm', () => { }); 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' ); }); }); diff --git a/x-pack/plugins/elastic_assistant/server/lib/langchain/llm/actions_client_llm.ts b/x-pack/plugins/elastic_assistant/server/lib/langchain/llm/actions_client_llm.ts index 78cc3caf54354..8b7715e790e46 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/langchain/llm/actions_client_llm.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/langchain/llm/actions_client_llm.ts @@ -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; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - #actionResultData: Record; + #request: KibanaRequest; + #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; + request: KibanaRequest; }) { 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 { + 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); - - 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; // save the raw response from the connector, because that's what the assistant expects + // TODO: handle errors from the connector + const content = get('data', actionResult); - 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 } } diff --git a/x-pack/plugins/elastic_assistant/server/lib/langchain/types.ts b/x-pack/plugins/elastic_assistant/server/lib/langchain/types.ts new file mode 100644 index 0000000000000..620f2554ecf43 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/langchain/types.ts @@ -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; +} diff --git a/x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.test.ts b/x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.test.ts index 57f2b25f5a65f..df5147bb5bec5 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.test.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.test.ts @@ -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', }, }); diff --git a/x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.ts b/x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.ts index bbb1c76e3e579..0ff90c5fa3ca6 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.ts @@ -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, diff --git a/x-pack/plugins/elastic_assistant/server/schemas/post_actions_connector_execute.ts b/x-pack/plugins/elastic_assistant/server/schemas/post_actions_connector_execute.ts index 0aae23ed7512d..b30ccd94e105b 100644 --- a/x-pack/plugins/elastic_assistant/server/schemas/post_actions_connector_execute.ts +++ b/x-pack/plugins/elastic_assistant/server/schemas/post_actions_connector_execute.ts @@ -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, }), }); diff --git a/x-pack/plugins/stack_connectors/common/bedrock/constants.ts b/x-pack/plugins/stack_connectors/common/bedrock/constants.ts new file mode 100644 index 0000000000000..d21515ead833f --- /dev/null +++ b/x-pack/plugins/stack_connectors/common/bedrock/constants.ts @@ -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; diff --git a/x-pack/plugins/stack_connectors/common/bedrock/schema.ts b/x-pack/plugins/stack_connectors/common/bedrock/schema.ts new file mode 100644 index 0000000000000..ac23ed9667ada --- /dev/null +++ b/x-pack/plugins/stack_connectors/common/bedrock/schema.ts @@ -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' } +); diff --git a/x-pack/plugins/stack_connectors/common/bedrock/types.ts b/x-pack/plugins/stack_connectors/common/bedrock/types.ts new file mode 100644 index 0000000000000..c6fad07cdba37 --- /dev/null +++ b/x-pack/plugins/stack_connectors/common/bedrock/types.ts @@ -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; +export type Secrets = TypeOf; +export type RunActionParams = TypeOf; +export type InvokeAIActionParams = TypeOf; +export type InvokeAIActionResponse = TypeOf; +export type RunActionResponse = TypeOf; diff --git a/x-pack/plugins/stack_connectors/common/gen_ai/constants.ts b/x-pack/plugins/stack_connectors/common/gen_ai/constants.ts index 6e3d2924ca6c7..8c8dd84c4e326 100644 --- a/x-pack/plugins/stack_connectors/common/gen_ai/constants.ts +++ b/x-pack/plugins/stack_connectors/common/gen_ai/constants.ts @@ -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', diff --git a/x-pack/plugins/stack_connectors/common/gen_ai/schema.ts b/x-pack/plugins/stack_connectors/common/gen_ai/schema.ts index f82b89ee7c2b6..fa14aa61fa5b3 100644 --- a/x-pack/plugins/stack_connectors/common/gen_ai/schema.ts +++ b/x-pack/plugins/stack_connectors/common/gen_ai/schema.ts @@ -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(), }); diff --git a/x-pack/plugins/stack_connectors/common/gen_ai/types.ts b/x-pack/plugins/stack_connectors/common/gen_ai/types.ts index 688a577a90f48..86e2c172846dc 100644 --- a/x-pack/plugins/stack_connectors/common/gen_ai/types.ts +++ b/x-pack/plugins/stack_connectors/common/gen_ai/types.ts @@ -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; -export type GenAiSecrets = TypeOf; -export type GenAiRunActionParams = TypeOf; -export type GenAiRunActionResponse = TypeOf; -export type GenAiDashboardActionParams = TypeOf; -export type GenAiDashboardActionResponse = TypeOf; -export type GenAiStreamActionParams = TypeOf; +export type Config = TypeOf; +export type Secrets = TypeOf; +export type RunActionParams = TypeOf; +export type InvokeAIActionParams = TypeOf; +export type InvokeAIActionResponse = TypeOf; +export type RunActionResponse = TypeOf; +export type DashboardActionParams = TypeOf; +export type DashboardActionResponse = TypeOf; +export type StreamActionParams = TypeOf; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/bedrock/bedrock.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/bedrock/bedrock.test.tsx new file mode 100644 index 0000000000000..d823633f84585 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/bedrock/bedrock.test.tsx @@ -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(); + 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: [], + }, + }); + }); +}); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/bedrock/bedrock.tsx b/x-pack/plugins/stack_connectors/public/connector_types/bedrock/bedrock.tsx new file mode 100644 index 0000000000000..1bc6febe04b09 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/bedrock/bedrock.tsx @@ -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> => { + 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')), + }; +} diff --git a/x-pack/plugins/stack_connectors/public/connector_types/bedrock/connector.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/bedrock/connector.test.tsx new file mode 100644 index 0000000000000..063d0e39d7ef9 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/bedrock/connector.test.tsx @@ -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; +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( + + {}} + /> + + ); + + 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( + + {}} + /> + + ); + + 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( + + {}} + /> + + ); + + 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( + + {}} + /> + + ); + + 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 }); + }); + }); +}); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/bedrock/connector.tsx b/x-pack/plugins/stack_connectors/public/connector_types/bedrock/connector.tsx new file mode 100644 index 0000000000000..c99574aaf5c43 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/bedrock/connector.tsx @@ -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 = ({ readOnly, isEdit }) => { + return ( + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { BedrockConnectorFields as default }; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/bedrock/constants.tsx b/x-pack/plugins/stack_connectors/public/connector_types/bedrock/constants.tsx new file mode 100644 index 0000000000000..18c635c41369f --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/bedrock/constants.tsx @@ -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: ( + + {`${i18n.BEDROCK} ${i18n.DOCUMENTATION}`} + + ), + }} + /> + ), + }, + { + id: 'defaultModel', + label: i18n.DEFAULT_MODEL_LABEL, + helpText: ( + + {`${i18n.BEDROCK} ${i18n.DOCUMENTATION}`} + + ), + }} + /> + ), + defaultValue: DEFAULT_BEDROCK_MODEL, + }, +]; + +export const bedrockSecrets: SecretsFieldSchema[] = [ + { + id: 'accessKey', + label: i18n.ACCESS_KEY_LABEL, + isPasswordField: true, + helpText: ( + + {`${i18n.BEDROCK} ${i18n.DOCUMENTATION}`} + + ), + }} + /> + ), + }, + { + id: 'secret', + label: i18n.SECRET, + isPasswordField: true, + helpText: ( + + {`${i18n.BEDROCK} ${i18n.DOCUMENTATION}`} + + ), + }} + /> + ), + }, +]; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/bedrock/index.ts b/x-pack/plugins/stack_connectors/public/connector_types/bedrock/index.ts new file mode 100644 index 0000000000000..95c03f880277d --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/bedrock/index.ts @@ -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'; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/bedrock/logo.tsx b/x-pack/plugins/stack_connectors/public/connector_types/bedrock/logo.tsx new file mode 100644 index 0000000000000..68b385b4b246e --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/bedrock/logo.tsx @@ -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) => ( + + + +); + +// eslint-disable-next-line import/no-default-export +export { Logo as default }; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/bedrock/params.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/bedrock/params.test.tsx new file mode 100644 index 0000000000000..893c513b6f395 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/bedrock/params.test.tsx @@ -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 ; + }, + }; +}); +const messageVariables = [ + { + name: 'myVar', + description: 'My variable description', + useWithTripleBracesInTemplates: true, + }, +]; + +describe('Bedrock Params Fields renders', () => { + test('all params fields are rendered', () => { + const { getByTestId } = render( + {}} + index={0} + messageVariables={messageVariables} + />, + { + wrapper: ({ children }) => {children}, + } + ); + 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( + , + { + wrapper: ({ children }) => {children}, + } + ); + 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( + , + { + wrapper: ({ children }) => {children}, + } + ); + 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( + , + { + wrapper: ({ children }) => {children}, + } + ); + 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( + , + { + wrapper: ({ children }) => {children}, + } + ); + 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 + ); + }); +}); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/bedrock/params.tsx b/x-pack/plugins/stack_connectors/public/connector_types/bedrock/params.tsx new file mode 100644 index 0000000000000..b880e388b9f91 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/bedrock/params.tsx @@ -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> = ({ + 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) => { + editAction('subActionParams', { ...subActionParams, ...params }, index); + }, + [editAction, index, subActionParams] + ); + + return ( + <> + { + editSubActionParams({ body: json }); + }} + onBlur={() => { + if (!body) { + editSubActionParams({ body: '' }); + } + }} + data-test-subj="bedrock-bodyJsonEditor" + /> + + {`${i18n.BEDROCK} ${i18n.DOCUMENTATION}`} + + ), + }} + /> + } + > + { + editSubActionParams({ model: ev.target.value }); + }} + fullWidth + /> + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { BedrockParamsFields as default }; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/bedrock/translations.ts b/x-pack/plugins/stack_connectors/public/connector_types/bedrock/translations.ts new file mode 100644 index 0000000000000..37c8165b76048 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/bedrock/translations.ts @@ -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', +}); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/bedrock/types.ts b/x-pack/plugins/stack_connectors/public/connector_types/bedrock/types.ts new file mode 100644 index 0000000000000..db766ea562a51 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/bedrock/types.ts @@ -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; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/gen_ai.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/gen_ai.test.tsx index efa0d5ce82fe7..3bed686dca025 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/gen_ai.test.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/gen_ai.test.tsx @@ -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'); }); }); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/gen_ai.tsx b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/gen_ai.tsx index b326d59cc9c64..7a4c798763c46 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/gen_ai.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/gen_ai.tsx @@ -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> => { diff --git a/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/types.ts b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/types.ts index d86508c750bce..ddb0f8e871b19 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/types.ts +++ b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/types.ts @@ -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 { diff --git a/x-pack/plugins/stack_connectors/public/connector_types/index.ts b/x-pack/plugins/stack_connectors/public/connector_types/index.ts index abc018631db7a..20964012607f8 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/index.ts +++ b/x-pack/plugins/stack_connectors/public/connector_types/index.ts @@ -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()); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/bedrock/bedrock.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/bedrock/bedrock.test.ts new file mode 100644 index 0000000000000..919c4303c4f66 --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/bedrock/bedrock.test.ts @@ -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'); + }); + }); + }); +}); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/bedrock/bedrock.ts b/x-pack/plugins/stack_connectors/server/connector_types/bedrock/bedrock.ts new file mode 100644 index 0000000000000..5012970e4e91c --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/bedrock/bedrock.ts @@ -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; + body: string; + path: string; +} + +export class BedrockConnector extends SubActionConnector { + private url; + private model; + + constructor(params: ServiceParams) { + 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 { + // 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 { + 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(); + } +} diff --git a/x-pack/plugins/stack_connectors/server/connector_types/bedrock/index.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/bedrock/index.test.ts new file mode 100644 index 0000000000000..78a1937bf76c2 --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/bedrock/index.test.ts @@ -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; +let configurationUtilities: jest.Mocked; + +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"` + ); + }); + }); +}); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/bedrock/index.ts b/x-pack/plugins/stack_connectors/server/connector_types/bedrock/index.ts new file mode 100644 index 0000000000000..661771f6df124 --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/bedrock/index.ts @@ -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 => ({ + 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, + }, + }) + ); + } +}; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/bedrock/render.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/bedrock/render.test.ts new file mode 100644 index 0000000000000..bdbe792115562 --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/bedrock/render.test.ts @@ -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' + ); + }); +}); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/bedrock/render.ts b/x-pack/plugins/stack_connectors/server/connector_types/bedrock/render.ts new file mode 100644 index 0000000000000..34dd90ff2a862 --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/bedrock/render.ts @@ -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 = ( + 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'), + }, + }; +}; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/create_dashboard.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/create_dashboard.test.ts index b13fa276beacb..490f7f7cfc23f 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/create_dashboard.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/create_dashboard.test.ts @@ -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); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/create_dashboard.ts b/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/create_dashboard.ts index d1d09a2e50afd..80c3bc8cc7f21 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/create_dashboard.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/create_dashboard.ts @@ -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( 'dashboard', - getGenAiDashboard(dashboardId).attributes, + getDashboard(dashboardId).attributes, { overwrite: true, id: dashboardId, diff --git a/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/dashboard.ts b/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/dashboard.ts index 46a4fa5145de5..6470f088577b7 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/dashboard.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/dashboard.ts @@ -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 => { +export const getDashboard = (dashboardId: string): SavedObject => { const ids: Record = { genAiSavedObjectId: dashboardId, tokens: uuidv4(), diff --git a/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/gen_ai.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/gen_ai.test.ts index 31f88dd0edb0b..650ff4dd5cdc3 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/gen_ai.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/gen_ai.test.ts @@ -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; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/gen_ai.ts b/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/gen_ai.ts index c88d129db7071..4a0efbe11d472 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/gen_ai.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/gen_ai.ts @@ -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 { +export class GenAiConnector extends SubActionConnector { private url; private provider; private key; - constructor(params: ServiceParams) { + constructor(params: ServiceParams) { super(params); this.url = this.config.apiUrl; @@ -53,25 +56,31 @@ export class GenAiConnector extends SubActionConnector { + /** + * 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 { const sanitizedBody = sanitizeRequest( this.provider, this.url, @@ -98,17 +110,22 @@ export class GenAiConnector extends SubActionConnector { + /** + * 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 { const executeBody = getRequestWithStreamOption( this.provider, this.url, @@ -121,16 +138,21 @@ export class GenAiConnector extends SubActionConnector { + }: DashboardActionParams): Promise { const privilege = (await this.esClient.transport.request({ path: '/_security/user/_has_privileges', method: 'POST', @@ -149,7 +171,7 @@ export class GenAiConnector extends SubActionConnector { + 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.'; + } } diff --git a/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/index.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/index.test.ts index 75611b610dbee..5ed6aaf070b88 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/index.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/index.test.ts @@ -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; +let connectorType: SubActionConnectorType; let configurationUtilities: jest.Mocked; 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, diff --git a/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/index.ts b/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/index.ts index f845215ddba48..cc43a05fd346d 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/index.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/index.ts @@ -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 => ({ +export const getConnectorType = (): SubActionConnectorType => ({ 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 { +export const configValidator = (configObject: Config, validatorServices: ValidatorServices) => { try { assertURL(configObject.apiUrl); urlAllowListValidator('apiUrl')(configObject, validatorServices); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/index.ts b/x-pack/plugins/stack_connectors/server/connector_types/index.ts index a6c5d6ec2f905..743f2664ed0b5 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/index.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/index.ts @@ -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()); } diff --git a/x-pack/plugins/stack_connectors/server/plugin.test.ts b/x-pack/plugins/stack_connectors/server/plugin.test.ts index 87d9ca97edb0a..c728ee92cea40 100644 --- a/x-pack/plugins/stack_connectors/server/plugin.test.ts +++ b/x-pack/plugins/stack_connectors/server/plugin.test.ts @@ -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', diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index 823ef92ea32ca..7fdd7668135a4 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -33,6 +33,7 @@ interface CreateTestConfigOptions { // test.not-enabled is specifically not enabled const enabledActionTypes = [ + '.bedrock', '.cases-webhook', '.email', '.index', diff --git a/x-pack/test/alerting_api_integration/common/plugins/actions_simulators/server/bedrock_simulation.ts b/x-pack/test/alerting_api_integration/common/plugins/actions_simulators/server/bedrock_simulation.ts new file mode 100644 index 0000000000000..bfa8c5cb0736f --- /dev/null +++ b/x-pack/test/alerting_api_integration/common/plugins/actions_simulators/server/bedrock_simulation.ts @@ -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 + ) { + 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.', +}; diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/bedrock.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/bedrock.ts new file mode 100644 index 0000000000000..18260ac4244d8 --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/bedrock.ts @@ -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, + }); + }); + }); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/gen_ai.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/gen_ai.ts index b52d9c14cc3a7..5cfe992171dac 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/gen_ai.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/gen_ai.ts @@ -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`, }); }); }); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts index 6b57d75b26835..fd667f8f1025a 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts @@ -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')); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/check_registered_connector_types.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/check_registered_connector_types.ts index 257d774c160dd..13fcc069a4df3 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/check_registered_connector_types.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/check_registered_connector_types.ts @@ -49,6 +49,7 @@ export default function createRegisteredConnectorTypeTests({ getService }: FtrPr '.torq', '.opsgenie', '.gen-ai', + '.bedrock', ].sort() ); }); diff --git a/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts b/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts index a5f039eaed5f2..5f4657341da45 100644 --- a/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts +++ b/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts @@ -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', diff --git a/yarn.lock b/yarn.lock index 9dc5ced6ac193..861e3d293a17e 100644 --- a/yarn.lock +++ b/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"