mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[Security solution] Generative AI Connector (#157228)
This commit is contained in:
parent
2781645d07
commit
029eb3104a
45 changed files with 2094 additions and 17 deletions
|
@ -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]
|
||||
|
|
89
docs/management/connectors/action-types/gen-ai.asciidoc
Normal file
89
docs/management/connectors/action-types/gen-ai.asciidoc
Normal 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.
|
BIN
docs/management/connectors/images/gen-ai-connector.png
Normal file
BIN
docs/management/connectors/images/gen-ai-connector.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 194 KiB |
BIN
docs/management/connectors/images/gen-ai-params-test.png
Normal file
BIN
docs/management/connectors/images/gen-ai-params-test.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 179 KiB |
|
@ -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]
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
@ -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[]) {
|
||||
|
|
|
@ -12,6 +12,7 @@ export {
|
|||
CasesConnectorFeatureId,
|
||||
UptimeConnectorFeatureId,
|
||||
SecurityConnectorFeatureId,
|
||||
GeneralConnectorFeatureId,
|
||||
} from './connector_feature_config';
|
||||
export interface ActionType {
|
||||
id: string;
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
|
|
|
@ -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) {
|
||||
|
|
24
x-pack/plugins/stack_connectors/common/gen_ai/constants.ts
Normal file
24
x-pack/plugins/stack_connectors/common/gen_ai/constants.ts
Normal 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',
|
||||
}
|
22
x-pack/plugins/stack_connectors/common/gen_ai/schema.ts
Normal file
22
x-pack/plugins/stack_connectors/common/gen_ai/schema.ts
Normal 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' });
|
19
x-pack/plugins/stack_connectors/common/gen_ai/types.ts
Normal file
19
x-pack/plugins/stack_connectors/common/gen_ai/types.ts
Normal 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>;
|
|
@ -14,6 +14,9 @@
|
|||
"actions",
|
||||
"esUiShared",
|
||||
"triggersActionsUi"
|
||||
],
|
||||
"extraPublicDirs": [
|
||||
"public/common"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
11
x-pack/plugins/stack_connectors/public/common/index.ts
Normal file
11
x-pack/plugins/stack_connectors/public/common/index.ts
Normal 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 };
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 };
|
|
@ -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"
|
||||
}]
|
||||
}`;
|
|
@ -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: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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')),
|
||||
};
|
||||
}
|
|
@ -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';
|
|
@ -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 };
|
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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 };
|
|
@ -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.',
|
||||
}
|
||||
);
|
|
@ -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
|
||||
>;
|
|
@ -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());
|
||||
|
|
|
@ -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' }
|
||||
);
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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"`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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'),
|
||||
},
|
||||
};
|
||||
};
|
|
@ -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());
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -44,6 +44,7 @@ const enabledActionTypes = [
|
|||
'.servicenow-itom',
|
||||
'.jira',
|
||||
'.resilient',
|
||||
'.gen-ai',
|
||||
'.slack',
|
||||
'.slack_api',
|
||||
'.tines',
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
};
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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'));
|
||||
|
|
|
@ -47,6 +47,7 @@ export default function createRegisteredConnectorTypeTests({ getService }: FtrPr
|
|||
'.tines',
|
||||
'.torq',
|
||||
'.opsgenie',
|
||||
'.gen-ai',
|
||||
].sort()
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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',
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue