[Security solution] Generative AI Connector (#157228)

This commit is contained in:
Steph Milovic 2023-05-24 16:47:35 -06:00 committed by GitHub
parent 2781645d07
commit 029eb3104a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
45 changed files with 2094 additions and 17 deletions

View file

@ -78,6 +78,11 @@ a| <<xmatters-action-type,xMatters>>
a| <<torq-action-type,Torq>>
| Trigger a Torq workflow.
a| <<gen-ai-action-type,Generative AI>>
| Send a request to OpenAI.
|===
[NOTE]

View file

@ -0,0 +1,89 @@
[[gen-ai-action-type]]
== Generative AI connector and action
++++
<titleabbrev>Generative AI</titleabbrev>
++++
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 <<execute-connector-api,run connector API>> 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 <<execute-connector-api,run connector API>> 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 <<action-settings, Action configuration settings>> to customize connector networking configurations, such as proxies, certificates, or TLS settings. You can set configurations that apply to all your connectors or use `xpack.actions.customHostSettings` to set per-host configurations.

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

View file

@ -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]

View file

@ -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.

View file

@ -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',
]);
});

View file

@ -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[]) {

View file

@ -12,6 +12,7 @@ export {
CasesConnectorFeatureId,
UptimeConnectorFeatureId,
SecurityConnectorFeatureId,
GeneralConnectorFeatureId,
} from './connector_feature_config';
export interface ActionType {
id: string;

View file

@ -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();
});
});
});

View file

@ -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 = <T>(urlKey: string) => {
return (obj: T, validatorServices: ValidatorServices) => {
const { configurationUtilities } = validatorServices;

View file

@ -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<Config, Secrets> {
[k: string]: ((params: unknown) => unknown) | unknown;
private axiosInstance: AxiosInstance;
private validProtocols: string[] = ['http:', 'https:'];
private subActions: Map<string, SubAction> = new Map();
private configurationUtilities: ActionsConfigurationUtilities;
protected logger: Logger;
@ -56,19 +56,7 @@ export abstract class SubActionConnector<Config, Secrets> {
}
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) {

View file

@ -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',
}

View file

@ -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' });

View file

@ -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<typeof GenAiConfigSchema>;
export type GenAiSecrets = TypeOf<typeof GenAiSecretsSchema>;
export type GenAiRunActionParams = TypeOf<typeof GenAiRunActionParamsSchema>;
export type GenAiRunActionResponse = TypeOf<typeof GenAiRunActionResponseSchema>;

View file

@ -14,6 +14,9 @@
"actions",
"esUiShared",
"triggersActionsUi"
],
"extraPublicDirs": [
"public/common"
]
}
}

View file

@ -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 };

View file

@ -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(
<ConnectorFormTestProvider connector={actionConnector}>
<GenerativeAiConnectorFields
readOnly={false}
isEdit={false}
registerPreSubmitValidator={() => {}}
/>
</ConnectorFormTestProvider>
);
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(
<ConnectorFormTestProvider connector={actionConnector}>
<GenerativeAiConnectorFields
readOnly={false}
isEdit={false}
registerPreSubmitValidator={() => {}}
/>
</ConnectorFormTestProvider>
);
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(
<ConnectorFormTestProvider connector={actionConnector} onSubmit={onSubmit}>
<GenerativeAiConnectorFields
readOnly={false}
isEdit={false}
registerPreSubmitValidator={() => {}}
/>
</ConnectorFormTestProvider>
);
await act(async () => {
userEvent.click(getByTestId('form-test-provide-submit'));
});
await waitFor(async () => {
expect(onSubmit).toHaveBeenCalled();
});
expect(onSubmit).toBeCalledWith({
data: actionConnector,
isValid: true,
});
});
it('validates correctly if the apiUrl is empty', async () => {
const connector = {
...actionConnector,
config: {
...actionConnector.config,
apiUrl: '',
},
};
const res = render(
<ConnectorFormTestProvider connector={connector} onSubmit={onSubmit}>
<GenerativeAiConnectorFields
readOnly={false}
isEdit={false}
registerPreSubmitValidator={() => {}}
/>
</ConnectorFormTestProvider>
);
await act(async () => {
userEvent.click(res.getByTestId('form-test-provide-submit'));
});
await waitFor(async () => {
expect(onSubmit).toHaveBeenCalled();
});
expect(onSubmit).toHaveBeenCalledWith({ data: {}, isValid: false });
});
const tests: Array<[string, string]> = [
['config.apiUrl-input', 'not-valid'],
['secrets.apiKey-input', ''],
];
it.each(tests)('validates correctly %p', async (field, value) => {
const connector = {
...actionConnector,
config: {
...actionConnector.config,
headers: [],
},
};
const res = render(
<ConnectorFormTestProvider connector={connector} onSubmit={onSubmit}>
<GenerativeAiConnectorFields
readOnly={false}
isEdit={false}
registerPreSubmitValidator={() => {}}
/>
</ConnectorFormTestProvider>
);
await act(async () => {
await userEvent.type(res.getByTestId(field), `{selectall}{backspace}${value}`, {
delay: 10,
});
});
await act(async () => {
userEvent.click(res.getByTestId('form-test-provide-submit'));
});
await waitFor(async () => {
expect(onSubmit).toHaveBeenCalled();
});
expect(onSubmit).toHaveBeenCalledWith({ data: {}, isValid: false });
});
});
});

View file

@ -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: (
<FormattedMessage
defaultMessage="The OpenAI API endpoint URL. For more information on the URL, refer to the {genAiAPIUrlDocs}."
id="xpack.stackConnectors.components.genAi.openAiDocumentation"
values={{
genAiAPIUrlDocs: (
<EuiLink
data-test-subj="open-ai-api-doc"
href="https://platform.openai.com/docs/api-reference"
target="_blank"
>
{`${i18n.OPEN_AI} ${i18n.DOCUMENTATION}`}
</EuiLink>
),
}}
/>
),
},
];
const azureAiConfig: ConfigFieldSchema[] = [
{
id: 'apiUrl',
label: i18n.API_URL_LABEL,
isUrlField: true,
defaultValue: DEFAULT_URL_AZURE,
helpText: (
<FormattedMessage
defaultMessage="The Azure OpenAI API endpoint URL. For more information on the URL, refer to the {genAiAPIUrlDocs}."
id="xpack.stackConnectors.components.genAi.azureAiDocumentation"
values={{
genAiAPIUrlDocs: (
<EuiLink
data-test-subj="azure-ai-api-doc"
href="https://learn.microsoft.com/en-us/azure/cognitive-services/openai/reference"
target="_blank"
>
{`${i18n.AZURE_AI} ${i18n.DOCUMENTATION}`}
</EuiLink>
),
}}
/>
),
},
];
const openAiSecrets: SecretsFieldSchema[] = [
{
id: 'apiKey',
label: i18n.API_KEY_LABEL,
isPasswordField: true,
helpText: (
<FormattedMessage
defaultMessage="The OpenAI API authentication key for HTTP Basic authentication. For more details about generating OpenAI API keys, refer to the {genAiAPIKeyDocs}."
id="xpack.stackConnectors.components.genAi.openAiApiKeyDocumentation"
values={{
genAiAPIKeyDocs: (
<EuiLink
data-test-subj="open-ai-api-keys-doc"
href="https://platform.openai.com/account/api-keys"
target="_blank"
>
{`${i18n.OPEN_AI} ${i18n.DOCUMENTATION}`}
</EuiLink>
),
}}
/>
),
},
];
const azureAiSecrets: SecretsFieldSchema[] = [
{
id: 'apiKey',
label: i18n.API_KEY_LABEL,
isPasswordField: true,
helpText: (
<FormattedMessage
defaultMessage="The Azure API key for HTTP Basic authentication. For more details about generating Azure OpenAI API keys, refer to the {genAiAPIKeyDocs}."
id="xpack.stackConnectors.components.genAi.azureAiApiKeyDocumentation"
values={{
genAiAPIKeyDocs: (
<EuiLink
data-test-subj="azure-ai-api-keys-doc"
href="https://learn.microsoft.com/en-us/azure/cognitive-services/openai/reference#authentication"
target="_blank"
>
{`${i18n.AZURE_AI} ${i18n.DOCUMENTATION}`}
</EuiLink>
),
}}
/>
),
},
];
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<ActionConnectorFieldsProps> = ({
readOnly,
isEdit,
}) => {
const { getFieldDefaultValue } = useFormContext();
const [{ config }] = useFormData({
watch: ['config.apiProvider'],
});
const selectedProviderDefaultValue = useMemo(
() =>
getFieldDefaultValue<OpenAiProviderType>('config.apiProvider') ?? OpenAiProviderType.OpenAi,
[getFieldDefaultValue]
);
return (
<>
<UseField
path="config.apiProvider"
component={SelectField}
config={{
label: i18n.API_PROVIDER_LABEL,
defaultValue: selectedProviderDefaultValue,
validations: [
{
validator: emptyField(i18n.API_PROVIDER_REQUIRED),
},
],
}}
componentProps={{
euiFieldProps: {
'data-test-subj': 'config.apiProvider-select',
options: providerOptions,
fullWidth: true,
hasNoInitialSelection: true,
disabled: readOnly,
readOnly,
},
}}
/>
<EuiSpacer size="s" />
{config != null && config.apiProvider === OpenAiProviderType.OpenAi && (
<SimpleConnectorForm
isEdit={isEdit}
readOnly={readOnly}
configFormSchema={openAiConfig}
secretsFormSchema={openAiSecrets}
/>
)}
{/* ^v These are intentionally not if/else because of the way the `config.defaultValue` renders */}
{config != null && config.apiProvider === OpenAiProviderType.AzureAi && (
<SimpleConnectorForm
isEdit={isEdit}
readOnly={readOnly}
configFormSchema={azureAiConfig}
secretsFormSchema={azureAiSecrets}
/>
)}
</>
);
};
// eslint-disable-next-line import/no-default-export
export { GenerativeAiConnectorFields as default };

View file

@ -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"
}]
}`;

View file

@ -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<ActionTypeModel>();
registerConnectorTypes({ connectorTypeRegistry, services: registrationServicesMock });
const getResult = connectorTypeRegistry.get(ACTION_TYPE_ID);
if (getResult !== null) {
actionTypeModel = getResult;
}
});
describe('actionTypeRegistry.get() works', () => {
test('connector type static data is as expected', () => {
expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID);
expect(actionTypeModel.selectMessage).toBe('Send a request to 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: [],
},
});
});
});

View file

@ -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<GenericValidationResult<ValidationErrors>> => {
const { subAction, subActionParams } = actionParams;
const translations = await import('./translations');
const errors: ValidationErrors = {
body: [],
subAction: [],
};
if (subAction === SUB_ACTION.TEST || subAction === SUB_ACTION.RUN) {
if (!subActionParams.body?.length) {
errors.body.push(translations.BODY_REQUIRED);
} else {
try {
JSON.parse(subActionParams.body);
} catch {
errors.body.push(translations.BODY_INVALID);
}
}
}
if (errors.body.length) return { errors };
// The internal "subAction" param should always be valid, ensure it is only if "subActionParams" are valid
if (!subAction) {
errors.subAction.push(translations.ACTION_REQUIRED);
} else if (subAction !== SUB_ACTION.RUN && subAction !== SUB_ACTION.TEST) {
errors.subAction.push(translations.INVALID_ACTION);
}
return { errors };
},
actionConnectorFields: lazy(() => import('./connector')),
actionParamsFields: lazy(() => import('./params')),
};
}

View file

@ -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';

View file

@ -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) => (
<svg
{...props}
fill="#000000"
width="800px"
height="800px"
viewBox="0 0 24 24"
role="img"
xmlns="http://www.w3.org/2000/svg"
>
<title>OpenAI icon</title>
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" />
</svg>
);
// eslint-disable-next-line import/no-default-export
export { Logo as default };

View file

@ -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 <MockCodeEditor {...props} />;
},
};
});
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(
<GenerativeAiParamsFields
actionParams={actionParams}
errors={{ body: [] }}
editAction={() => {}}
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(
<GenerativeAiParamsFields
actionParams={actionParams}
actionConnector={actionConnector}
editAction={editAction}
index={0}
messageVariables={messageVariables}
errors={errors}
/>
);
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(
<GenerativeAiParamsFields
actionParams={actionParams}
editAction={editAction}
index={0}
messageVariables={messageVariables}
errors={errors}
/>
);
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(
<GenerativeAiParamsFields
actionParams={actionParams}
editAction={editAction}
index={0}
messageVariables={messageVariables}
errors={errors}
/>
);
const jsonEditor = getByTestId('bodyJsonEditor');
fireEvent.change(jsonEditor, { target: { value: '{"new_key": "new_value"}' } });
expect(editAction).toHaveBeenCalledWith(
'subActionParams',
{ body: '{"new_key": "new_value"}' },
0
);
});
});

View file

@ -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<GenerativeAiActionParams>
> = ({
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 (
<JsonEditorWithMessageVariables
messageVariables={messageVariables}
paramsProperty={'body'}
inputTargetValue={body}
label={i18n.translate('xpack.stackConnectors.components.genAi.bodyFieldLabel', {
defaultMessage: 'Body',
})}
aria-label={i18n.translate('xpack.stackConnectors.components.genAi.bodyCodeEditorAriaLabel', {
defaultMessage: 'Code editor',
})}
errors={errors.body as string[]}
onDocumentsChange={(json: string) => {
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 };

View file

@ -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.',
}
);

View file

@ -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
>;

View file

@ -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());

View file

@ -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' }
);

View file

@ -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' });
});
});
});

View file

@ -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<GenAiConfig, GenAiSecrets> {
private url;
private provider;
private key;
constructor(params: ServiceParams<GenAiConfig, GenAiSecrets>) {
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<GenAiRunActionResponse> {
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;
}
}

View file

@ -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<GenAiConfig, GenAiSecrets>;
let configurationUtilities: jest.Mocked<ActionsConfigurationUtilities>;
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"`
);
});
});
});

View file

@ -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<GenAiConfig, GenAiSecrets> => ({
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,
},
})
);
}
};

View file

@ -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'
);
});
});

View file

@ -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<ExecutorParams> = (
params,
variables
) => {
if (params?.subAction !== SUB_ACTION.RUN && params?.subAction !== SUB_ACTION.TEST) return params;
return {
...params,
subActionParams: {
...params.subActionParams,
body: renderMustacheString(params.subActionParams.body as string, variables, 'json'),
},
};
};

View file

@ -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());
}

View file

@ -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',
})
);
});
});
});

View file

@ -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';

View file

@ -44,6 +44,7 @@ const enabledActionTypes = [
'.servicenow-itom',
'.jira',
'.resilient',
'.gen-ai',
'.slack',
'.slack_api',
'.tines',

View file

@ -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<string, unknown>
) {
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,
},
};

View file

@ -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,
});
});
});
});
});
});
}

View file

@ -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'));

View file

@ -47,6 +47,7 @@ export default function createRegisteredConnectorTypeTests({ getService }: FtrPr
'.tines',
'.torq',
'.opsgenie',
'.gen-ai',
].sort()
);
});

View file

@ -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',