diff --git a/docs/management/action-types.asciidoc b/docs/management/action-types.asciidoc index 4e9adbd8c8e7..40963e765b5c 100644 --- a/docs/management/action-types.asciidoc +++ b/docs/management/action-types.asciidoc @@ -78,6 +78,11 @@ a| <> a| <> | Trigger a Torq workflow. + +a| <> + +| Send a request to OpenAI. + |=== [NOTE] diff --git a/docs/management/connectors/action-types/gen-ai.asciidoc b/docs/management/connectors/action-types/gen-ai.asciidoc new file mode 100644 index 000000000000..dda7ebc3e190 --- /dev/null +++ b/docs/management/connectors/action-types/gen-ai.asciidoc @@ -0,0 +1,89 @@ +[[gen-ai-action-type]] +== Generative AI connector and action +++++ +Generative AI +++++ + +The Generative AI connector uses https://github.com/axios/axios[axios] to send a POST request to an OpenAI provider, either OpenAI or Azure OpenAI. The connector uses the <> to send the request. + +[float] +[[define-gen-ai-ui]] +=== Create connectors in {kib} + +You can create connectors in *{stack-manage-app} > {connectors-ui}*. For example: + +[role="screenshot"] +image::management/connectors/images/gen-ai-connector.png[Generative AI connector] + +[float] +[[gen-ai-connector-configuration]] +==== Connector configuration + +Generative AI connectors have the following configuration properties: + +Name:: The name of the connector. +API Provider:: The OpenAI API provider, either OpenAI or Azure OpenAI. +API URL:: The OpenAI request URL. +API Key:: The OpenAI or Azure OpenAI API key for authentication. + +[float] +[[preconfigured-gen-ai-configuration]] +=== Create preconfigured connectors + +If you are running {kib} on-prem, you can define connectors by +adding `xpack.actions.preconfigured` settings to your `kibana.yml` file. +For example: + +[source,text] +-- +xpack.actions.preconfigured: + my-gen-ai: + name: preconfigured-gen-ai-connector-type + actionTypeId: .gen-ai + config: + apiUrl: https://api.openai.com/v1/chat/completions + apiProvider: 'Azure OpenAI' + secrets: + apiKey: superlongapikey +-- + +Config defines information for the connector type. + +`apiProvider`:: A string that corresponds to *OpenAI API Provider*. +`apiUrl`:: A URL string that corresponds to the *OpenAI API URL*. + +Secrets defines sensitive information for the connector type. + +`apiKey`:: A string that corresponds to *OpenAI API Key*. + +[float] +[[gen-ai-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"] +image::management/connectors/images/gen-ai-params-test.png[Generative AI params test] + +The Generative AI actions have the following configuration properties. + +Body:: A JSON payload sent to the OpenAI API URL. For example: ++ +[source,text] +-- +{ + "model": "gpt-3.5-turbo", + "messages": [ + { + "role": "user", + "content": "Hello world" + } + ] +} +-- +[float] +[[gen-ai-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. \ No newline at end of file diff --git a/docs/management/connectors/images/gen-ai-connector.png b/docs/management/connectors/images/gen-ai-connector.png new file mode 100644 index 000000000000..7306f6b28383 Binary files /dev/null and b/docs/management/connectors/images/gen-ai-connector.png differ diff --git a/docs/management/connectors/images/gen-ai-params-test.png b/docs/management/connectors/images/gen-ai-params-test.png new file mode 100644 index 000000000000..2a48c4f369ef Binary files /dev/null and b/docs/management/connectors/images/gen-ai-params-test.png differ diff --git a/docs/management/connectors/index.asciidoc b/docs/management/connectors/index.asciidoc index 86adf5d49581..25a6ec1d042c 100644 --- a/docs/management/connectors/index.asciidoc +++ b/docs/management/connectors/index.asciidoc @@ -16,4 +16,5 @@ include::action-types/torq.asciidoc[leveloffset=+1] include::action-types/webhook.asciidoc[leveloffset=+1] include::action-types/cases-webhook.asciidoc[leveloffset=+1] include::action-types/xmatters.asciidoc[leveloffset=+1] +include::action-types/gen-ai.asciidoc[leveloffset=+1] include::pre-configured-connectors.asciidoc[leveloffset=+1] diff --git a/docs/settings/alert-action-settings.asciidoc b/docs/settings/alert-action-settings.asciidoc index 7cebc648ca13..c749c074483c 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`, 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`, 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/x-pack/plugins/actions/common/connector_feature_config.test.ts b/x-pack/plugins/actions/common/connector_feature_config.test.ts index 5aea0a7c72bd..cb571dfa8714 100644 --- a/x-pack/plugins/actions/common/connector_feature_config.test.ts +++ b/x-pack/plugins/actions/common/connector_feature_config.test.ts @@ -13,7 +13,7 @@ import { describe('areValidFeatures', () => { it('returns true when all inputs are valid features', () => { - expect(areValidFeatures(['alerting', 'cases'])).toBeTruthy(); + expect(areValidFeatures(['alerting', 'cases', 'general'])).toBeTruthy(); }); it('returns true when only one input and it is a valid feature', () => { @@ -42,9 +42,10 @@ describe('getConnectorFeatureName', () => { describe('getConnectorCompatibility', () => { it('returns the compatibility list for valid feature ids', () => { - expect(getConnectorCompatibility(['alerting', 'cases', 'uptime', 'siem'])).toEqual([ + expect(getConnectorCompatibility(['alerting', 'cases', 'uptime', 'siem', 'general'])).toEqual([ 'Alerting Rules', 'Cases', + 'General', ]); }); diff --git a/x-pack/plugins/actions/common/connector_feature_config.ts b/x-pack/plugins/actions/common/connector_feature_config.ts index 6e9adab5de5a..27c035546882 100644 --- a/x-pack/plugins/actions/common/connector_feature_config.ts +++ b/x-pack/plugins/actions/common/connector_feature_config.ts @@ -25,6 +25,14 @@ export const AlertingConnectorFeatureId = 'alerting'; export const CasesConnectorFeatureId = 'cases'; export const UptimeConnectorFeatureId = 'uptime'; export const SecurityConnectorFeatureId = 'siem'; +export const GeneralConnectorFeatureId = 'general'; + +const compatibilityGeneral = i18n.translate( + 'xpack.actions.availableConnectorFeatures.compatibility.general', + { + defaultMessage: 'General', + } +); const compatibilityAlertingRules = i18n.translate( 'xpack.actions.availableConnectorFeatures.compatibility.alertingRules', @@ -72,11 +80,18 @@ export const SecuritySolutionFeature: ConnectorFeatureConfig = { compatibility: compatibilityAlertingRules, }; +export const GeneralFeature: ConnectorFeatureConfig = { + id: GeneralConnectorFeatureId, + name: compatibilityGeneral, + compatibility: compatibilityGeneral, +}; + const AllAvailableConnectorFeatures = { [AlertingConnectorFeature.id]: AlertingConnectorFeature, [CasesConnectorFeature.id]: CasesConnectorFeature, [UptimeConnectorFeature.id]: UptimeConnectorFeature, [SecuritySolutionFeature.id]: SecuritySolutionFeature, + [GeneralFeature.id]: GeneralFeature, }; export function areValidFeatures(ids: string[]) { diff --git a/x-pack/plugins/actions/common/types.ts b/x-pack/plugins/actions/common/types.ts index fe52e1db5b28..275a757597d2 100644 --- a/x-pack/plugins/actions/common/types.ts +++ b/x-pack/plugins/actions/common/types.ts @@ -12,6 +12,7 @@ export { CasesConnectorFeatureId, UptimeConnectorFeatureId, SecurityConnectorFeatureId, + GeneralConnectorFeatureId, } from './connector_feature_config'; export interface ActionType { id: string; diff --git a/x-pack/plugins/actions/server/sub_action_framework/helpers/validators.test.ts b/x-pack/plugins/actions/server/sub_action_framework/helpers/validators.test.ts new file mode 100644 index 000000000000..4c9d2c1ddf50 --- /dev/null +++ b/x-pack/plugins/actions/server/sub_action_framework/helpers/validators.test.ts @@ -0,0 +1,32 @@ +/* + * 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 { assertURL } from './validators'; + +describe('Validators', () => { + describe('assertURL function', () => { + it('valid URL with a valid protocol and hostname does not throw an error', () => { + expect(() => assertURL('https://www.example.com')).not.toThrow(); + }); + + it('invalid URL throws an error with a relevant message', () => { + expect(() => assertURL('invalidurl')).toThrowError('Invalid URL'); + }); + + it('URL with an invalid protocol throws an error with a relevant message', () => { + expect(() => assertURL('ftp://www.example.com')).toThrowError('Invalid protocol'); + }); + + it('function handles case sensitivity of protocols correctly', () => { + expect(() => assertURL('hTtPs://www.example.com')).not.toThrow(); + }); + + it('function handles URLs with query parameters and fragment identifiers correctly', () => { + expect(() => assertURL('https://www.example.com/path?query=value#fragment')).not.toThrow(); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/sub_action_framework/helpers/validators.ts b/x-pack/plugins/actions/server/sub_action_framework/helpers/validators.ts index 7618fef0f3ea..6ca20386d764 100644 --- a/x-pack/plugins/actions/server/sub_action_framework/helpers/validators.ts +++ b/x-pack/plugins/actions/server/sub_action_framework/helpers/validators.ts @@ -9,6 +9,23 @@ import { i18n } from '@kbn/i18n'; import { get } from 'lodash'; import { ValidatorServices } from '../../types'; +const validProtocols: string[] = ['http:', 'https:']; +export const assertURL = (url: string) => { + try { + const parsedUrl = new URL(url); + + if (!parsedUrl.hostname) { + throw new Error(`URL must contain hostname`); + } + + if (!validProtocols.includes(parsedUrl.protocol)) { + throw new Error(`Invalid protocol`); + } + } catch (error) { + throw new Error(`URL Error: ${error.message}`); + } +}; + export const urlAllowListValidator = (urlKey: string) => { return (obj: T, validatorServices: ValidatorServices) => { const { configurationUtilities } = validatorServices; diff --git a/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.ts b/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.ts index 5b8a9fdcbf1c..043eb3585c3a 100644 --- a/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.ts +++ b/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.ts @@ -9,6 +9,7 @@ import { isPlainObject, isEmpty } from 'lodash'; import { Type } from '@kbn/config-schema'; import { Logger } from '@kbn/logging'; import axios, { AxiosInstance, AxiosResponse, AxiosError, AxiosRequestHeaders } from 'axios'; +import { assertURL } from './helpers/validators'; import { ActionsConfigurationUtilities } from '../actions_config'; import { SubAction, SubActionRequestParams } from './types'; import { ServiceParams } from './types'; @@ -24,7 +25,6 @@ const isAxiosError = (error: unknown): error is AxiosError => (error as AxiosErr export abstract class SubActionConnector { [k: string]: ((params: unknown) => unknown) | unknown; private axiosInstance: AxiosInstance; - private validProtocols: string[] = ['http:', 'https:']; private subActions: Map = new Map(); private configurationUtilities: ActionsConfigurationUtilities; protected logger: Logger; @@ -56,19 +56,7 @@ export abstract class SubActionConnector { } private assertURL(url: string) { - try { - const parsedUrl = new URL(url); - - if (!parsedUrl.hostname) { - throw new Error('URL must contain hostname'); - } - - if (!this.validProtocols.includes(parsedUrl.protocol)) { - throw new Error('Invalid protocol'); - } - } catch (error) { - throw new Error(`URL Error: ${error.message}`); - } + assertURL(url); } private ensureUriAllowed(url: string) { diff --git a/x-pack/plugins/stack_connectors/common/gen_ai/constants.ts b/x-pack/plugins/stack_connectors/common/gen_ai/constants.ts new file mode 100644 index 000000000000..8ed871ef4575 --- /dev/null +++ b/x-pack/plugins/stack_connectors/common/gen_ai/constants.ts @@ -0,0 +1,24 @@ +/* + * 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 GEN_AI_TITLE = i18n.translate( + 'xpack.stackConnectors.components.genAi.connectorTypeTitle', + { + defaultMessage: 'Generative AI', + } +); +export const GEN_AI_CONNECTOR_ID = '.gen-ai'; +export enum SUB_ACTION { + RUN = 'run', + TEST = 'test', +} +export enum OpenAiProviderType { + OpenAi = 'OpenAI', + AzureAi = 'Azure OpenAI', +} diff --git a/x-pack/plugins/stack_connectors/common/gen_ai/schema.ts b/x-pack/plugins/stack_connectors/common/gen_ai/schema.ts new file mode 100644 index 000000000000..f3b1f510231b --- /dev/null +++ b/x-pack/plugins/stack_connectors/common/gen_ai/schema.ts @@ -0,0 +1,22 @@ +/* + * 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'; + +// Connector schema +export const GenAiConfigSchema = schema.object({ + apiProvider: schema.string(), + apiUrl: schema.string(), +}); + +export const GenAiSecretsSchema = schema.object({ apiKey: schema.string() }); + +// Run action schema +export const GenAiRunActionParamsSchema = schema.object({ + body: schema.string(), +}); +export const GenAiRunActionResponseSchema = schema.object({}, { unknowns: 'ignore' }); diff --git a/x-pack/plugins/stack_connectors/common/gen_ai/types.ts b/x-pack/plugins/stack_connectors/common/gen_ai/types.ts new file mode 100644 index 000000000000..9f27aafa0b3a --- /dev/null +++ b/x-pack/plugins/stack_connectors/common/gen_ai/types.ts @@ -0,0 +1,19 @@ +/* + * 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 { + GenAiConfigSchema, + GenAiSecretsSchema, + GenAiRunActionParamsSchema, + GenAiRunActionResponseSchema, +} from './schema'; + +export type GenAiConfig = TypeOf; +export type GenAiSecrets = TypeOf; +export type GenAiRunActionParams = TypeOf; +export type GenAiRunActionResponse = TypeOf; diff --git a/x-pack/plugins/stack_connectors/kibana.jsonc b/x-pack/plugins/stack_connectors/kibana.jsonc index 7e6d894e4fd4..dc4023890d65 100644 --- a/x-pack/plugins/stack_connectors/kibana.jsonc +++ b/x-pack/plugins/stack_connectors/kibana.jsonc @@ -14,6 +14,9 @@ "actions", "esUiShared", "triggersActionsUi" + ], + "extraPublicDirs": [ + "public/common" ] } } diff --git a/x-pack/plugins/stack_connectors/public/common/index.ts b/x-pack/plugins/stack_connectors/public/common/index.ts new file mode 100644 index 000000000000..aad68b20ad5c --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/common/index.ts @@ -0,0 +1,11 @@ +/* + * 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 GenAiLogo from '../connector_types/gen_ai/logo'; + +export { GEN_AI_CONNECTOR_ID } from '../../common/gen_ai/constants'; +export { GenAiLogo }; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/connector.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/connector.test.tsx new file mode 100644 index 000000000000..5376daa5027b --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/connector.test.tsx @@ -0,0 +1,195 @@ +/* + * 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 GenerativeAiConnectorFields from './connector'; +import { ConnectorFormTestProvider } from '../lib/test_utils'; +import { act, render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { OpenAiProviderType } from '../../../common/gen_ai/constants'; + +describe('GenerativeAiConnectorFields renders', () => { + test('open ai connector fields are rendered', async () => { + const actionConnector = { + actionTypeId: '.gen-ai', + name: 'genAi', + config: { + apiUrl: 'https://openaiurl.com', + apiProvider: OpenAiProviderType.OpenAi, + }, + secrets: { + apiKey: 'thats-a-nice-looking-key', + }, + isDeprecated: false, + }; + + const { getAllByTestId } = render( + + {}} + /> + + ); + expect(getAllByTestId('config.apiUrl-input')[0]).toBeInTheDocument(); + expect(getAllByTestId('config.apiUrl-input')[0]).toHaveValue(actionConnector.config.apiUrl); + expect(getAllByTestId('config.apiProvider-select')[0]).toBeInTheDocument(); + expect(getAllByTestId('config.apiProvider-select')[0]).toHaveValue( + actionConnector.config.apiProvider + ); + expect(getAllByTestId('open-ai-api-doc')[0]).toBeInTheDocument(); + expect(getAllByTestId('open-ai-api-keys-doc')[0]).toBeInTheDocument(); + }); + + test('azure ai connector fields are rendered', async () => { + const actionConnector = { + actionTypeId: '.gen-ai', + name: 'genAi', + config: { + apiUrl: 'https://azureaiurl.com', + apiProvider: OpenAiProviderType.AzureAi, + }, + secrets: { + apiKey: 'thats-a-nice-looking-key', + }, + isDeprecated: false, + }; + + const { getAllByTestId } = render( + + {}} + /> + + ); + + expect(getAllByTestId('config.apiUrl-input')[0]).toBeInTheDocument(); + expect(getAllByTestId('config.apiUrl-input')[0]).toHaveValue(actionConnector.config.apiUrl); + expect(getAllByTestId('config.apiProvider-select')[0]).toBeInTheDocument(); + expect(getAllByTestId('config.apiProvider-select')[0]).toHaveValue( + actionConnector.config.apiProvider + ); + expect(getAllByTestId('azure-ai-api-doc')[0]).toBeInTheDocument(); + expect(getAllByTestId('azure-ai-api-keys-doc')[0]).toBeInTheDocument(); + }); + + describe('Validation', () => { + const onSubmit = jest.fn(); + const actionConnector = { + actionTypeId: '.gen-ai', + name: 'genAi', + config: { + apiUrl: 'https://openaiurl.com', + apiProvider: OpenAiProviderType.OpenAi, + }, + secrets: { + apiKey: 'thats-a-nice-looking-key', + }, + isDeprecated: false, + }; + + 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: actionConnector, + isValid: true, + }); + }); + + it('validates correctly if the apiUrl is empty', async () => { + const connector = { + ...actionConnector, + config: { + ...actionConnector.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.apiKey-input', ''], + ]; + it.each(tests)('validates correctly %p', async (field, value) => { + const connector = { + ...actionConnector, + config: { + ...actionConnector.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/gen_ai/connector.tsx b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/connector.tsx new file mode 100644 index 000000000000..f75edba2d57b --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/connector.tsx @@ -0,0 +1,207 @@ +/* + * 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, { useMemo } from 'react'; +import { + ActionConnectorFieldsProps, + ConfigFieldSchema, + SecretsFieldSchema, + SimpleConnectorForm, +} from '@kbn/triggers-actions-ui-plugin/public'; +import { SelectField } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import { EuiLink, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { + UseField, + useFormContext, + useFormData, +} from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers'; +import { OpenAiProviderType } from '../../../common/gen_ai/constants'; +import * as i18n from './translations'; +import { DEFAULT_URL, DEFAULT_URL_AZURE } from './constants'; +const { emptyField } = fieldValidators; + +const openAiConfig: ConfigFieldSchema[] = [ + { + id: 'apiUrl', + label: i18n.API_URL_LABEL, + isUrlField: true, + defaultValue: DEFAULT_URL, + helpText: ( + + {`${i18n.OPEN_AI} ${i18n.DOCUMENTATION}`} + + ), + }} + /> + ), + }, +]; + +const azureAiConfig: ConfigFieldSchema[] = [ + { + id: 'apiUrl', + label: i18n.API_URL_LABEL, + isUrlField: true, + defaultValue: DEFAULT_URL_AZURE, + helpText: ( + + {`${i18n.AZURE_AI} ${i18n.DOCUMENTATION}`} + + ), + }} + /> + ), + }, +]; + +const openAiSecrets: SecretsFieldSchema[] = [ + { + id: 'apiKey', + label: i18n.API_KEY_LABEL, + isPasswordField: true, + helpText: ( + + {`${i18n.OPEN_AI} ${i18n.DOCUMENTATION}`} + + ), + }} + /> + ), + }, +]; + +const azureAiSecrets: SecretsFieldSchema[] = [ + { + id: 'apiKey', + label: i18n.API_KEY_LABEL, + isPasswordField: true, + helpText: ( + + {`${i18n.AZURE_AI} ${i18n.DOCUMENTATION}`} + + ), + }} + /> + ), + }, +]; + +const providerOptions = [ + { + value: OpenAiProviderType.OpenAi, + text: i18n.OPEN_AI, + label: i18n.OPEN_AI, + }, + { + value: OpenAiProviderType.AzureAi, + text: i18n.AZURE_AI, + label: i18n.AZURE_AI, + }, +]; + +const GenerativeAiConnectorFields: React.FC = ({ + readOnly, + isEdit, +}) => { + const { getFieldDefaultValue } = useFormContext(); + const [{ config }] = useFormData({ + watch: ['config.apiProvider'], + }); + + const selectedProviderDefaultValue = useMemo( + () => + getFieldDefaultValue('config.apiProvider') ?? OpenAiProviderType.OpenAi, + [getFieldDefaultValue] + ); + + return ( + <> + + + {config != null && config.apiProvider === OpenAiProviderType.OpenAi && ( + + )} + {/* ^v These are intentionally not if/else because of the way the `config.defaultValue` renders */} + {config != null && config.apiProvider === OpenAiProviderType.AzureAi && ( + + )} + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { GenerativeAiConnectorFields as default }; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/constants.ts b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/constants.ts new file mode 100644 index 000000000000..66210eaf1a75 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/constants.ts @@ -0,0 +1,24 @@ +/* + * 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 const DEFAULT_URL = 'https://api.openai.com/v1/chat/completions' as const; +export const DEFAULT_URL_AZURE = + 'https://{your-resource-name}.openai.azure.com/openai/deployments/{deployment-id}/completions?api-version={api-version}' as const; + +export const DEFAULT_BODY = `{ + "model":"gpt-3.5-turbo", + "messages": [{ + "role":"user", + "content":"Hello world" + }] +}`; +export const DEFAULT_BODY_AZURE = `{ + "messages": [{ + "role":"user", + "content":"Hello world" + }] +}`; 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 new file mode 100644 index 000000000000..efa0d5ce82fe --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/gen_ai.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/gen_ai/constants'; + +const ACTION_TYPE_ID = '.gen-ai'; +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 generative AI systems.'); + expect(actionTypeModel.actionTypeTitle).toBe('Generative AI'); + }); +}); + +describe('gen ai 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/gen_ai/gen_ai.tsx b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/gen_ai.tsx new file mode 100644 index 000000000000..b326d59cc9c6 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/gen_ai.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/gen_ai/constants'; +import { GEN_AI_CONNECTOR_ID, GEN_AI_TITLE } from '../../../common/gen_ai/constants'; +import { GenerativeAiActionParams, GenerativeAiConnector } from './types'; + +interface ValidationErrors { + subAction: string[]; + body: string[]; +} +export function getConnectorType(): GenerativeAiConnector { + return { + id: GEN_AI_CONNECTOR_ID, + iconClass: lazy(() => import('./logo')), + selectMessage: i18n.translate('xpack.stackConnectors.components.genAi.selectMessageText', { + defaultMessage: 'Send a request to generative AI systems.', + }), + actionTypeTitle: GEN_AI_TITLE, + validateParams: async ( + actionParams: GenerativeAiActionParams + ): 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/gen_ai/index.ts b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/index.ts new file mode 100644 index 000000000000..dea9dbeaef3d --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/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 getGenerativeAiConnectorType } from './gen_ai'; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/logo.tsx b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/logo.tsx new file mode 100644 index 000000000000..80cbbf6e1402 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/logo.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 { LogoProps } from '../types'; + +const Logo = (props: LogoProps) => ( + + OpenAI icon + + +); + +// eslint-disable-next-line import/no-default-export +export { Logo as default }; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/params.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/params.test.tsx new file mode 100644 index 000000000000..a1260e32c841 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/params.test.tsx @@ -0,0 +1,146 @@ +/* + * 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 GenerativeAiParamsFields from './params'; +import { MockCodeEditor } from '@kbn/triggers-actions-ui-plugin/public/application/code_editor.mock'; +import { OpenAiProviderType, SUB_ACTION } from '../../../common/gen_ai/constants'; +import { DEFAULT_BODY, DEFAULT_BODY_AZURE, DEFAULT_URL } from './constants'; + +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('Gen AI Params Fields renders', () => { + test('all params fields are rendered', () => { + const actionParams = { + subAction: SUB_ACTION.RUN, + subActionParams: { body: '{"message": "test"}' }, + }; + + const { getByTestId } = render( + {}} + index={0} + messageVariables={messageVariables} + /> + ); + expect(getByTestId('bodyJsonEditor')).toBeInTheDocument(); + expect(getByTestId('bodyJsonEditor')).toHaveProperty('value', '{"message": "test"}'); + expect(getByTestId('bodyAddVariableButton')).toBeInTheDocument(); + }); + test.each([OpenAiProviderType.OpenAi, OpenAiProviderType.AzureAi])( + 'useEffect handles the case when subAction and subActionParams are undefined and apiProvider is %p', + (apiProvider) => { + const actionParams = { + subAction: undefined, + subActionParams: undefined, + }; + const editAction = jest.fn(); + const errors = {}; + const actionConnector = { + secrets: { + apiKey: 'apiKey', + }, + id: 'test', + actionTypeId: '.gen-ai', + isPreconfigured: false, + isDeprecated: false, + name: 'My GenAI Connector', + config: { + apiProvider, + apiUrl: DEFAULT_URL, + }, + }; + render( + + ); + expect(editAction).toHaveBeenCalledTimes(2); + expect(editAction).toHaveBeenCalledWith('subAction', SUB_ACTION.RUN, 0); + if (apiProvider === OpenAiProviderType.OpenAi) { + expect(editAction).toHaveBeenCalledWith('subActionParams', { body: DEFAULT_BODY }, 0); + } + if (apiProvider === OpenAiProviderType.AzureAi) { + expect(editAction).toHaveBeenCalledWith('subActionParams', { body: DEFAULT_BODY_AZURE }, 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( + + ); + expect(editAction).toHaveBeenCalledTimes(1); + expect(editAction).toHaveBeenCalledWith('subAction', SUB_ACTION.RUN, 0); + }); + + it('calls editAction function with the correct arguments ', () => { + const actionParams = { + subAction: SUB_ACTION.RUN, + subActionParams: { + body: '{"key": "value"}', + }, + }; + const editAction = jest.fn(); + const errors = {}; + const { getByTestId } = render( + + ); + const jsonEditor = getByTestId('bodyJsonEditor'); + fireEvent.change(jsonEditor, { target: { value: '{"new_key": "new_value"}' } }); + expect(editAction).toHaveBeenCalledWith( + 'subActionParams', + { body: '{"new_key": "new_value"}' }, + 0 + ); + }); +}); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/params.tsx b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/params.tsx new file mode 100644 index 000000000000..3ad883d1a248 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/params.tsx @@ -0,0 +1,90 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import type { ActionParamsProps } from '@kbn/triggers-actions-ui-plugin/public'; +import { + ActionConnectorMode, + JsonEditorWithMessageVariables, +} from '@kbn/triggers-actions-ui-plugin/public'; +import { DEFAULT_BODY, DEFAULT_BODY_AZURE } from './constants'; +import { OpenAiProviderType, SUB_ACTION } from '../../../common/gen_ai/constants'; +import { GenerativeAiActionConnector, GenerativeAiActionParams } from './types'; + +const GenerativeAiParamsFields: React.FunctionComponent< + ActionParamsProps +> = ({ + actionConnector, + actionParams, + editAction, + index, + messageVariables, + executionMode, + errors, +}) => { + const { subAction, subActionParams } = actionParams; + + const { body } = subActionParams ?? {}; + + const typedActionConnector = actionConnector as unknown as GenerativeAiActionConnector; + + 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) { + // default to OpenAiProviderType.OpenAi sample data + let sampleBody = DEFAULT_BODY; + + if (typedActionConnector?.config?.apiProvider === OpenAiProviderType.AzureAi) { + // update sample data if AzureAi + sampleBody = DEFAULT_BODY_AZURE; + } + editAction('subActionParams', { body: sampleBody }, index); + } + }, [typedActionConnector?.config?.apiProvider, editAction, index, subActionParams]); + + const editSubActionParams = useCallback( + (params: GenerativeAiActionParams['subActionParams']) => { + editAction('subActionParams', { ...subActionParams, ...params }, index); + }, + [editAction, index, subActionParams] + ); + + return ( + { + editSubActionParams({ body: json }); + }} + onBlur={() => { + if (!body) { + editSubActionParams({ body: '' }); + } + }} + data-test-subj="genAi-bodyJsonEditor" + /> + ); +}; + +// eslint-disable-next-line import/no-default-export +export { GenerativeAiParamsFields as default }; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/translations.ts b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/translations.ts new file mode 100644 index 000000000000..6bc911ffe5fa --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/translations.ts @@ -0,0 +1,89 @@ +/* + * 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.genAi.apiUrlTextFieldLabel', + { + defaultMessage: 'URL', + } +); + +export const API_KEY_LABEL = i18n.translate('xpack.stackConnectors.components.genAi.apiKeySecret', { + defaultMessage: 'API Key', +}); + +export const API_PROVIDER_HEADING = i18n.translate( + 'xpack.stackConnectors.components.genAi.providerHeading', + { + defaultMessage: 'OpenAI provider', + } +); + +export const API_PROVIDER_LABEL = i18n.translate( + 'xpack.stackConnectors.components.genAi.apiProviderLabel', + { + defaultMessage: 'Select an OpenAI provider', + } +); + +export const OPEN_AI = i18n.translate('xpack.stackConnectors.components.genAi.openAi', { + defaultMessage: 'OpenAI', +}); + +export const AZURE_AI = i18n.translate('xpack.stackConnectors.components.genAi.azureAi', { + defaultMessage: 'Azure OpenAI', +}); + +export const DOCUMENTATION = i18n.translate( + 'xpack.stackConnectors.components.genAi.documentation', + { + defaultMessage: 'documentation', + } +); + +export const URL_LABEL = i18n.translate( + 'xpack.stackConnectors.components.genAi.urlTextFieldLabel', + { + defaultMessage: 'URL', + } +); + +export const BODY_REQUIRED = i18n.translate( + 'xpack.stackConnectors.components.genAi.error.requiredGenerativeAiBodyText', + { + defaultMessage: 'Body is required.', + } +); +export const BODY_INVALID = i18n.translate( + 'xpack.stackConnectors.security.genAi.params.error.invalidBodyText', + { + defaultMessage: 'Body does not have a valid JSON format.', + } +); + +export const ACTION_REQUIRED = i18n.translate( + 'xpack.stackConnectors.security.genAi.params.error.requiredActionText', + { + defaultMessage: 'Action is required.', + } +); + +export const INVALID_ACTION = i18n.translate( + 'xpack.stackConnectors.security.genAi.params.error.invalidActionText', + { + defaultMessage: 'Invalid action name.', + } +); + +export const API_PROVIDER_REQUIRED = i18n.translate( + 'xpack.stackConnectors.components.genAi.error.requiredApiProviderText', + { + defaultMessage: 'API provider is required.', + } +); 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 new file mode 100644 index 000000000000..d86508c750bc --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/types.ts @@ -0,0 +1,35 @@ +/* + * 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 { 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'; + +export interface GenerativeAiActionParams { + subAction: SUB_ACTION.RUN | SUB_ACTION.TEST; + subActionParams: GenAiRunActionParams; +} + +export interface GenerativeAiConfig { + apiProvider: OpenAiProviderType; + apiUrl: string; +} + +export interface GenerativeAiSecrets { + apiKey: string; +} + +export type GenerativeAiConnector = ConnectorTypeModel< + GenerativeAiConfig, + GenerativeAiSecrets, + GenerativeAiActionParams +>; +export type GenerativeAiActionConnector = UserConfiguredActionConnector< + GenerativeAiConfig, + GenerativeAiSecrets +>; 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 2cedad5996a8..c0cec0382b4e 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/index.ts +++ b/x-pack/plugins/stack_connectors/public/connector_types/index.ts @@ -11,6 +11,7 @@ import { getCasesWebhookConnectorType } from './cases_webhook'; import { getEmailConnectorType } from './email'; import { getIndexConnectorType } from './es_index'; import { getJiraConnectorType } from './jira'; +import { getGenerativeAiConnectorType } from './gen_ai'; import { getOpsgenieConnectorType } from './opsgenie'; import { getPagerDutyConnectorType } from './pagerduty'; import { getResilientConnectorType } from './resilient'; @@ -57,6 +58,7 @@ export function registerConnectorTypes({ connectorTypeRegistry.register(getJiraConnectorType()); connectorTypeRegistry.register(getResilientConnectorType()); connectorTypeRegistry.register(getOpsgenieConnectorType()); + connectorTypeRegistry.register(getGenerativeAiConnectorType()); connectorTypeRegistry.register(getTeamsConnectorType()); connectorTypeRegistry.register(getTorqConnectorType()); connectorTypeRegistry.register(getTinesConnectorType()); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/api_schema.ts b/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/api_schema.ts new file mode 100644 index 000000000000..e6aab4be10d6 --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/api_schema.ts @@ -0,0 +1,33 @@ +/* + * 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'; + +export const GenAiBaseApiResponseSchema = schema.object( + { + id: schema.string(), + object: schema.string(), + created: schema.number(), + model: schema.string(), + usage: schema.object({ + prompt_tokens: schema.number(), + completion_tokens: schema.number(), + total_tokens: schema.number(), + }), + choices: schema.arrayOf( + schema.object({ + message: schema.object({ + role: schema.string(), + content: schema.string(), + }), + finish_reason: schema.string(), + index: schema.number(), + }) + ), + }, + { unknowns: 'ignore' } +); 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 new file mode 100644 index 000000000000..267f07ea38f1 --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/gen_ai.test.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 { GenAiConnector } from './gen_ai'; +import { GenAiBaseApiResponseSchema } from './api_schema'; +import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.mock'; +import { GEN_AI_CONNECTOR_ID, OpenAiProviderType } from '../../../common/gen_ai/constants'; +import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; +import { actionsMock } from '@kbn/actions-plugin/server/mocks'; + +describe('GenAiConnector', () => { + const sampleBody = JSON.stringify({ + model: 'gpt-3.5-turbo', + messages: [ + { + role: 'user', + content: 'Hello world', + }, + ], + }); + const mockResponse = { data: { result: 'success' } }; + const mockRequest = jest.fn().mockResolvedValue(mockResponse); + const mockError = jest.fn().mockImplementation(() => { + throw new Error('API Error'); + }); + + describe('OpenAI', () => { + const connector = new GenAiConnector({ + configurationUtilities: actionsConfigMock.create(), + connector: { id: '1', type: GEN_AI_CONNECTOR_ID }, + config: { apiUrl: 'https://example.com/api', apiProvider: OpenAiProviderType.OpenAi }, + secrets: { apiKey: '123' }, + logger: loggingSystemMock.createLogger(), + services: actionsMock.createServices(), + }); + beforeEach(() => { + // @ts-ignore + connector.request = mockRequest; + jest.clearAllMocks(); + }); + it('the OpenAI API call is successful with correct parameters', async () => { + const response = await connector.runApi({ body: sampleBody }); + expect(mockRequest).toBeCalledTimes(1); + expect(mockRequest).toHaveBeenCalledWith({ + url: 'https://example.com/api', + method: 'post', + responseSchema: GenAiBaseApiResponseSchema, + data: sampleBody, + headers: { + Authorization: 'Bearer 123', + 'content-type': 'application/json', + }, + }); + expect(response).toEqual({ result: 'success' }); + }); + + it('errors during API calls are properly handled', async () => { + // @ts-ignore + connector.request = mockError; + + await expect(connector.runApi({ body: sampleBody })).rejects.toThrow('API Error'); + }); + }); + + describe('AzureAI', () => { + const connector = new GenAiConnector({ + configurationUtilities: actionsConfigMock.create(), + connector: { id: '1', type: GEN_AI_CONNECTOR_ID }, + config: { apiUrl: 'https://example.com/api', apiProvider: OpenAiProviderType.AzureAi }, + secrets: { apiKey: '123' }, + logger: loggingSystemMock.createLogger(), + services: actionsMock.createServices(), + }); + beforeEach(() => { + // @ts-ignore + connector.request = mockRequest; + jest.clearAllMocks(); + }); + it('the AzureAI API call is successful with correct parameters', async () => { + const response = await connector.runApi({ body: sampleBody }); + expect(mockRequest).toBeCalledTimes(1); + expect(mockRequest).toHaveBeenCalledWith({ + url: 'https://example.com/api', + method: 'post', + responseSchema: GenAiBaseApiResponseSchema, + data: sampleBody, + headers: { + 'api-key': '123', + 'content-type': 'application/json', + }, + }); + expect(response).toEqual({ result: 'success' }); + }); + }); +}); 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 new file mode 100644 index 000000000000..928dde648061 --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/gen_ai.ts @@ -0,0 +1,74 @@ +/* + * 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 type { AxiosError } from 'axios'; +import { GenAiRunActionParamsSchema } from '../../../common/gen_ai/schema'; +import type { + GenAiConfig, + GenAiSecrets, + GenAiRunActionParams, + GenAiRunActionResponse, +} from '../../../common/gen_ai/types'; +import { GenAiBaseApiResponseSchema } from './api_schema'; +import { OpenAiProviderType, SUB_ACTION } from '../../../common/gen_ai/constants'; + +export class GenAiConnector extends SubActionConnector { + private url; + private provider; + private key; + + constructor(params: ServiceParams) { + super(params); + + this.url = this.config.apiUrl; + this.provider = this.config.apiProvider; + this.key = this.secrets.apiKey; + + this.registerSubActions(); + } + + private registerSubActions() { + this.registerSubAction({ + name: SUB_ACTION.RUN, + method: 'runApi', + schema: GenAiRunActionParamsSchema, + }); + + this.registerSubAction({ + name: SUB_ACTION.TEST, + method: 'runApi', + schema: GenAiRunActionParamsSchema, + }); + } + + protected getResponseErrorMessage(error: AxiosError): string { + if (!error.response?.status) { + return 'Unknown API Error'; + } + if (error.response.status === 401) { + return 'Unauthorized API Error'; + } + return `API Error: ${error.response?.status} - ${error.response?.statusText}`; + } + + public async runApi({ body }: GenAiRunActionParams): Promise { + const response = await this.request({ + url: this.url, + method: 'post', + responseSchema: GenAiBaseApiResponseSchema, + data: body, + headers: { + ...(this.provider === OpenAiProviderType.OpenAi + ? { Authorization: `Bearer ${this.key}` } + : { ['api-key']: this.key }), + ['content-type']: 'application/json', + }, + }); + return response.data; + } +} 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 new file mode 100644 index 000000000000..bf279a1739f8 --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/index.test.ts @@ -0,0 +1,108 @@ +/* + * 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 { GenAiConfig, GenAiSecrets } from '../../../common/gen_ai/types'; +import { SubActionConnectorType } from '@kbn/actions-plugin/server/sub_action_framework/types'; +import { OpenAiProviderType } from '../../../common/gen_ai/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); + +axios.create = jest.fn(() => axios); + +let connectorType: SubActionConnectorType; +let configurationUtilities: jest.Mocked; + +describe('Generative AI Connector', () => { + beforeEach(() => { + configurationUtilities = actionsConfigMock.create(); + connectorType = getConnectorType(); + }); + test('exposes the connector as `Generative AI` with id `.gen-ai`', () => { + expect(connectorType.id).toEqual('.gen-ai'); + expect(connectorType.name).toEqual('Generative AI'); + }); + describe('config validation', () => { + test('config validation passes when only required fields are provided', () => { + const config: GenAiConfig = { + apiUrl: 'https://api.openai.com/v1/chat/completions', + apiProvider: OpenAiProviderType.OpenAi, + }; + + expect(configValidator(config, { configurationUtilities })).toEqual(config); + }); + + test('config validation failed when a url is invalid', () => { + const config: GenAiConfig = { + apiUrl: 'example.com/do-something', + apiProvider: OpenAiProviderType.OpenAi, + }; + expect(() => { + configValidator(config, { configurationUtilities }); + }).toThrowErrorMatchingInlineSnapshot( + '"Error configuring Generative AI action: Error: URL Error: Invalid URL: example.com/do-something"' + ); + }); + + test('config validation failed when the OpenAI API provider is empty', () => { + const config: GenAiConfig = { + apiUrl: 'https://api.openai.com/v1/chat/completions', + apiProvider: '', + }; + expect(() => { + configValidator(config, { configurationUtilities }); + }).toThrowErrorMatchingInlineSnapshot( + '"Error configuring Generative AI action: Error: API Provider is not supported"' + ); + }); + + test('config validation failed when the OpenAI API provider is invalid', () => { + const config: GenAiConfig = { + apiUrl: 'https://api.openai.com/v1/chat/completions', + apiProvider: 'bad-one', + }; + expect(() => { + configValidator(config, { configurationUtilities }); + }).toThrowErrorMatchingInlineSnapshot( + '"Error configuring Generative AI action: Error: API Provider is not supported: bad-one"' + ); + }); + + 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: GenAiConfig = { + apiUrl: 'http://mylisteningserver.com:9200/endpoint', + apiProvider: OpenAiProviderType.OpenAi, + }; + + expect(() => { + configValidator(config, { configurationUtilities: configUtils }); + }).toThrowErrorMatchingInlineSnapshot( + `"Error configuring Generative AI action: Error: error validating url: target url is not present in allowedHosts"` + ); + }); + }); +}); 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 new file mode 100644 index 000000000000..36e8c198fe69 --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/index.ts @@ -0,0 +1,73 @@ +/* + * 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 { + GEN_AI_CONNECTOR_ID, + GEN_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 { GenAiConnector } from './gen_ai'; +import { renderParameterTemplates } from './render'; + +export const getConnectorType = (): SubActionConnectorType => ({ + id: GEN_AI_CONNECTOR_ID, + name: GEN_AI_TITLE, + Service: GenAiConnector, + schema: { + config: GenAiConfigSchema, + secrets: GenAiSecretsSchema, + }, + validators: [{ type: ValidatorType.CONFIG, validator: configValidator }], + supportedFeatureIds: [GeneralConnectorFeatureId], + minimumLicenseRequired: 'platinum' as const, + renderParameterTemplates, +}); + +export const configValidator = ( + configObject: GenAiConfig, + validatorServices: ValidatorServices +) => { + try { + assertURL(configObject.apiUrl); + urlAllowListValidator('apiUrl')(configObject, validatorServices); + + if ( + configObject.apiProvider !== OpenAiProviderType.OpenAi && + configObject.apiProvider !== OpenAiProviderType.AzureAi + ) { + throw new Error( + `API Provider is not supported${ + configObject.apiProvider && configObject.apiProvider.length + ? `: ${configObject.apiProvider}` + : `` + }` + ); + } + + return configObject; + } catch (err) { + throw new Error( + i18n.translate('xpack.stackConnectors.genAi.configurationErrorApiProvider', { + defaultMessage: 'Error configuring Generative AI action: {err}', + values: { + err, + }, + }) + ); + } +}; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/render.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/render.test.ts new file mode 100644 index 000000000000..301e096ad35b --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/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('GenAI - 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/gen_ai/render.ts b/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/render.ts new file mode 100644 index 000000000000..aae342a5acd8 --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/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/gen_ai/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/index.ts b/x-pack/plugins/stack_connectors/server/connector_types/index.ts index 0cd9a3b5a719..8bf9486499cf 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/index.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/index.ts @@ -17,6 +17,7 @@ import { getTinesConnectorType } from './tines'; 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 getPagerDutyConnectorType } from './pagerduty'; import { getConnectorType as getSwimlaneConnectorType } from './swimlane'; import { getConnectorType as getServerLogConnectorType } from './server_log'; @@ -98,4 +99,5 @@ export function registerConnectorTypes({ actions.registerSubActionConnectorType(getOpsgenieConnectorType()); actions.registerSubActionConnectorType(getTinesConnectorType()); + actions.registerSubActionConnectorType(getGenerativeAiConnectorType()); } diff --git a/x-pack/plugins/stack_connectors/server/plugin.test.ts b/x-pack/plugins/stack_connectors/server/plugin.test.ts index a572970e0be1..d84b416f6dda 100644 --- a/x-pack/plugins/stack_connectors/server/plugin.test.ts +++ b/x-pack/plugins/stack_connectors/server/plugin.test.ts @@ -131,6 +131,35 @@ describe('Stack Connectors Plugin', () => { name: 'Microsoft Teams', }) ); + expect(actionsSetup.registerType).toHaveBeenNthCalledWith( + 17, + expect.objectContaining({ + id: '.torq', + name: 'Torq', + }) + ); + expect(actionsSetup.registerSubActionConnectorType).toHaveBeenCalledTimes(3); + expect(actionsSetup.registerSubActionConnectorType).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + id: '.opsgenie', + name: 'Opsgenie', + }) + ); + expect(actionsSetup.registerSubActionConnectorType).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + id: '.tines', + name: 'Tines', + }) + ); + expect(actionsSetup.registerSubActionConnectorType).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + id: '.gen-ai', + name: 'Generative AI', + }) + ); }); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts b/x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts index 1d93811d7ecd..261780d6dcf0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts @@ -7,6 +7,10 @@ export { COMPARATORS, builtInComparators } from './comparators'; export { AGGREGATION_TYPES, builtInAggregationTypes } from './aggregation_types'; +export { loadAllActions, loadActionTypes } from '../../application/lib/action_connector_api'; +export { ConnectorAddModal } from '../../application/sections/action_connector_form'; +export type { ActionConnector } from '../..'; + export { builtInGroupByTypes } from './group_by_types'; export * from './action_frequency_types'; diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index d418b268f69c..cc1d41e48778 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -44,6 +44,7 @@ const enabledActionTypes = [ '.servicenow-itom', '.jira', '.resilient', + '.gen-ai', '.slack', '.slack_api', '.tines', diff --git a/x-pack/test/alerting_api_integration/common/plugins/actions_simulators/server/gen_ai_simulation.ts b/x-pack/test/alerting_api_integration/common/plugins/actions_simulators/server/gen_ai_simulation.ts new file mode 100644 index 000000000000..b10c5b6aa5f7 --- /dev/null +++ b/x-pack/test/alerting_api_integration/common/plugins/actions_simulators/server/gen_ai_simulation.ts @@ -0,0 +1,67 @@ +/* + * 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 GenAiSimulator 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 GenAiSimulator.sendErrorResponse(response); + } + + return GenAiSimulator.sendResponse(response); + } + + private static sendResponse(response: http.ServerResponse) { + response.statusCode = 202; + response.setHeader('Content-Type', 'application/json'); + response.end(JSON.stringify(genAiSuccessResponse, null, 4)); + } + + private static sendErrorResponse(response: http.ServerResponse) { + response.statusCode = 422; + response.setHeader('Content-Type', 'application/json;charset=UTF-8'); + response.end(JSON.stringify(genAiFailedResponse, null, 4)); + } +} + +export const genAiSuccessResponse = { + id: 'chatcmpl-7Gruzw7iTrb9X5mmQ533cSOGZU5Kh', + object: 'chat.completion', + created: 1684254865, + model: 'gpt-3.5-turbo-0301', + usage: { prompt_tokens: 10, completion_tokens: 10, total_tokens: 20 }, + choices: [ + { + message: { role: 'assistant', content: 'Hello there! How may I assist you today?' }, + finish_reason: 'stop', + index: 0, + }, + ], +}; +export const genAiFailedResponse = { + error: { + message: 'The model `bad model` does not exist', + type: 'invalid_request_error', + param: null, + code: null, + }, +}; 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 new file mode 100644 index 000000000000..14101b338ba8 --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/gen_ai.ts @@ -0,0 +1,316 @@ +/* + * 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 { + GenAiSimulator, + genAiSuccessResponse, +} from '@kbn/actions-simulators-plugin/server/gen_ai_simulation'; +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; + +const connectorTypeId = '.gen-ai'; +const name = 'A genAi action'; +const secrets = { + apiKey: 'genAiApiKey', +}; + +const defaultConfig = { apiProvider: 'OpenAI' }; + +// eslint-disable-next-line import/no-default-export +export default function genAiTest({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const configService = getService('config'); + + const createConnector = async (apiUrl: string) => { + const { body } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name, + connector_type_id: connectorTypeId, + config: { ...defaultConfig, apiUrl }, + secrets, + }) + .expect(200); + + return body.id; + }; + + describe('GenAi', () => { + describe('action creation', () => { + const simulator = new GenAiSimulator({ + 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_deprecated: false, + name, + connector_type_id: connectorTypeId, + is_missing_secrets: false, + config, + }); + }); + + it('should return 400 Bad Request when creating the connector without the apiProvider', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A GenAi action', + connector_type_id: '.gen-ai', + config: { + apiUrl: config.apiUrl, + }, + secrets: { + apiKey: '123', + }, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type config: [apiProvider]: expected value of type [string] but got [undefined]', + }); + }); + }); + + it('should return 400 Bad Request when creating the connector without the apiUrl', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name, + connector_type_id: connectorTypeId, + config: defaultConfig, + 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://genAi.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 Generative AI action: Error: error validating url: target url "http://genAi.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: [apiKey]: expected value of type [string] but got [undefined]', + }); + }); + }); + }); + + describe('executor', () => { + describe('validation', () => { + const simulator = new GenAiSimulator({ + proxy: { + config: configService.get('kbnTestServer.serverArgs'), + }, + }); + let genAiActionId: string; + + before(async () => { + const apiUrl = await simulator.start(); + genAiActionId = await createConnector(apiUrl); + }); + + after(() => { + simulator.close(); + }); + + it('should fail when the params is empty', async () => { + const { body } = await supertest + .post(`/api/actions/connector/${genAiActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: {}, + }); + expect(200); + + expect(body).to.eql({ + status: 'error', + connector_id: genAiActionId, + 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/${genAiActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { subAction: 'invalidAction' }, + }) + .expect(200); + + expect(body).to.eql({ + connector_id: genAiActionId, + 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`, + }); + }); + }); + + describe('execution', () => { + describe('successful response simulator', () => { + const simulator = new GenAiSimulator({ + proxy: { + config: configService.get('kbnTestServer.serverArgs'), + }, + }); + let apiUrl: string; + let genAiActionId: string; + + before(async () => { + apiUrl = await simulator.start(); + genAiActionId = await createConnector(apiUrl); + }); + + after(() => { + simulator.close(); + }); + + it('should send a stringified JSON object', async () => { + const { body } = await supertest + .post(`/api/actions/connector/${genAiActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + subAction: 'test', + subActionParams: { + body: '{"model":"gpt-3.5-turbo","messages":[{"role":"user","content":"Hello world"}]}', + }, + }, + }) + .expect(200); + + expect(simulator.requestData).to.eql({ + model: 'gpt-3.5-turbo', + messages: [{ role: 'user', content: 'Hello world' }], + }); + expect(body).to.eql({ + status: 'ok', + connector_id: genAiActionId, + data: genAiSuccessResponse, + }); + }); + }); + + describe('error response simulator', () => { + const simulator = new GenAiSimulator({ + returnError: true, + proxy: { + config: configService.get('kbnTestServer.serverArgs'), + }, + }); + + let genAiActionId: string; + + before(async () => { + const apiUrl = await simulator.start(); + genAiActionId = await createConnector(apiUrl); + }); + + after(() => { + simulator.close(); + }); + + it('should return a failure when error happens', async () => { + const { body } = await supertest + .post(`/api/actions/connector/${genAiActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: {}, + }) + .expect(200); + + expect(body).to.eql({ + status: 'error', + connector_id: genAiActionId, + 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/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts index 05bd4da72c19..d66e2a563014 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 @@ -38,6 +38,7 @@ export default function connectorsTests({ loadTestFile, getService }: FtrProvide loadTestFile(require.resolve('./connector_types/xmatters')); loadTestFile(require.resolve('./connector_types/tines')); loadTestFile(require.resolve('./connector_types/torq')); + loadTestFile(require.resolve('./connector_types/gen_ai')); 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 f0578f6dbd7c..4ef9363f0315 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 @@ -47,6 +47,7 @@ export default function createRegisteredConnectorTypeTests({ getService }: FtrPr '.tines', '.torq', '.opsgenie', + '.gen-ai', ].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 6bc6b02013ed..edd751eedfad 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 @@ -47,6 +47,7 @@ export default function ({ getService }: FtrProviderContext) { 'UPTIME:SyntheticsService:Sync-Saved-Monitor-Objects', 'actions:.cases-webhook', 'actions:.email', + 'actions:.gen-ai', 'actions:.index', 'actions:.jira', 'actions:.opsgenie',