[Connectors][GenAI] Inference Service Kibana connector (#189027)
## Summary Resolves https://github.com/elastic/kibana/issues/188043 This PR adds new connector which is define integration with Elastic Inference Endpoint via [Inference APIs](https://www.elastic.co/guide/en/elasticsearch/reference/current/inference-apis.html) The lifecycle of the Inference Endpoint are managed by the connector registered handlers: - `preSaveHook` - [create](https://www.elastic.co/guide/en/elasticsearch/reference/current/put-inference-api.html) new Inference Endpoint in the connector create mode (`isEdit === false`) and [delete](https://www.elastic.co/guide/en/elasticsearch/reference/current/delete-inference-api.html)+[create](https://www.elastic.co/guide/en/elasticsearch/reference/current/put-inference-api.html) in the connector edit mode (`isEdit === true`) - `postSaveHook` - check if the connector SO was created/updated and if not removes Inference Endpoint from preSaveHook - `postDeleteHook` - [delete](https://www.elastic.co/guide/en/elasticsearch/reference/current/delete-inference-api.html) Inference Endpoint if connector was deleted. In the Kibana Stack Management Connectors, its represented with the new card (Technical preview badge): <img width="1261" alt="Screenshot 2024-09-27 at 2 11 12 PM" src="https://github.com/user-attachments/assets/dcbcce1f-06e7-4d08-8b77-0ba4105354f8"> To simplify the future integration with AI Assistants, the Connector consists from the two main UI parts: provider selector and required provider settings, which will be always displayed <img width="862" alt="Screenshot 2024-10-07 at 7 59 09 AM" src="https://github.com/user-attachments/assets/87bae493-c642-479e-b28f-6150354608dd"> and Additional options, which contains optional provider settings and Task Type configuration: <img width="861" alt="Screenshot 2024-10-07 at 8 00 15 AM" src="https://github.com/user-attachments/assets/2341c034-6198-4731-8ce7-e22e6c6fb20f"> subActions corresponds to the different taskTypes Inference API supports. Each of the task type has its own Inference Perform params. Currently added: - completion & completionStream - rerank - text_embedding - sparse_embedding Follow up work: 1. Collapse/expand Additional options, when the connector flyout/modal has AI Assistant as a context (path through the extending context implementation on the connector framework level) 2. Add support for additional params for Completion subAction to be able to path functions 3. Add support for tokens usage Dashboard, when inference API will include the used tokens count in the response 4. Add functionality and UX for migration from existing specific AI connectors to the Inference connector with proper provider and completion task 5. Integrate Connector with the AI Assistants --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: István Zoltán Szabó <istvan.szabo@elastic.co> Co-authored-by: Liam Thompson <32779855+leemthompo@users.noreply.github.com> Co-authored-by: Steph Milovic <stephanie.milovic@elastic.co>
5
.github/CODEOWNERS
vendored
|
@ -1681,6 +1681,11 @@ x-pack/test/security_solution_cypress/cypress/tasks/expandable_flyout @elastic/
|
|||
/x-pack/plugins/stack_connectors/server/connector_types/gemini @elastic/security-generative-ai @elastic/obs-ai-assistant @elastic/appex-ai-infra
|
||||
/x-pack/plugins/stack_connectors/common/gemini @elastic/security-generative-ai @elastic/obs-ai-assistant @elastic/appex-ai-infra
|
||||
|
||||
# Inference API
|
||||
/x-pack/plugins/stack_connectors/public/connector_types/inference @elastic/appex-ai-infra @elastic/security-generative-ai @elastic/obs-ai-assistant
|
||||
/x-pack/plugins/stack_connectors/server/connector_types/inference @elastic/appex-ai-infra @elastic/security-generative-ai @elastic/obs-ai-assistant
|
||||
/x-pack/plugins/stack_connectors/common/inference @elastic/appex-ai-infra @elastic/security-generative-ai @elastic/obs-ai-assistant
|
||||
|
||||
## Defend Workflows owner connectors
|
||||
/x-pack/plugins/stack_connectors/public/connector_types/sentinelone @elastic/security-defend-workflows
|
||||
/x-pack/plugins/stack_connectors/server/connector_types/sentinelone @elastic/security-defend-workflows
|
||||
|
|
|
@ -28,6 +28,10 @@ a| <<gemini-action-type,{gemini}>>
|
|||
|
||||
| Send a request to {gemini}.
|
||||
|
||||
a| <<inference-action-type,{inference}>>
|
||||
|
||||
| Send a request to {inference}.
|
||||
|
||||
a| <<email-action-type,Email>>
|
||||
|
||||
| Send email from your server.
|
||||
|
|
126
docs/management/connectors/action-types/inference.asciidoc
Normal file
|
@ -0,0 +1,126 @@
|
|||
[[inference-action-type]]
|
||||
== {infer-cap} connector and action
|
||||
++++
|
||||
<titleabbrev>{inference}</titleabbrev>
|
||||
++++
|
||||
:frontmatter-description: Add a connector that can send requests to {inference}.
|
||||
:frontmatter-tags-products: [kibana]
|
||||
:frontmatter-tags-content-type: [how-to]
|
||||
:frontmatter-tags-user-goals: [configure]
|
||||
|
||||
|
||||
The {infer} connector uses the {es} client to send requests to an {infer} service. The connector uses the <<execute-connector-api,run connector API>> to send the request.
|
||||
|
||||
[float]
|
||||
[[define-inference-ui]]
|
||||
=== Create connectors in {kib}
|
||||
|
||||
You can create connectors in *{stack-manage-app} > {connectors-ui}*. For example:
|
||||
|
||||
[role="screenshot"]
|
||||
image::management/connectors/images/inference-connector.png[{inference} connector]
|
||||
// NOTE: This is an autogenerated screenshot. Do not edit it directly.
|
||||
|
||||
[float]
|
||||
[[inference-connector-configuration]]
|
||||
==== Connector configuration
|
||||
|
||||
{infer-cap} connectors have the following configuration properties:
|
||||
|
||||
Name:: The name of the connector.
|
||||
Service:: The supported {infer} service provider.
|
||||
Task type:: The {infer} task type, it depends on the selected service.
|
||||
Inference ID:: The unique identifier of the {infer} endpoint.
|
||||
Provider configuration:: Settings for service configuration.
|
||||
Provider secrets:: Configuration for authentication.
|
||||
Task type configuration:: Settings for task type configuration.
|
||||
|
||||
[float]
|
||||
[[inference-action-configuration]]
|
||||
=== Test connectors
|
||||
|
||||
You can test connectors using the <<execute-connector-api,run connector API>> or
|
||||
while creating or editing the connector in {kib}. For example:
|
||||
|
||||
[role="screenshot"]
|
||||
image::management/connectors/images/inference-completion-params.png[{infer} params test]
|
||||
// NOTE: This is an autogenerated screenshot. Do not edit it directly.
|
||||
[float]
|
||||
[[inference-connector-actions]]
|
||||
=== {infer-cap} connector actions
|
||||
|
||||
The {infer} actions have the following configuration properties. Properties depend on the selected task type.
|
||||
|
||||
[float]
|
||||
[[inference-connector-perform-completion]]
|
||||
==== Completion
|
||||
|
||||
The following example performs a completion task on the example question.
|
||||
Input::
|
||||
The text on which you want to perform the {infer} task. For example:
|
||||
+
|
||||
[source,text]
|
||||
--
|
||||
{
|
||||
input: 'What is Elastic?'
|
||||
}
|
||||
--
|
||||
|
||||
[float]
|
||||
[[inference-connector-perform-text-embedding]]
|
||||
==== Text embedding
|
||||
|
||||
The following example performs a text embedding task.
|
||||
Input::
|
||||
The text on which you want to perform the {infer} task. For example:
|
||||
+
|
||||
[source,text]
|
||||
--
|
||||
{
|
||||
input: 'The sky above the port was the color of television tuned to a dead channel.',
|
||||
task_settings: {
|
||||
input_type: 'ingest'
|
||||
}
|
||||
}
|
||||
--
|
||||
Input type::
|
||||
An optional string that overwrites the connector's default model.
|
||||
|
||||
[float]
|
||||
[[inference-connector-perform-rerank]]
|
||||
==== Reranking
|
||||
|
||||
The following example performs a reranking task on the example input.
|
||||
Input::
|
||||
The text on which you want to perform the {infer} task. Should be a string array. For example:
|
||||
+
|
||||
[source,text]
|
||||
--
|
||||
{
|
||||
input: ['luke', 'like', 'leia', 'chewy', 'r2d2', 'star', 'wars'],
|
||||
query: 'star wars main character'
|
||||
}
|
||||
--
|
||||
Query::
|
||||
The search query text.
|
||||
|
||||
[float]
|
||||
[[inference-connector-perform-sparse-embedding]]
|
||||
==== Sparse embedding
|
||||
|
||||
The following example performs a sparse embedding task on the example sentence.
|
||||
Input::
|
||||
The text on which you want to perform the {infer} task. For example:
|
||||
+
|
||||
[source,text]
|
||||
--
|
||||
{
|
||||
input: 'The sky above the port was the color of television tuned to a dead channel.'
|
||||
}
|
||||
--
|
||||
|
||||
[float]
|
||||
[[inference-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 apply these settings to all your connectors or use `xpack.actions.customHostSettings` to set per-host configurations.
|
After Width: | Height: | Size: 113 KiB |
BIN
docs/management/connectors/images/inference-connector.png
Normal file
After Width: | Height: | Size: 219 KiB |
|
@ -4,6 +4,7 @@ include::action-types/crowdstrike.asciidoc[leveloffset=+1]
|
|||
include::action-types/d3security.asciidoc[leveloffset=+1]
|
||||
include::action-types/email.asciidoc[leveloffset=+1]
|
||||
include::action-types/gemini.asciidoc[leveloffset=+1]
|
||||
include::action-types/inference.asciidoc[leveloffset=+1]
|
||||
include::action-types/resilient.asciidoc[leveloffset=+1]
|
||||
include::action-types/index.asciidoc[leveloffset=+1]
|
||||
include::action-types/jira.asciidoc[leveloffset=+1]
|
||||
|
|
|
@ -283,6 +283,7 @@ A configuration URL that varies by connector:
|
|||
--
|
||||
* For an <<bedrock-action-type,{bedrock} connector>>, specifies the {bedrock} request URL.
|
||||
* For an <<gemini-action-type,{gemini} connector>>, specifies the {gemini} request URL.
|
||||
* For an <<inference-action-type,{inference} connector>>, specifies the Elastic {inference} request.
|
||||
* For a <<openai-action-type,OpenAI connector>>, specifies the OpenAI request URL.
|
||||
* For a <<resilient-action-type,{ibm-r} connector>>, specifies the {ibm-r} instance URL.
|
||||
* For a <<jira-action-type,Jira connector>>, specifies the Jira instance URL.
|
||||
|
|
|
@ -31,3 +31,14 @@ value:
|
|||
supported_feature_ids:
|
||||
- generativeAIForSecurity
|
||||
is_system_action_type: false
|
||||
- id: .inference
|
||||
name: Inference API
|
||||
enabled: true
|
||||
enabled_in_config: true
|
||||
enabled_in_license: true
|
||||
minimum_license_required: enterprise
|
||||
supported_feature_ids:
|
||||
- generativeAIForSecurity
|
||||
- generativeAIForObservability
|
||||
- generativeAIForSearchPlayground
|
||||
is_system_action_type: false
|
||||
|
|
|
@ -40,8 +40,18 @@ export const useLoadActionTypes = ({
|
|||
http,
|
||||
featureId: GenerativeAIForSecurityConnectorFeatureId,
|
||||
});
|
||||
const sortedData = queryResult.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const actionTypeKey = {
|
||||
bedrock: '.bedrock',
|
||||
openai: '.gen-ai',
|
||||
gemini: '.gemini',
|
||||
};
|
||||
|
||||
const sortedData = queryResult
|
||||
.filter((p) =>
|
||||
[actionTypeKey.bedrock, actionTypeKey.openai, actionTypeKey.gemini].includes(p.id)
|
||||
)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return sortedData;
|
||||
},
|
||||
{
|
||||
|
|
|
@ -46,7 +46,7 @@ const compatibilityGenerativeAIForObservability = i18n.translate(
|
|||
const compatibilityGenerativeAIForSearchPlayground = i18n.translate(
|
||||
'xpack.actions.availableConnectorFeatures.compatibility.generativeAIForSearchPlayground',
|
||||
{
|
||||
defaultMessage: 'Generative AI for Search Playground',
|
||||
defaultMessage: 'Generative AI for Search',
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@ description: The type of connector. For example, `.email`, `.index`, `.jira`, `.
|
|||
enum:
|
||||
- .bedrock
|
||||
- .gemini
|
||||
- .inference
|
||||
- .cases-webhook
|
||||
- .d3security
|
||||
- .email
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
title: Connector request properties for an Inference API connector
|
||||
description: Defines properties for connectors when type is `.inference`.
|
||||
type: object
|
||||
required:
|
||||
- provider
|
||||
- taskType
|
||||
- inferenceId
|
||||
properties:
|
||||
provider:
|
||||
type: string
|
||||
description: The Inference API service provider.
|
||||
taskType:
|
||||
type: string
|
||||
description: The Inference task type supported by provider.
|
||||
providerConfig:
|
||||
type: object
|
||||
description: The provider settings.
|
||||
taskTypeConfig:
|
||||
type: object
|
||||
description: The task type settings.
|
||||
inferenceId:
|
||||
type: string
|
||||
description: The task type settings.
|
|
@ -0,0 +1,9 @@
|
|||
title: Connector secrets properties for an AI Connector
|
||||
description: Defines secrets for connectors when type is `.inference`.
|
||||
type: object
|
||||
required:
|
||||
- providerSecrets
|
||||
properties:
|
||||
providerSecrets:
|
||||
type: object
|
||||
description: The service account credentials. The service account could have different type of properties to encode.
|
|
@ -5617,6 +5617,353 @@ Object {
|
|||
}
|
||||
`;
|
||||
|
||||
exports[`Connector type config checks detect connector type changes for: .inference 1`] = `
|
||||
Object {
|
||||
"flags": Object {
|
||||
"default": Object {
|
||||
"special": "deep",
|
||||
},
|
||||
"error": [Function],
|
||||
"presence": "optional",
|
||||
},
|
||||
"keys": Object {
|
||||
"input": Object {
|
||||
"flags": Object {
|
||||
"error": [Function],
|
||||
},
|
||||
"rules": Array [
|
||||
Object {
|
||||
"args": Object {
|
||||
"method": [Function],
|
||||
},
|
||||
"name": "custom",
|
||||
},
|
||||
],
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
"type": "object",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Connector type config checks detect connector type changes for: .inference 2`] = `
|
||||
Object {
|
||||
"flags": Object {
|
||||
"default": Object {
|
||||
"special": "deep",
|
||||
},
|
||||
"error": [Function],
|
||||
"presence": "optional",
|
||||
},
|
||||
"keys": Object {
|
||||
"input": Object {
|
||||
"flags": Object {
|
||||
"default": Array [],
|
||||
"error": [Function],
|
||||
"presence": "optional",
|
||||
},
|
||||
"items": Array [
|
||||
Object {
|
||||
"flags": Object {
|
||||
"error": [Function],
|
||||
"presence": "optional",
|
||||
},
|
||||
"rules": Array [
|
||||
Object {
|
||||
"args": Object {
|
||||
"method": [Function],
|
||||
},
|
||||
"name": "custom",
|
||||
},
|
||||
],
|
||||
"type": "string",
|
||||
},
|
||||
],
|
||||
"type": "array",
|
||||
},
|
||||
"query": Object {
|
||||
"flags": Object {
|
||||
"error": [Function],
|
||||
},
|
||||
"rules": Array [
|
||||
Object {
|
||||
"args": Object {
|
||||
"method": [Function],
|
||||
},
|
||||
"name": "custom",
|
||||
},
|
||||
],
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
"type": "object",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Connector type config checks detect connector type changes for: .inference 3`] = `
|
||||
Object {
|
||||
"flags": Object {
|
||||
"default": Object {
|
||||
"special": "deep",
|
||||
},
|
||||
"error": [Function],
|
||||
"presence": "optional",
|
||||
},
|
||||
"keys": Object {
|
||||
"input": Object {
|
||||
"flags": Object {
|
||||
"error": [Function],
|
||||
},
|
||||
"rules": Array [
|
||||
Object {
|
||||
"args": Object {
|
||||
"method": [Function],
|
||||
},
|
||||
"name": "custom",
|
||||
},
|
||||
],
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
"type": "object",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Connector type config checks detect connector type changes for: .inference 4`] = `
|
||||
Object {
|
||||
"flags": Object {
|
||||
"default": Object {
|
||||
"special": "deep",
|
||||
},
|
||||
"error": [Function],
|
||||
"presence": "optional",
|
||||
},
|
||||
"keys": Object {
|
||||
"input": Object {
|
||||
"flags": Object {
|
||||
"error": [Function],
|
||||
},
|
||||
"rules": Array [
|
||||
Object {
|
||||
"args": Object {
|
||||
"method": [Function],
|
||||
},
|
||||
"name": "custom",
|
||||
},
|
||||
],
|
||||
"type": "string",
|
||||
},
|
||||
"inputType": Object {
|
||||
"flags": Object {
|
||||
"error": [Function],
|
||||
},
|
||||
"rules": Array [
|
||||
Object {
|
||||
"args": Object {
|
||||
"method": [Function],
|
||||
},
|
||||
"name": "custom",
|
||||
},
|
||||
],
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
"type": "object",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Connector type config checks detect connector type changes for: .inference 5`] = `
|
||||
Object {
|
||||
"flags": Object {
|
||||
"default": Object {
|
||||
"special": "deep",
|
||||
},
|
||||
"error": [Function],
|
||||
"presence": "optional",
|
||||
},
|
||||
"keys": Object {
|
||||
"input": Object {
|
||||
"flags": Object {
|
||||
"error": [Function],
|
||||
},
|
||||
"rules": Array [
|
||||
Object {
|
||||
"args": Object {
|
||||
"method": [Function],
|
||||
},
|
||||
"name": "custom",
|
||||
},
|
||||
],
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
"type": "object",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Connector type config checks detect connector type changes for: .inference 6`] = `
|
||||
Object {
|
||||
"flags": Object {
|
||||
"default": Object {
|
||||
"special": "deep",
|
||||
},
|
||||
"error": [Function],
|
||||
"presence": "optional",
|
||||
},
|
||||
"keys": Object {
|
||||
"inferenceId": Object {
|
||||
"flags": Object {
|
||||
"error": [Function],
|
||||
},
|
||||
"rules": Array [
|
||||
Object {
|
||||
"args": Object {
|
||||
"method": [Function],
|
||||
},
|
||||
"name": "custom",
|
||||
},
|
||||
],
|
||||
"type": "string",
|
||||
},
|
||||
"provider": Object {
|
||||
"flags": Object {
|
||||
"error": [Function],
|
||||
},
|
||||
"rules": Array [
|
||||
Object {
|
||||
"args": Object {
|
||||
"method": [Function],
|
||||
},
|
||||
"name": "custom",
|
||||
},
|
||||
],
|
||||
"type": "string",
|
||||
},
|
||||
"providerConfig": Object {
|
||||
"flags": Object {
|
||||
"default": Object {},
|
||||
"error": [Function],
|
||||
"presence": "optional",
|
||||
"unknown": true,
|
||||
},
|
||||
"keys": Object {},
|
||||
"preferences": Object {
|
||||
"stripUnknown": Object {
|
||||
"objects": false,
|
||||
},
|
||||
},
|
||||
"type": "object",
|
||||
},
|
||||
"taskType": Object {
|
||||
"flags": Object {
|
||||
"error": [Function],
|
||||
},
|
||||
"rules": Array [
|
||||
Object {
|
||||
"args": Object {
|
||||
"method": [Function],
|
||||
},
|
||||
"name": "custom",
|
||||
},
|
||||
],
|
||||
"type": "string",
|
||||
},
|
||||
"taskTypeConfig": Object {
|
||||
"flags": Object {
|
||||
"default": Object {},
|
||||
"error": [Function],
|
||||
"presence": "optional",
|
||||
"unknown": true,
|
||||
},
|
||||
"keys": Object {},
|
||||
"preferences": Object {
|
||||
"stripUnknown": Object {
|
||||
"objects": false,
|
||||
},
|
||||
},
|
||||
"type": "object",
|
||||
},
|
||||
},
|
||||
"type": "object",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Connector type config checks detect connector type changes for: .inference 7`] = `
|
||||
Object {
|
||||
"flags": Object {
|
||||
"default": Object {
|
||||
"special": "deep",
|
||||
},
|
||||
"error": [Function],
|
||||
"presence": "optional",
|
||||
},
|
||||
"keys": Object {
|
||||
"providerSecrets": Object {
|
||||
"flags": Object {
|
||||
"default": Object {},
|
||||
"error": [Function],
|
||||
"presence": "optional",
|
||||
"unknown": true,
|
||||
},
|
||||
"keys": Object {},
|
||||
"preferences": Object {
|
||||
"stripUnknown": Object {
|
||||
"objects": false,
|
||||
},
|
||||
},
|
||||
"type": "object",
|
||||
},
|
||||
},
|
||||
"type": "object",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Connector type config checks detect connector type changes for: .inference 8`] = `
|
||||
Object {
|
||||
"flags": Object {
|
||||
"default": Object {
|
||||
"special": "deep",
|
||||
},
|
||||
"error": [Function],
|
||||
"presence": "optional",
|
||||
},
|
||||
"keys": Object {
|
||||
"subAction": Object {
|
||||
"flags": Object {
|
||||
"error": [Function],
|
||||
},
|
||||
"rules": Array [
|
||||
Object {
|
||||
"args": Object {
|
||||
"method": [Function],
|
||||
},
|
||||
"name": "custom",
|
||||
},
|
||||
],
|
||||
"type": "string",
|
||||
},
|
||||
"subActionParams": Object {
|
||||
"flags": Object {
|
||||
"default": Object {
|
||||
"special": "deep",
|
||||
},
|
||||
"error": [Function],
|
||||
"presence": "optional",
|
||||
"unknown": true,
|
||||
},
|
||||
"keys": Object {},
|
||||
"preferences": Object {
|
||||
"stripUnknown": Object {
|
||||
"objects": false,
|
||||
},
|
||||
},
|
||||
"type": "object",
|
||||
},
|
||||
},
|
||||
"type": "object",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Connector type config checks detect connector type changes for: .jira 1`] = `
|
||||
Object {
|
||||
"flags": Object {
|
||||
|
|
|
@ -32,6 +32,7 @@ export const connectorTypes: string[] = [
|
|||
'.thehive',
|
||||
'.sentinelone',
|
||||
'.crowdstrike',
|
||||
'.inference',
|
||||
'.cases',
|
||||
'.observability-ai-assistant',
|
||||
];
|
||||
|
|
|
@ -15,6 +15,7 @@ export const allowedExperimentalValues = Object.freeze({
|
|||
isMustacheAutocompleteOn: false,
|
||||
sentinelOneConnectorOn: true,
|
||||
crowdstrikeConnectorOn: true,
|
||||
inferenceConnectorOn: true,
|
||||
});
|
||||
|
||||
export type ExperimentalConfigKeys = Array<keyof ExperimentalFeatures>;
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* 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 INFERENCE_CONNECTOR_TITLE = i18n.translate(
|
||||
'xpack.stackConnectors.components.inference.connectorTypeTitle',
|
||||
{
|
||||
defaultMessage: 'AI Connector',
|
||||
}
|
||||
);
|
||||
|
||||
export enum ServiceProviderKeys {
|
||||
amazonbedrock = 'amazonbedrock',
|
||||
azureopenai = 'azureopenai',
|
||||
azureaistudio = 'azureaistudio',
|
||||
cohere = 'cohere',
|
||||
elasticsearch = 'elasticsearch',
|
||||
googleaistudio = 'googleaistudio',
|
||||
googlevertexai = 'googlevertexai',
|
||||
hugging_face = 'hugging_face',
|
||||
mistral = 'mistral',
|
||||
openai = 'openai',
|
||||
anthropic = 'anthropic',
|
||||
watsonxai = 'watsonxai',
|
||||
'alibabacloud-ai-search' = 'alibabacloud-ai-search',
|
||||
}
|
||||
|
||||
export const INFERENCE_CONNECTOR_ID = '.inference';
|
||||
export enum SUB_ACTION {
|
||||
COMPLETION = 'completion',
|
||||
RERANK = 'rerank',
|
||||
TEXT_EMBEDDING = 'text_embedding',
|
||||
SPARSE_EMBEDDING = 'sparse_embedding',
|
||||
COMPLETION_STREAM = 'completion_stream',
|
||||
}
|
||||
|
||||
export const DEFAULT_PROVIDER = 'openai';
|
||||
export const DEFAULT_TASK_TYPE = 'completion';
|
68
x-pack/plugins/stack_connectors/common/inference/schema.ts
Normal file
|
@ -0,0 +1,68 @@
|
|||
/*
|
||||
* 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 ConfigSchema = schema.object({
|
||||
provider: schema.string(),
|
||||
taskType: schema.string(),
|
||||
inferenceId: schema.string(),
|
||||
providerConfig: schema.object({}, { unknowns: 'allow', defaultValue: {} }),
|
||||
taskTypeConfig: schema.object({}, { unknowns: 'allow', defaultValue: {} }),
|
||||
});
|
||||
|
||||
export const SecretsSchema = schema.object({
|
||||
providerSecrets: schema.object({}, { unknowns: 'allow', defaultValue: {} }),
|
||||
});
|
||||
|
||||
export const ChatCompleteParamsSchema = schema.object({
|
||||
input: schema.string(),
|
||||
});
|
||||
|
||||
export const ChatCompleteResponseSchema = schema.arrayOf(
|
||||
schema.object({
|
||||
result: schema.string(),
|
||||
}),
|
||||
{ defaultValue: [] }
|
||||
);
|
||||
|
||||
export const RerankParamsSchema = schema.object({
|
||||
input: schema.arrayOf(schema.string(), { defaultValue: [] }),
|
||||
query: schema.string(),
|
||||
});
|
||||
|
||||
export const RerankResponseSchema = schema.arrayOf(
|
||||
schema.object({
|
||||
text: schema.maybe(schema.string()),
|
||||
index: schema.number(),
|
||||
score: schema.number(),
|
||||
}),
|
||||
{ defaultValue: [] }
|
||||
);
|
||||
|
||||
export const SparseEmbeddingParamsSchema = schema.object({
|
||||
input: schema.string(),
|
||||
});
|
||||
|
||||
export const SparseEmbeddingResponseSchema = schema.arrayOf(
|
||||
schema.object({}, { unknowns: 'allow' }),
|
||||
{ defaultValue: [] }
|
||||
);
|
||||
|
||||
export const TextEmbeddingParamsSchema = schema.object({
|
||||
input: schema.string(),
|
||||
inputType: schema.string(),
|
||||
});
|
||||
|
||||
export const TextEmbeddingResponseSchema = schema.arrayOf(
|
||||
schema.object({
|
||||
embedding: schema.arrayOf(schema.any(), { defaultValue: [] }),
|
||||
}),
|
||||
{ defaultValue: [] }
|
||||
);
|
||||
|
||||
export const StreamingResponseSchema = schema.stream();
|
38
x-pack/plugins/stack_connectors/common/inference/types.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { TypeOf } from '@kbn/config-schema';
|
||||
import {
|
||||
ConfigSchema,
|
||||
SecretsSchema,
|
||||
StreamingResponseSchema,
|
||||
ChatCompleteParamsSchema,
|
||||
ChatCompleteResponseSchema,
|
||||
RerankParamsSchema,
|
||||
RerankResponseSchema,
|
||||
SparseEmbeddingParamsSchema,
|
||||
SparseEmbeddingResponseSchema,
|
||||
TextEmbeddingParamsSchema,
|
||||
TextEmbeddingResponseSchema,
|
||||
} from './schema';
|
||||
|
||||
export type Config = TypeOf<typeof ConfigSchema>;
|
||||
export type Secrets = TypeOf<typeof SecretsSchema>;
|
||||
|
||||
export type ChatCompleteParams = TypeOf<typeof ChatCompleteParamsSchema>;
|
||||
export type ChatCompleteResponse = TypeOf<typeof ChatCompleteResponseSchema>;
|
||||
|
||||
export type RerankParams = TypeOf<typeof RerankParamsSchema>;
|
||||
export type RerankResponse = TypeOf<typeof RerankResponseSchema>;
|
||||
|
||||
export type SparseEmbeddingParams = TypeOf<typeof SparseEmbeddingParamsSchema>;
|
||||
export type SparseEmbeddingResponse = TypeOf<typeof SparseEmbeddingResponseSchema>;
|
||||
|
||||
export type TextEmbeddingParams = TypeOf<typeof TextEmbeddingParamsSchema>;
|
||||
export type TextEmbeddingResponse = TypeOf<typeof TextEmbeddingResponseSchema>;
|
||||
|
||||
export type StreamingResponse = TypeOf<typeof StreamingResponseSchema>;
|
|
@ -14,6 +14,7 @@ import { getJiraConnectorType } from './jira';
|
|||
import { getOpenAIConnectorType } from './openai';
|
||||
import { getBedrockConnectorType } from './bedrock';
|
||||
import { getGeminiConnectorType } from './gemini';
|
||||
import { getInferenceConnectorType } from './inference';
|
||||
import { getOpsgenieConnectorType } from './opsgenie';
|
||||
import { getPagerDutyConnectorType } from './pagerduty';
|
||||
import { getResilientConnectorType } from './resilient';
|
||||
|
@ -80,4 +81,7 @@ export function registerConnectorTypes({
|
|||
if (ExperimentalFeaturesService.get().crowdstrikeConnectorOn) {
|
||||
connectorTypeRegistry.register(getCrowdStrikeConnectorType());
|
||||
}
|
||||
if (ExperimentalFeaturesService.get().inferenceConnectorOn) {
|
||||
connectorTypeRegistry.register(getInferenceConnectorType());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,360 @@
|
|||
/*
|
||||
* 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, useCallback } from 'react';
|
||||
import { css } from '@emotion/react';
|
||||
|
||||
import {
|
||||
EuiFormRow,
|
||||
EuiSpacer,
|
||||
EuiTitle,
|
||||
EuiAccordion,
|
||||
EuiFieldText,
|
||||
useEuiTheme,
|
||||
EuiTextColor,
|
||||
EuiButtonGroup,
|
||||
EuiPanel,
|
||||
EuiHorizontalRule,
|
||||
EuiButtonEmpty,
|
||||
EuiCopy,
|
||||
EuiButton,
|
||||
useEuiFontSize,
|
||||
} from '@elastic/eui';
|
||||
import {
|
||||
getFieldValidityAndErrorMessage,
|
||||
UseField,
|
||||
useFormContext,
|
||||
} from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
|
||||
import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers';
|
||||
import { ConnectorConfigurationFormItems } from '../lib/dynamic_config/connector_configuration_form_items';
|
||||
import * as i18n from './translations';
|
||||
import { DEFAULT_TASK_TYPE } from './constants';
|
||||
import { ConfigEntryView } from '../lib/dynamic_config/types';
|
||||
import { Config } from './types';
|
||||
import { TaskTypeOption } from './helpers';
|
||||
|
||||
// Custom trigger button CSS
|
||||
const buttonCss = css`
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
`;
|
||||
|
||||
interface AdditionalOptionsConnectorFieldsProps {
|
||||
config: Config;
|
||||
readOnly: boolean;
|
||||
isEdit: boolean;
|
||||
optionalProviderFormFields: ConfigEntryView[];
|
||||
onSetProviderConfigEntry: (key: string, value: unknown) => Promise<void>;
|
||||
onTaskTypeOptionsSelect: (taskType: string, provider?: string) => Promise<void>;
|
||||
selectedTaskType?: string;
|
||||
taskTypeFormFields: ConfigEntryView[];
|
||||
taskTypeSchema: ConfigEntryView[];
|
||||
taskTypeOptions: TaskTypeOption[];
|
||||
}
|
||||
|
||||
export const AdditionalOptionsConnectorFields: React.FC<AdditionalOptionsConnectorFieldsProps> = ({
|
||||
config,
|
||||
readOnly,
|
||||
isEdit,
|
||||
taskTypeOptions,
|
||||
optionalProviderFormFields,
|
||||
taskTypeFormFields,
|
||||
taskTypeSchema,
|
||||
selectedTaskType,
|
||||
onSetProviderConfigEntry,
|
||||
onTaskTypeOptionsSelect,
|
||||
}) => {
|
||||
const xsFontSize = useEuiFontSize('xs').fontSize;
|
||||
const { euiTheme } = useEuiTheme();
|
||||
const { setFieldValue, validateFields } = useFormContext();
|
||||
|
||||
const onSetTaskTypeConfigEntry = useCallback(
|
||||
async (key: string, value: unknown) => {
|
||||
if (taskTypeSchema) {
|
||||
const entry: ConfigEntryView | undefined = taskTypeSchema.find(
|
||||
(p: ConfigEntryView) => p.key === key
|
||||
);
|
||||
if (entry) {
|
||||
if (!config.taskTypeConfig) {
|
||||
config.taskTypeConfig = {};
|
||||
}
|
||||
const newConfig = { ...config.taskTypeConfig };
|
||||
newConfig[key] = value;
|
||||
setFieldValue('config.taskTypeConfig', newConfig);
|
||||
await validateFields(['config.taskTypeConfig']);
|
||||
}
|
||||
}
|
||||
},
|
||||
[config, setFieldValue, taskTypeSchema, validateFields]
|
||||
);
|
||||
|
||||
const taskTypeSettings = useMemo(
|
||||
() =>
|
||||
selectedTaskType || config.taskType?.length ? (
|
||||
<>
|
||||
<EuiTitle size="xxs" data-test-subj="task-type-details-label">
|
||||
<h4>
|
||||
<FormattedMessage
|
||||
id="xpack.stackConnectors.components.inference.taskTypeDetailsLabel"
|
||||
defaultMessage="Task settings"
|
||||
/>
|
||||
</h4>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="xs" />
|
||||
<div
|
||||
css={css`
|
||||
font-size: ${xsFontSize};
|
||||
color: ${euiTheme.colors.subduedText};
|
||||
`}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.stackConnectors.components.inference.taskTypeHelpLabel"
|
||||
defaultMessage="Configure the inference task. These settings are specific to the service and model selected."
|
||||
/>
|
||||
</div>
|
||||
<EuiSpacer size="m" />
|
||||
<UseField
|
||||
path="config.taskType"
|
||||
config={{
|
||||
validations: [
|
||||
{
|
||||
validator: fieldValidators.emptyField(i18n.getRequiredMessage('Task type')),
|
||||
isBlocking: true,
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
{(field) => {
|
||||
const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field);
|
||||
|
||||
return (
|
||||
<EuiFormRow
|
||||
id="taskType"
|
||||
fullWidth
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="xpack.stackConnectors.components.inference.taskTypeLabel"
|
||||
defaultMessage="Task type"
|
||||
/>
|
||||
}
|
||||
isInvalid={isInvalid}
|
||||
error={errorMessage}
|
||||
>
|
||||
{isEdit || readOnly ? (
|
||||
<EuiButton
|
||||
css={{
|
||||
background: euiTheme.colors.disabled,
|
||||
color: euiTheme.colors.lightestShade,
|
||||
}}
|
||||
data-test-subj="taskTypeSelectDisabled"
|
||||
isDisabled
|
||||
>
|
||||
{config.taskType}
|
||||
</EuiButton>
|
||||
) : taskTypeOptions.length === 1 ? (
|
||||
<EuiButton
|
||||
css={{
|
||||
background: euiTheme.colors.darkShade,
|
||||
color: euiTheme.colors.lightestShade,
|
||||
}}
|
||||
data-test-subj="taskTypeSelectSingle"
|
||||
onClick={() => onTaskTypeOptionsSelect(config.taskType)}
|
||||
>
|
||||
{config.taskType}
|
||||
</EuiButton>
|
||||
) : (
|
||||
<EuiButtonGroup
|
||||
data-test-subj="taskTypeSelect"
|
||||
buttonSize="m"
|
||||
legend="Task type"
|
||||
defaultValue={DEFAULT_TASK_TYPE}
|
||||
idSelected={config.taskType}
|
||||
onChange={(id) => onTaskTypeOptionsSelect(id)}
|
||||
options={taskTypeOptions}
|
||||
color="text"
|
||||
type="single"
|
||||
/>
|
||||
)}
|
||||
</EuiFormRow>
|
||||
);
|
||||
}}
|
||||
</UseField>
|
||||
<EuiSpacer size="s" />
|
||||
<ConnectorConfigurationFormItems
|
||||
itemsGrow={false}
|
||||
isLoading={false}
|
||||
direction="column"
|
||||
items={taskTypeFormFields}
|
||||
setConfigEntry={onSetTaskTypeConfigEntry}
|
||||
/>
|
||||
</>
|
||||
) : null,
|
||||
[
|
||||
selectedTaskType,
|
||||
config?.taskType,
|
||||
xsFontSize,
|
||||
euiTheme.colors,
|
||||
taskTypeFormFields,
|
||||
onSetTaskTypeConfigEntry,
|
||||
isEdit,
|
||||
readOnly,
|
||||
taskTypeOptions,
|
||||
onTaskTypeOptionsSelect,
|
||||
]
|
||||
);
|
||||
|
||||
const inferenceUri = useMemo(() => `_inference/${selectedTaskType}/`, [selectedTaskType]);
|
||||
|
||||
return (
|
||||
<EuiAccordion
|
||||
id="inferenceAdditionalOptions"
|
||||
data-test-subj="inferenceAdditionalOptions"
|
||||
buttonProps={{ css: buttonCss }}
|
||||
css={css`
|
||||
.euiAccordion__triggerWrapper {
|
||||
display: inline-flex;
|
||||
}
|
||||
`}
|
||||
element="fieldset"
|
||||
arrowDisplay="right"
|
||||
arrowProps={{
|
||||
color: 'primary',
|
||||
}}
|
||||
buttonElement="button"
|
||||
borders="none"
|
||||
buttonContent={
|
||||
<EuiTextColor color={euiTheme.colors.primary}>
|
||||
<FormattedMessage
|
||||
id="xpack.stackConnectors.components.inference.additionalOptionsLabel"
|
||||
defaultMessage="Additional options"
|
||||
/>
|
||||
</EuiTextColor>
|
||||
}
|
||||
initialIsOpen={true}
|
||||
>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiPanel hasBorder={true}>
|
||||
{optionalProviderFormFields.length > 0 ? (
|
||||
<>
|
||||
<EuiTitle size="xxs" data-test-subj="provider-optional-settings-label">
|
||||
<h4>
|
||||
<FormattedMessage
|
||||
id="xpack.stackConnectors.components.inference.providerOptionalSettingsLabel"
|
||||
defaultMessage="Service settings"
|
||||
/>
|
||||
</h4>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="xs" />
|
||||
<div
|
||||
css={css`
|
||||
font-size: ${xsFontSize};
|
||||
color: ${euiTheme.colors.subduedText};
|
||||
`}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.stackConnectors.components.inference.providerOptionalSettingsHelpLabel"
|
||||
defaultMessage="Configure the inference provider. These settings are optional provider settings."
|
||||
/>
|
||||
</div>
|
||||
<EuiSpacer size="m" />
|
||||
<ConnectorConfigurationFormItems
|
||||
itemsGrow={false}
|
||||
isLoading={false}
|
||||
direction="column"
|
||||
items={optionalProviderFormFields}
|
||||
setConfigEntry={onSetProviderConfigEntry}
|
||||
/>
|
||||
<EuiSpacer size="m" />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{taskTypeSettings}
|
||||
<EuiHorizontalRule />
|
||||
<EuiTitle size="xxs" data-test-subj="task-type-details-label">
|
||||
<h4>
|
||||
<FormattedMessage
|
||||
id="xpack.stackConnectors.components.inference.inferenceEndpointLabel"
|
||||
defaultMessage="Inference Endpoint"
|
||||
/>
|
||||
</h4>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="xs" />
|
||||
<div
|
||||
css={css`
|
||||
font-size: ${xsFontSize};
|
||||
color: ${euiTheme.colors.subduedText};
|
||||
`}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.stackConnectors.components.inference.inferenceEndpointHelpLabel"
|
||||
defaultMessage="Inference endpoints provide a simplified method for using this configuration, ecpecially from the API"
|
||||
/>
|
||||
</div>
|
||||
<EuiSpacer size="s" />
|
||||
|
||||
<UseField path="config.inferenceId">
|
||||
{(field) => {
|
||||
const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field);
|
||||
|
||||
return (
|
||||
<EuiFormRow
|
||||
id="inferenceId"
|
||||
isInvalid={isInvalid}
|
||||
error={errorMessage}
|
||||
fullWidth
|
||||
helpText={
|
||||
<FormattedMessage
|
||||
id="xpack.stackConnectors.components.inference.inferenceIdHelpLabel"
|
||||
defaultMessage="This ID cannot be changed once created."
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiFieldText
|
||||
fullWidth
|
||||
disabled={isEdit || readOnly}
|
||||
value={config.inferenceId}
|
||||
onChange={(e) => {
|
||||
setFieldValue('config.inferenceId', e.target.value);
|
||||
}}
|
||||
prepend={inferenceUri}
|
||||
append={
|
||||
<EuiCopy
|
||||
beforeMessage={i18n.COPY_TOOLTIP}
|
||||
afterMessage={i18n.COPIED_TOOLTIP}
|
||||
textToCopy={`${inferenceUri}${config.inferenceId}`}
|
||||
>
|
||||
{(copy) => (
|
||||
<EuiButtonEmpty
|
||||
iconType="copy"
|
||||
size="xs"
|
||||
iconSide="right"
|
||||
onClick={copy}
|
||||
data-test-subj="copyInferenceUriToClipboard"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.stackConnectors.components.inference.copyLabel"
|
||||
defaultMessage="Copy"
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
)}
|
||||
</EuiCopy>
|
||||
}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
);
|
||||
}}
|
||||
</UseField>
|
||||
</EuiPanel>
|
||||
</EuiAccordion>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export { AdditionalOptionsConnectorFields as default };
|
|
@ -0,0 +1,353 @@
|
|||
/*
|
||||
* 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 ConnectorFields from './connector';
|
||||
import { ConnectorFormTestProvider } from '../lib/test_utils';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { createStartServicesMock } from '@kbn/triggers-actions-ui-plugin/public/common/lib/kibana/kibana_react.mock';
|
||||
import { DisplayType, FieldType } from '../lib/dynamic_config/types';
|
||||
import { useProviders } from './providers/get_providers';
|
||||
import { getTaskTypes } from './get_task_types';
|
||||
import { HttpSetup } from '@kbn/core-http-browser';
|
||||
|
||||
jest.mock('./providers/get_providers');
|
||||
jest.mock('./get_task_types');
|
||||
|
||||
const mockUseKibanaReturnValue = createStartServicesMock();
|
||||
jest.mock('@kbn/triggers-actions-ui-plugin/public/common/lib/kibana', () => ({
|
||||
__esModule: true,
|
||||
useKibana: jest.fn(() => ({
|
||||
services: mockUseKibanaReturnValue,
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('@faker-js/faker', () => ({
|
||||
faker: {
|
||||
string: {
|
||||
alpha: jest.fn().mockReturnValue('123'),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const mockProviders = useProviders as jest.Mock;
|
||||
const mockTaskTypes = getTaskTypes as jest.Mock;
|
||||
|
||||
const providersSchemas = [
|
||||
{
|
||||
provider: 'openai',
|
||||
logo: '', // should be openai logo here, the hardcoded uses assets/images
|
||||
taskTypes: ['completion', 'text_embedding'],
|
||||
configuration: {
|
||||
api_key: {
|
||||
display: DisplayType.TEXTBOX,
|
||||
label: 'API Key',
|
||||
order: 3,
|
||||
required: true,
|
||||
sensitive: true,
|
||||
tooltip: `The OpenAI API authentication key. For more details about generating OpenAI API keys, refer to the https://platform.openai.com/account/api-keys.`,
|
||||
type: FieldType.STRING,
|
||||
validations: [],
|
||||
value: null,
|
||||
ui_restrictions: [],
|
||||
default_value: null,
|
||||
depends_on: [],
|
||||
},
|
||||
model_id: {
|
||||
display: DisplayType.TEXTBOX,
|
||||
label: 'Model ID',
|
||||
order: 2,
|
||||
required: true,
|
||||
sensitive: false,
|
||||
tooltip: 'The name of the model.',
|
||||
type: FieldType.STRING,
|
||||
validations: [],
|
||||
value: null,
|
||||
ui_restrictions: [],
|
||||
default_value: null,
|
||||
depends_on: [],
|
||||
},
|
||||
organization_id: {
|
||||
display: DisplayType.TEXTBOX,
|
||||
label: 'Organization ID',
|
||||
order: 4,
|
||||
required: false,
|
||||
sensitive: false,
|
||||
tooltip: '',
|
||||
type: FieldType.STRING,
|
||||
validations: [],
|
||||
value: null,
|
||||
ui_restrictions: [],
|
||||
default_value: null,
|
||||
depends_on: [],
|
||||
},
|
||||
url: {
|
||||
display: DisplayType.TEXTBOX,
|
||||
label: 'URL',
|
||||
order: 1,
|
||||
required: true,
|
||||
sensitive: false,
|
||||
tooltip: '',
|
||||
type: FieldType.STRING,
|
||||
validations: [],
|
||||
value: null,
|
||||
ui_restrictions: [],
|
||||
default_value: 'https://api.openai.com/v1/chat/completions',
|
||||
depends_on: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
provider: 'googleaistudio',
|
||||
logo: '', // should be googleaistudio logo here, the hardcoded uses assets/images
|
||||
taskTypes: ['completion', 'text_embedding'],
|
||||
configuration: {
|
||||
api_key: {
|
||||
display: DisplayType.TEXTBOX,
|
||||
label: 'API Key',
|
||||
order: 1,
|
||||
required: true,
|
||||
sensitive: true,
|
||||
tooltip: `API Key for the provider you're connecting to`,
|
||||
type: FieldType.STRING,
|
||||
validations: [],
|
||||
value: null,
|
||||
ui_restrictions: [],
|
||||
default_value: null,
|
||||
depends_on: [],
|
||||
},
|
||||
model_id: {
|
||||
display: DisplayType.TEXTBOX,
|
||||
label: 'Model ID',
|
||||
order: 2,
|
||||
required: true,
|
||||
sensitive: false,
|
||||
tooltip: `ID of the LLM you're using`,
|
||||
type: FieldType.STRING,
|
||||
validations: [],
|
||||
value: null,
|
||||
ui_restrictions: [],
|
||||
default_value: null,
|
||||
depends_on: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
const taskTypesSchemas: Record<string, any> = {
|
||||
googleaistudio: [
|
||||
{
|
||||
task_type: 'completion',
|
||||
configuration: {},
|
||||
},
|
||||
{
|
||||
task_type: 'text_embedding',
|
||||
configuration: {},
|
||||
},
|
||||
],
|
||||
openai: [
|
||||
{
|
||||
task_type: 'completion',
|
||||
configuration: {
|
||||
user: {
|
||||
display: DisplayType.TEXTBOX,
|
||||
label: 'User',
|
||||
order: 1,
|
||||
required: false,
|
||||
sensitive: false,
|
||||
tooltip: 'Specifies the user issuing the request.',
|
||||
type: FieldType.STRING,
|
||||
validations: [],
|
||||
value: '',
|
||||
ui_restrictions: [],
|
||||
default_value: null,
|
||||
depends_on: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const openAiConnector = {
|
||||
actionTypeId: '.inference',
|
||||
name: 'AI Connector',
|
||||
id: '123',
|
||||
config: {
|
||||
provider: 'openai',
|
||||
taskType: 'completion',
|
||||
providerConfig: {
|
||||
url: 'https://openaiurl.com',
|
||||
model_id: 'gpt-4o',
|
||||
organization_id: 'test-org',
|
||||
},
|
||||
taskTypeConfig: {
|
||||
user: 'elastic',
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
secretsConfig: {
|
||||
api_key: 'thats-a-nice-looking-key',
|
||||
},
|
||||
},
|
||||
isDeprecated: false,
|
||||
};
|
||||
|
||||
const googleaistudioConnector = {
|
||||
...openAiConnector,
|
||||
config: {
|
||||
...openAiConnector.config,
|
||||
provider: 'googleaistudio',
|
||||
providerConfig: {
|
||||
...openAiConnector.config.providerConfig,
|
||||
model_id: 'somemodel',
|
||||
},
|
||||
taskTypeConfig: {},
|
||||
},
|
||||
secrets: {
|
||||
secretsConfig: {
|
||||
api_key: 'thats-google-key',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('ConnectorFields renders', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockProviders.mockReturnValue({
|
||||
isLoading: false,
|
||||
data: providersSchemas,
|
||||
});
|
||||
mockTaskTypes.mockImplementation(
|
||||
(http: HttpSetup, provider: string) => taskTypesSchemas[provider]
|
||||
);
|
||||
});
|
||||
test('openai provider fields are rendered', async () => {
|
||||
const { getAllByTestId } = render(
|
||||
<ConnectorFormTestProvider connector={openAiConnector}>
|
||||
<ConnectorFields readOnly={false} isEdit={true} registerPreSubmitValidator={() => {}} />
|
||||
</ConnectorFormTestProvider>
|
||||
);
|
||||
expect(getAllByTestId('provider-select')[0]).toBeInTheDocument();
|
||||
expect(getAllByTestId('provider-select')[0]).toHaveValue('OpenAI');
|
||||
|
||||
expect(getAllByTestId('url-input')[0]).toBeInTheDocument();
|
||||
expect(getAllByTestId('url-input')[0]).toHaveValue(openAiConnector.config?.providerConfig?.url);
|
||||
expect(getAllByTestId('taskTypeSelectDisabled')[0]).toBeInTheDocument();
|
||||
expect(getAllByTestId('taskTypeSelectDisabled')[0]).toHaveTextContent('completion');
|
||||
});
|
||||
|
||||
test('googleaistudio provider fields are rendered', async () => {
|
||||
const { getAllByTestId } = render(
|
||||
<ConnectorFormTestProvider connector={googleaistudioConnector}>
|
||||
<ConnectorFields readOnly={false} isEdit={true} registerPreSubmitValidator={() => {}} />
|
||||
</ConnectorFormTestProvider>
|
||||
);
|
||||
expect(getAllByTestId('api_key-password')[0]).toBeInTheDocument();
|
||||
expect(getAllByTestId('api_key-password')[0]).toHaveValue('');
|
||||
expect(getAllByTestId('provider-select')[0]).toBeInTheDocument();
|
||||
expect(getAllByTestId('provider-select')[0]).toHaveValue('Google AI Studio');
|
||||
expect(getAllByTestId('model_id-input')[0]).toBeInTheDocument();
|
||||
expect(getAllByTestId('model_id-input')[0]).toHaveValue(
|
||||
googleaistudioConnector.config?.providerConfig.model_id
|
||||
);
|
||||
});
|
||||
|
||||
describe('Validation', () => {
|
||||
const onSubmit = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.spyOn(global.Math, 'random').mockReturnValue(0.123456789);
|
||||
});
|
||||
|
||||
it('connector validation succeeds when connector config is valid', async () => {
|
||||
const { getByTestId } = render(
|
||||
<ConnectorFormTestProvider connector={openAiConnector} onSubmit={onSubmit}>
|
||||
<ConnectorFields readOnly={false} isEdit={false} registerPreSubmitValidator={() => {}} />
|
||||
</ConnectorFormTestProvider>
|
||||
);
|
||||
|
||||
await userEvent.click(getByTestId('form-test-provide-submit'));
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(onSubmit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(onSubmit).toBeCalledWith({
|
||||
data: {
|
||||
config: {
|
||||
inferenceId: 'openai-completion-4fzzzxjylrx',
|
||||
...openAiConnector.config,
|
||||
},
|
||||
actionTypeId: openAiConnector.actionTypeId,
|
||||
name: openAiConnector.name,
|
||||
id: openAiConnector.id,
|
||||
isDeprecated: openAiConnector.isDeprecated,
|
||||
},
|
||||
isValid: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('validates correctly if the provider config url is empty', async () => {
|
||||
const connector = {
|
||||
...openAiConnector,
|
||||
config: {
|
||||
...openAiConnector.config,
|
||||
providerConfig: {
|
||||
url: '',
|
||||
modelId: 'gpt-4o',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const res = render(
|
||||
<ConnectorFormTestProvider connector={connector} onSubmit={onSubmit}>
|
||||
<ConnectorFields readOnly={false} isEdit={true} registerPreSubmitValidator={() => {}} />
|
||||
</ConnectorFormTestProvider>
|
||||
);
|
||||
|
||||
await 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]> = [
|
||||
['url-input', 'not-valid'],
|
||||
['api_key-password', ''],
|
||||
];
|
||||
it.each(tests)('validates correctly %p', async (field, value) => {
|
||||
const connector = {
|
||||
...openAiConnector,
|
||||
config: {
|
||||
...openAiConnector.config,
|
||||
headers: [],
|
||||
},
|
||||
};
|
||||
|
||||
const res = render(
|
||||
<ConnectorFormTestProvider connector={connector} onSubmit={onSubmit}>
|
||||
<ConnectorFields readOnly={false} isEdit={true} registerPreSubmitValidator={() => {}} />
|
||||
</ConnectorFormTestProvider>
|
||||
);
|
||||
|
||||
await userEvent.type(res.getByTestId(field), `{selectall}{backspace}${value}`, {
|
||||
delay: 10,
|
||||
});
|
||||
|
||||
await userEvent.click(res.getByTestId('form-test-provide-submit'));
|
||||
await waitFor(async () => {
|
||||
expect(onSubmit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith({ data: {}, isValid: false });
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,445 @@
|
|||
/*
|
||||
* 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, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
EuiFormRow,
|
||||
EuiSpacer,
|
||||
EuiInputPopover,
|
||||
EuiFieldText,
|
||||
EuiFieldTextProps,
|
||||
EuiSelectableOption,
|
||||
EuiFormControlLayout,
|
||||
keys,
|
||||
EuiHorizontalRule,
|
||||
} from '@elastic/eui';
|
||||
import {
|
||||
getFieldValidityAndErrorMessage,
|
||||
UseField,
|
||||
useFormContext,
|
||||
useFormData,
|
||||
} from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import {
|
||||
ConnectorFormSchema,
|
||||
type ActionConnectorFieldsProps,
|
||||
} from '@kbn/triggers-actions-ui-plugin/public';
|
||||
import { useKibana } from '@kbn/triggers-actions-ui-plugin/public';
|
||||
|
||||
import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers';
|
||||
import { ServiceProviderKeys } from '../../../common/inference/constants';
|
||||
import { ConnectorConfigurationFormItems } from '../lib/dynamic_config/connector_configuration_form_items';
|
||||
import { getTaskTypes } from './get_task_types';
|
||||
import * as i18n from './translations';
|
||||
import { DEFAULT_TASK_TYPE } from './constants';
|
||||
import { ConfigEntryView } from '../lib/dynamic_config/types';
|
||||
import { SelectableProvider } from './providers/selectable';
|
||||
import { Config, Secrets } from './types';
|
||||
import { generateInferenceEndpointId, getTaskTypeOptions, TaskTypeOption } from './helpers';
|
||||
import { useProviders } from './providers/get_providers';
|
||||
import { SERVICE_PROVIDERS } from './providers/render_service_provider/service_provider';
|
||||
import { AdditionalOptionsConnectorFields } from './additional_options_fields';
|
||||
import {
|
||||
getProviderConfigHiddenField,
|
||||
getProviderSecretsHiddenField,
|
||||
getTaskTypeConfigHiddenField,
|
||||
} from './hidden_fields';
|
||||
|
||||
const InferenceAPIConnectorFields: React.FunctionComponent<ActionConnectorFieldsProps> = ({
|
||||
readOnly,
|
||||
isEdit,
|
||||
}) => {
|
||||
const {
|
||||
http,
|
||||
notifications: { toasts },
|
||||
} = useKibana().services;
|
||||
|
||||
const { updateFieldValues, setFieldValue, validateFields, isSubmitting } = useFormContext();
|
||||
const [{ config, secrets }] = useFormData<ConnectorFormSchema<Config, Secrets>>({
|
||||
watch: [
|
||||
'secrets.providerSecrets',
|
||||
'config.taskType',
|
||||
'config.taskTypeConfig',
|
||||
'config.inferenceId',
|
||||
'config.provider',
|
||||
'config.providerConfig',
|
||||
],
|
||||
});
|
||||
|
||||
const { data: providers, isLoading } = useProviders(http, toasts);
|
||||
|
||||
const [isProviderPopoverOpen, setProviderPopoverOpen] = useState(false);
|
||||
|
||||
const [providerSchema, setProviderSchema] = useState<ConfigEntryView[]>([]);
|
||||
const [optionalProviderFormFields, setOptionalProviderFormFields] = useState<ConfigEntryView[]>(
|
||||
[]
|
||||
);
|
||||
const [requiredProviderFormFields, setRequiredProviderFormFields] = useState<ConfigEntryView[]>(
|
||||
[]
|
||||
);
|
||||
|
||||
const [taskTypeSchema, setTaskTypeSchema] = useState<ConfigEntryView[]>([]);
|
||||
const [taskTypeOptions, setTaskTypeOptions] = useState<TaskTypeOption[]>([]);
|
||||
const [selectedTaskType, setSelectedTaskType] = useState<string>(DEFAULT_TASK_TYPE);
|
||||
const [taskTypeFormFields, setTaskTypeFormFields] = useState<ConfigEntryView[]>([]);
|
||||
|
||||
const handleProviderClosePopover = useCallback(() => {
|
||||
setProviderPopoverOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleProviderPopover = useCallback(() => {
|
||||
setProviderPopoverOpen((isOpen) => !isOpen);
|
||||
}, []);
|
||||
|
||||
const handleProviderKeyboardOpen: EuiFieldTextProps['onKeyDown'] = useCallback((event: any) => {
|
||||
if (event.key === keys.ENTER) {
|
||||
setProviderPopoverOpen(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEdit && config && !config.inferenceId) {
|
||||
generateInferenceEndpointId(config, setFieldValue);
|
||||
}
|
||||
}, [isEdit, setFieldValue, config]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSubmitting) {
|
||||
validateFields(['config.providerConfig']);
|
||||
validateFields(['secrets.providerSecrets']);
|
||||
validateFields(['config.taskTypeConfig']);
|
||||
}
|
||||
}, [isSubmitting, config, validateFields]);
|
||||
|
||||
const onTaskTypeOptionsSelect = useCallback(
|
||||
async (taskType: string, provider?: string) => {
|
||||
// Get task type settings
|
||||
const currentTaskTypes = await getTaskTypes(http, provider ?? config?.provider);
|
||||
const newTaskType = currentTaskTypes?.find((p) => p.task_type === taskType);
|
||||
|
||||
setSelectedTaskType(taskType);
|
||||
generateInferenceEndpointId(config, setFieldValue);
|
||||
|
||||
// transform the schema
|
||||
const newTaskTypeSchema = Object.keys(newTaskType?.configuration ?? {}).map((k) => ({
|
||||
key: k,
|
||||
isValid: true,
|
||||
...newTaskType?.configuration[k],
|
||||
})) as ConfigEntryView[];
|
||||
setTaskTypeSchema(newTaskTypeSchema);
|
||||
|
||||
const configDefaults = Object.keys(newTaskType?.configuration ?? {}).reduce(
|
||||
(res: Record<string, unknown>, k) => {
|
||||
if (newTaskType?.configuration[k] && !!newTaskType?.configuration[k].default_value) {
|
||||
res[k] = newTaskType.configuration[k].default_value;
|
||||
} else {
|
||||
res[k] = null;
|
||||
}
|
||||
return res;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
updateFieldValues({
|
||||
config: {
|
||||
taskType,
|
||||
taskTypeConfig: configDefaults,
|
||||
},
|
||||
});
|
||||
},
|
||||
[config, http, setFieldValue, updateFieldValues]
|
||||
);
|
||||
|
||||
const onProviderChange = useCallback(
|
||||
async (provider?: string) => {
|
||||
const newProvider = providers?.find((p) => p.provider === provider);
|
||||
|
||||
// Update task types list available for the selected provider
|
||||
const providerTaskTypes = newProvider?.taskTypes ?? [];
|
||||
setTaskTypeOptions(getTaskTypeOptions(providerTaskTypes));
|
||||
if (providerTaskTypes.length > 0) {
|
||||
await onTaskTypeOptionsSelect(providerTaskTypes[0], provider);
|
||||
}
|
||||
|
||||
// Update connector providerSchema
|
||||
const newProviderSchema = Object.keys(newProvider?.configuration ?? {}).map((k) => ({
|
||||
key: k,
|
||||
isValid: true,
|
||||
...newProvider?.configuration[k],
|
||||
})) as ConfigEntryView[];
|
||||
|
||||
setProviderSchema(newProviderSchema);
|
||||
|
||||
const defaultProviderConfig: Record<string, unknown> = {};
|
||||
const defaultProviderSecrets: Record<string, unknown> = {};
|
||||
|
||||
Object.keys(newProvider?.configuration ?? {}).forEach((k) => {
|
||||
if (!newProvider?.configuration[k].sensitive) {
|
||||
if (newProvider?.configuration[k] && !!newProvider?.configuration[k].default_value) {
|
||||
defaultProviderConfig[k] = newProvider.configuration[k].default_value;
|
||||
} else {
|
||||
defaultProviderConfig[k] = null;
|
||||
}
|
||||
} else {
|
||||
defaultProviderSecrets[k] = null;
|
||||
}
|
||||
});
|
||||
|
||||
updateFieldValues({
|
||||
config: {
|
||||
provider: newProvider?.provider,
|
||||
providerConfig: defaultProviderConfig,
|
||||
},
|
||||
secrets: {
|
||||
providerSecrets: defaultProviderSecrets,
|
||||
},
|
||||
});
|
||||
},
|
||||
[onTaskTypeOptionsSelect, providers, updateFieldValues]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const getTaskTypeSchema = async () => {
|
||||
const currentTaskTypes = await getTaskTypes(http, config?.provider ?? '');
|
||||
const newTaskType = currentTaskTypes?.find((p) => p.task_type === config?.taskType);
|
||||
|
||||
// transform the schema
|
||||
const newTaskTypeSchema = Object.keys(newTaskType?.configuration ?? {}).map((k) => ({
|
||||
key: k,
|
||||
isValid: true,
|
||||
...newTaskType?.configuration[k],
|
||||
})) as ConfigEntryView[];
|
||||
|
||||
setTaskTypeSchema(newTaskTypeSchema);
|
||||
};
|
||||
|
||||
if (config?.provider && isEdit) {
|
||||
const newProvider = providers?.find((p) => p.provider === config.provider);
|
||||
// Update connector providerSchema
|
||||
const newProviderSchema = Object.keys(newProvider?.configuration ?? {}).map((k) => ({
|
||||
key: k,
|
||||
isValid: true,
|
||||
...newProvider?.configuration[k],
|
||||
})) as ConfigEntryView[];
|
||||
|
||||
setProviderSchema(newProviderSchema);
|
||||
|
||||
getTaskTypeSchema();
|
||||
}
|
||||
}, [config?.provider, config?.taskType, http, isEdit, providers]);
|
||||
|
||||
useEffect(() => {
|
||||
// Set values from the provider secrets and config to the schema
|
||||
const existingConfiguration = providerSchema
|
||||
? providerSchema.map((item: ConfigEntryView) => {
|
||||
const itemValue = item;
|
||||
itemValue.isValid = true;
|
||||
if (item.sensitive && secrets?.providerSecrets) {
|
||||
itemValue.value = secrets?.providerSecrets[item.key] as any;
|
||||
} else if (config?.providerConfig) {
|
||||
itemValue.value = config?.providerConfig[item.key] as any;
|
||||
}
|
||||
return itemValue;
|
||||
})
|
||||
: [];
|
||||
|
||||
existingConfiguration.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
setOptionalProviderFormFields(existingConfiguration.filter((p) => !p.required && !p.sensitive));
|
||||
setRequiredProviderFormFields(existingConfiguration.filter((p) => p.required || p.sensitive));
|
||||
}, [config?.providerConfig, providerSchema, secrets]);
|
||||
|
||||
useEffect(() => {
|
||||
// Set values from the task type config to the schema
|
||||
const existingTaskTypeConfiguration = taskTypeSchema
|
||||
? taskTypeSchema.map((item: ConfigEntryView) => {
|
||||
const itemValue = item;
|
||||
itemValue.isValid = true;
|
||||
if (config?.taskTypeConfig) {
|
||||
itemValue.value = config?.taskTypeConfig[item.key] as any;
|
||||
}
|
||||
return itemValue;
|
||||
})
|
||||
: [];
|
||||
existingTaskTypeConfiguration.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
setTaskTypeFormFields(existingTaskTypeConfiguration);
|
||||
}, [config, taskTypeSchema]);
|
||||
|
||||
const getProviderOptions = useCallback(() => {
|
||||
return providers?.map((p) => ({
|
||||
label: p.provider,
|
||||
key: p.provider,
|
||||
})) as EuiSelectableOption[];
|
||||
}, [providers]);
|
||||
|
||||
const onSetProviderConfigEntry = useCallback(
|
||||
async (key: string, value: unknown) => {
|
||||
const entry: ConfigEntryView | undefined = providerSchema.find(
|
||||
(p: ConfigEntryView) => p.key === key
|
||||
);
|
||||
if (entry) {
|
||||
if (entry.sensitive) {
|
||||
if (!secrets.providerSecrets) {
|
||||
secrets.providerSecrets = {};
|
||||
}
|
||||
const newSecrets = { ...secrets.providerSecrets };
|
||||
newSecrets[key] = value;
|
||||
setFieldValue('secrets.providerSecrets', newSecrets);
|
||||
await validateFields(['secrets.providerSecrets']);
|
||||
} else {
|
||||
if (!config.providerConfig) {
|
||||
config.providerConfig = {};
|
||||
}
|
||||
const newConfig = { ...config.providerConfig };
|
||||
newConfig[key] = value;
|
||||
setFieldValue('config.providerConfig', newConfig);
|
||||
await validateFields(['config.providerConfig']);
|
||||
}
|
||||
}
|
||||
},
|
||||
[config, providerSchema, secrets, setFieldValue, validateFields]
|
||||
);
|
||||
|
||||
const onClearProvider = useCallback(() => {
|
||||
onProviderChange();
|
||||
setFieldValue('config.taskType', '');
|
||||
setFieldValue('config.provider', '');
|
||||
}, [onProviderChange, setFieldValue]);
|
||||
|
||||
const providerSuperSelect = useCallback(
|
||||
(isInvalid: boolean) => (
|
||||
<EuiFormControlLayout
|
||||
clear={isEdit || readOnly ? undefined : { onClick: onClearProvider }}
|
||||
isDropdown
|
||||
isDisabled={isEdit || readOnly}
|
||||
isInvalid={isInvalid}
|
||||
fullWidth
|
||||
icon={
|
||||
!config?.provider
|
||||
? { type: 'sparkles', side: 'left' }
|
||||
: SERVICE_PROVIDERS[config?.provider as ServiceProviderKeys].icon
|
||||
}
|
||||
>
|
||||
<EuiFieldText
|
||||
onClick={handleProviderPopover}
|
||||
data-test-subj="provider-select"
|
||||
isInvalid={isInvalid}
|
||||
disabled={isEdit || readOnly}
|
||||
onKeyDown={handleProviderKeyboardOpen}
|
||||
value={
|
||||
config?.provider ? SERVICE_PROVIDERS[config?.provider as ServiceProviderKeys].name : ''
|
||||
}
|
||||
fullWidth
|
||||
placeholder={i18n.SELECT_PROVIDER}
|
||||
icon={{ type: 'arrowDown', side: 'right' }}
|
||||
aria-expanded={isProviderPopoverOpen}
|
||||
role="combobox"
|
||||
/>
|
||||
</EuiFormControlLayout>
|
||||
),
|
||||
[
|
||||
isEdit,
|
||||
readOnly,
|
||||
onClearProvider,
|
||||
config?.provider,
|
||||
handleProviderPopover,
|
||||
handleProviderKeyboardOpen,
|
||||
isProviderPopoverOpen,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<UseField
|
||||
path="config.provider"
|
||||
config={{
|
||||
validations: [
|
||||
{
|
||||
validator: fieldValidators.emptyField(i18n.PROVIDER_REQUIRED),
|
||||
isBlocking: true,
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
{(field) => {
|
||||
const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field);
|
||||
const selectInput = providerSuperSelect(isInvalid);
|
||||
return (
|
||||
<EuiFormRow
|
||||
id="providerSelectBox"
|
||||
fullWidth
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="xpack.stackConnectors.components.inference.providerLabel"
|
||||
defaultMessage="Service"
|
||||
/>
|
||||
}
|
||||
isInvalid={isInvalid}
|
||||
error={errorMessage}
|
||||
>
|
||||
<EuiInputPopover
|
||||
id={'popoverId'}
|
||||
fullWidth
|
||||
input={selectInput}
|
||||
isOpen={isProviderPopoverOpen}
|
||||
closePopover={handleProviderClosePopover}
|
||||
className="rightArrowIcon"
|
||||
>
|
||||
<SelectableProvider
|
||||
isLoading={isLoading}
|
||||
getSelectableOptions={getProviderOptions}
|
||||
onClosePopover={handleProviderClosePopover}
|
||||
onProviderChange={onProviderChange}
|
||||
/>
|
||||
</EuiInputPopover>
|
||||
</EuiFormRow>
|
||||
);
|
||||
}}
|
||||
</UseField>
|
||||
{config?.provider ? (
|
||||
<>
|
||||
<EuiSpacer size="m" />
|
||||
<ConnectorConfigurationFormItems
|
||||
itemsGrow={false}
|
||||
isLoading={false}
|
||||
direction="column"
|
||||
items={requiredProviderFormFields}
|
||||
setConfigEntry={onSetProviderConfigEntry}
|
||||
/>
|
||||
<EuiSpacer size="m" />
|
||||
<AdditionalOptionsConnectorFields
|
||||
config={config}
|
||||
readOnly={readOnly}
|
||||
isEdit={isEdit}
|
||||
optionalProviderFormFields={optionalProviderFormFields}
|
||||
onSetProviderConfigEntry={onSetProviderConfigEntry}
|
||||
onTaskTypeOptionsSelect={onTaskTypeOptionsSelect}
|
||||
taskTypeFormFields={taskTypeFormFields}
|
||||
taskTypeSchema={taskTypeSchema}
|
||||
taskTypeOptions={taskTypeOptions}
|
||||
selectedTaskType={selectedTaskType}
|
||||
/>
|
||||
<EuiSpacer size="l" />
|
||||
<EuiHorizontalRule />
|
||||
{getProviderSecretsHiddenField(
|
||||
providerSchema,
|
||||
setRequiredProviderFormFields,
|
||||
isSubmitting
|
||||
)}
|
||||
{getProviderConfigHiddenField(
|
||||
providerSchema,
|
||||
setRequiredProviderFormFields,
|
||||
isSubmitting
|
||||
)}
|
||||
{getTaskTypeConfigHiddenField(taskTypeSchema, setTaskTypeFormFields, isSubmitting)}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export { InferenceAPIConnectorFields as default };
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* 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 { SUB_ACTION } from '../../../common/inference/constants';
|
||||
|
||||
export const DEFAULT_CHAT_COMPLETE_BODY = {
|
||||
input: 'What is Elastic?',
|
||||
};
|
||||
|
||||
export const DEFAULT_RERANK_BODY = {
|
||||
input: ['luke', 'like', 'leia', 'chewy', 'r2d2', 'star', 'wars'],
|
||||
query: 'star wars main character',
|
||||
};
|
||||
|
||||
export const DEFAULT_SPARSE_EMBEDDING_BODY = {
|
||||
input: 'The sky above the port was the color of television tuned to a dead channel.',
|
||||
};
|
||||
|
||||
export const DEFAULT_TEXT_EMBEDDING_BODY = {
|
||||
input: 'The sky above the port was the color of television tuned to a dead channel.',
|
||||
inputType: 'ingest',
|
||||
};
|
||||
|
||||
export const DEFAULTS_BY_TASK_TYPE: Record<string, unknown> = {
|
||||
[SUB_ACTION.COMPLETION]: DEFAULT_CHAT_COMPLETE_BODY,
|
||||
[SUB_ACTION.RERANK]: DEFAULT_RERANK_BODY,
|
||||
[SUB_ACTION.SPARSE_EMBEDDING]: DEFAULT_SPARSE_EMBEDDING_BODY,
|
||||
[SUB_ACTION.TEXT_EMBEDDING]: DEFAULT_TEXT_EMBEDDING_BODY,
|
||||
};
|
||||
|
||||
export const DEFAULT_TASK_TYPE = 'completion';
|
||||
|
||||
export const DEFAULT_PROVIDER = 'elasticsearch';
|
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* 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 { httpServiceMock } from '@kbn/core/public/mocks';
|
||||
import { DisplayType, FieldType } from '../lib/dynamic_config/types';
|
||||
import { getTaskTypes } from './get_task_types';
|
||||
|
||||
const http = httpServiceMock.createStartContract();
|
||||
|
||||
beforeEach(() => jest.resetAllMocks());
|
||||
|
||||
describe.skip('getTaskTypes', () => {
|
||||
test('should call get inference task types api', async () => {
|
||||
const apiResponse = {
|
||||
amazonbedrock: [
|
||||
{
|
||||
task_type: 'completion',
|
||||
configuration: {
|
||||
max_new_tokens: {
|
||||
display: DisplayType.NUMERIC,
|
||||
label: 'Max new tokens',
|
||||
order: 1,
|
||||
required: false,
|
||||
sensitive: false,
|
||||
tooltip: 'Sets the maximum number for the output tokens to be generated.',
|
||||
type: FieldType.INTEGER,
|
||||
validations: [],
|
||||
value: null,
|
||||
ui_restrictions: [],
|
||||
default_value: null,
|
||||
depends_on: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
task_type: 'text_embedding',
|
||||
configuration: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
http.get.mockResolvedValueOnce(apiResponse);
|
||||
|
||||
const result = await getTaskTypes(http, 'amazonbedrock');
|
||||
expect(result).toEqual(apiResponse);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,606 @@
|
|||
/*
|
||||
* 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 type { HttpSetup } from '@kbn/core-http-browser';
|
||||
import { DisplayType, FieldType } from '../lib/dynamic_config/types';
|
||||
import { FieldsConfiguration } from './types';
|
||||
|
||||
export interface InferenceTaskType {
|
||||
task_type: string;
|
||||
configuration: FieldsConfiguration;
|
||||
}
|
||||
|
||||
// this http param is for the future migrating to real API
|
||||
export const getTaskTypes = (http: HttpSetup, provider: string): Promise<InferenceTaskType[]> => {
|
||||
const providersTaskTypes: Record<string, InferenceTaskType[]> = {
|
||||
openai: [
|
||||
{
|
||||
task_type: 'completion',
|
||||
configuration: {
|
||||
user: {
|
||||
display: DisplayType.TEXTBOX,
|
||||
label: 'User',
|
||||
order: 1,
|
||||
required: false,
|
||||
sensitive: false,
|
||||
tooltip: 'Specifies the user issuing the request.',
|
||||
type: FieldType.STRING,
|
||||
validations: [],
|
||||
value: '',
|
||||
ui_restrictions: [],
|
||||
default_value: null,
|
||||
depends_on: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
task_type: 'text_embedding',
|
||||
configuration: {
|
||||
user: {
|
||||
display: DisplayType.TEXTBOX,
|
||||
label: 'User',
|
||||
order: 1,
|
||||
required: false,
|
||||
sensitive: false,
|
||||
tooltip: 'Specifies the user issuing the request.',
|
||||
type: FieldType.STRING,
|
||||
validations: [],
|
||||
value: '',
|
||||
ui_restrictions: [],
|
||||
default_value: null,
|
||||
depends_on: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
mistral: [
|
||||
{
|
||||
task_type: 'text_embedding',
|
||||
configuration: {},
|
||||
},
|
||||
],
|
||||
hugging_face: [
|
||||
{
|
||||
task_type: 'text_embedding',
|
||||
configuration: {},
|
||||
},
|
||||
],
|
||||
googlevertexai: [
|
||||
{
|
||||
task_type: 'text_embedding',
|
||||
configuration: {
|
||||
auto_truncate: {
|
||||
display: DisplayType.TOGGLE,
|
||||
label: 'Auto truncate',
|
||||
order: 1,
|
||||
required: false,
|
||||
sensitive: false,
|
||||
tooltip:
|
||||
'Specifies if the API truncates inputs longer than the maximum token length automatically.',
|
||||
type: FieldType.BOOLEAN,
|
||||
validations: [],
|
||||
value: false,
|
||||
ui_restrictions: [],
|
||||
default_value: null,
|
||||
depends_on: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
task_type: 'rerank',
|
||||
configuration: {
|
||||
top_n: {
|
||||
display: DisplayType.TOGGLE,
|
||||
label: 'Top N',
|
||||
order: 1,
|
||||
required: false,
|
||||
sensitive: false,
|
||||
tooltip: 'Specifies the number of the top n documents, which should be returned.',
|
||||
type: FieldType.BOOLEAN,
|
||||
validations: [],
|
||||
value: false,
|
||||
ui_restrictions: [],
|
||||
default_value: null,
|
||||
depends_on: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
googleaistudio: [
|
||||
{
|
||||
task_type: 'completion',
|
||||
configuration: {},
|
||||
},
|
||||
{
|
||||
task_type: 'text_embedding',
|
||||
configuration: {},
|
||||
},
|
||||
],
|
||||
elasticsearch: [
|
||||
{
|
||||
task_type: 'rerank',
|
||||
configuration: {
|
||||
return_documents: {
|
||||
display: DisplayType.TOGGLE,
|
||||
label: 'Return documents',
|
||||
options: [],
|
||||
order: 1,
|
||||
required: false,
|
||||
sensitive: false,
|
||||
tooltip: 'Returns the document instead of only the index.',
|
||||
type: FieldType.BOOLEAN,
|
||||
validations: [],
|
||||
value: true,
|
||||
ui_restrictions: [],
|
||||
default_value: null,
|
||||
depends_on: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
task_type: 'sparse_embedding',
|
||||
configuration: {},
|
||||
},
|
||||
{
|
||||
task_type: 'text_embedding',
|
||||
configuration: {},
|
||||
},
|
||||
],
|
||||
cohere: [
|
||||
{
|
||||
task_type: 'completion',
|
||||
configuration: {},
|
||||
},
|
||||
{
|
||||
task_type: 'text_embedding',
|
||||
configuration: {
|
||||
input_type: {
|
||||
display: DisplayType.DROPDOWN,
|
||||
label: 'Input type',
|
||||
order: 1,
|
||||
required: false,
|
||||
sensitive: false,
|
||||
tooltip: 'Specifies the type of input passed to the model.',
|
||||
type: FieldType.STRING,
|
||||
validations: [],
|
||||
options: [
|
||||
{
|
||||
label: 'classification',
|
||||
value: 'classification',
|
||||
},
|
||||
{
|
||||
label: 'clusterning',
|
||||
value: 'clusterning',
|
||||
},
|
||||
{
|
||||
label: 'ingest',
|
||||
value: 'ingest',
|
||||
},
|
||||
{
|
||||
label: 'search',
|
||||
value: 'search',
|
||||
},
|
||||
],
|
||||
value: '',
|
||||
ui_restrictions: [],
|
||||
default_value: null,
|
||||
depends_on: [],
|
||||
},
|
||||
truncate: {
|
||||
display: DisplayType.DROPDOWN,
|
||||
options: [
|
||||
{
|
||||
label: 'NONE',
|
||||
value: 'NONE',
|
||||
},
|
||||
{
|
||||
label: 'START',
|
||||
value: 'START',
|
||||
},
|
||||
{
|
||||
label: 'END',
|
||||
value: 'END',
|
||||
},
|
||||
],
|
||||
label: 'Truncate',
|
||||
order: 2,
|
||||
required: false,
|
||||
sensitive: false,
|
||||
tooltip: 'Specifies how the API handles inputs longer than the maximum token length.',
|
||||
type: FieldType.STRING,
|
||||
validations: [],
|
||||
value: '',
|
||||
ui_restrictions: [],
|
||||
default_value: null,
|
||||
depends_on: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
task_type: 'rerank',
|
||||
configuration: {
|
||||
return_documents: {
|
||||
display: DisplayType.TOGGLE,
|
||||
label: 'Return documents',
|
||||
order: 1,
|
||||
required: false,
|
||||
sensitive: false,
|
||||
tooltip: 'Specify whether to return doc text within the results.',
|
||||
type: FieldType.BOOLEAN,
|
||||
validations: [],
|
||||
value: false,
|
||||
ui_restrictions: [],
|
||||
default_value: null,
|
||||
depends_on: [],
|
||||
},
|
||||
top_n: {
|
||||
display: DisplayType.NUMERIC,
|
||||
label: 'Top N',
|
||||
order: 1,
|
||||
required: false,
|
||||
sensitive: false,
|
||||
tooltip:
|
||||
'The number of most relevant documents to return, defaults to the number of the documents.',
|
||||
type: FieldType.INTEGER,
|
||||
validations: [],
|
||||
value: false,
|
||||
ui_restrictions: [],
|
||||
default_value: null,
|
||||
depends_on: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
azureopenai: [
|
||||
{
|
||||
task_type: 'completion',
|
||||
configuration: {
|
||||
user: {
|
||||
display: DisplayType.TEXTBOX,
|
||||
label: 'User',
|
||||
order: 1,
|
||||
required: false,
|
||||
sensitive: false,
|
||||
tooltip: 'Specifies the user issuing the request.',
|
||||
type: FieldType.STRING,
|
||||
validations: [],
|
||||
value: '',
|
||||
ui_restrictions: [],
|
||||
default_value: null,
|
||||
depends_on: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
task_type: 'text_embedding',
|
||||
configuration: {
|
||||
user: {
|
||||
display: DisplayType.TEXTBOX,
|
||||
label: 'User',
|
||||
order: 1,
|
||||
required: false,
|
||||
sensitive: false,
|
||||
tooltip: 'Specifies the user issuing the request.',
|
||||
type: FieldType.STRING,
|
||||
validations: [],
|
||||
value: '',
|
||||
ui_restrictions: [],
|
||||
default_value: null,
|
||||
depends_on: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
azureaistudio: [
|
||||
{
|
||||
task_type: 'completion',
|
||||
configuration: {
|
||||
user: {
|
||||
display: DisplayType.TEXTBOX,
|
||||
label: 'User',
|
||||
order: 1,
|
||||
required: false,
|
||||
sensitive: false,
|
||||
tooltip: 'Specifies the user issuing the request.',
|
||||
type: FieldType.STRING,
|
||||
validations: [],
|
||||
value: '',
|
||||
ui_restrictions: [],
|
||||
default_value: null,
|
||||
depends_on: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
task_type: 'text_embedding',
|
||||
configuration: {
|
||||
do_sample: {
|
||||
display: DisplayType.NUMERIC,
|
||||
label: 'Do sample',
|
||||
order: 1,
|
||||
required: false,
|
||||
sensitive: false,
|
||||
tooltip: 'Instructs the inference process to perform sampling or not.',
|
||||
type: FieldType.INTEGER,
|
||||
validations: [],
|
||||
value: null,
|
||||
ui_restrictions: [],
|
||||
default_value: null,
|
||||
depends_on: [],
|
||||
},
|
||||
max_new_tokens: {
|
||||
display: DisplayType.NUMERIC,
|
||||
label: 'Max new tokens',
|
||||
order: 1,
|
||||
required: false,
|
||||
sensitive: false,
|
||||
tooltip: 'Provides a hint for the maximum number of output tokens to be generated.',
|
||||
type: FieldType.INTEGER,
|
||||
validations: [],
|
||||
value: null,
|
||||
ui_restrictions: [],
|
||||
default_value: null,
|
||||
depends_on: [],
|
||||
},
|
||||
temperature: {
|
||||
display: DisplayType.NUMERIC,
|
||||
label: 'Temperature',
|
||||
order: 1,
|
||||
required: false,
|
||||
sensitive: false,
|
||||
tooltip: 'A number in the range of 0.0 to 2.0 that specifies the sampling temperature.',
|
||||
type: FieldType.INTEGER,
|
||||
validations: [],
|
||||
value: null,
|
||||
ui_restrictions: [],
|
||||
default_value: null,
|
||||
depends_on: [],
|
||||
},
|
||||
top_p: {
|
||||
display: DisplayType.NUMERIC,
|
||||
label: 'Top P',
|
||||
order: 1,
|
||||
required: false,
|
||||
sensitive: false,
|
||||
tooltip:
|
||||
'A number in the range of 0.0 to 2.0 that is an alternative value to temperature. Should not be used if temperature is specified.',
|
||||
type: FieldType.INTEGER,
|
||||
validations: [],
|
||||
value: null,
|
||||
ui_restrictions: [],
|
||||
default_value: null,
|
||||
depends_on: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
amazonbedrock: [
|
||||
{
|
||||
task_type: 'completion',
|
||||
configuration: {
|
||||
max_new_tokens: {
|
||||
display: DisplayType.NUMERIC,
|
||||
label: 'Max new tokens',
|
||||
order: 1,
|
||||
required: false,
|
||||
sensitive: false,
|
||||
tooltip: 'Sets the maximum number for the output tokens to be generated.',
|
||||
type: FieldType.INTEGER,
|
||||
validations: [],
|
||||
value: null,
|
||||
ui_restrictions: [],
|
||||
default_value: null,
|
||||
depends_on: [],
|
||||
},
|
||||
temperature: {
|
||||
display: DisplayType.NUMERIC,
|
||||
label: 'Temperature',
|
||||
order: 1,
|
||||
required: false,
|
||||
sensitive: false,
|
||||
tooltip:
|
||||
'A number between 0.0 and 1.0 that controls the apparent creativity of the results.',
|
||||
type: FieldType.INTEGER,
|
||||
validations: [],
|
||||
value: null,
|
||||
ui_restrictions: [],
|
||||
default_value: null,
|
||||
depends_on: [],
|
||||
},
|
||||
top_p: {
|
||||
display: DisplayType.NUMERIC,
|
||||
label: 'Top P',
|
||||
order: 1,
|
||||
required: false,
|
||||
sensitive: false,
|
||||
tooltip:
|
||||
'Alternative to temperature. A number in the range of 0.0 to 1.0, to eliminate low-probability tokens.',
|
||||
type: FieldType.INTEGER,
|
||||
validations: [],
|
||||
value: null,
|
||||
ui_restrictions: [],
|
||||
default_value: null,
|
||||
depends_on: [],
|
||||
},
|
||||
top_k: {
|
||||
display: DisplayType.NUMERIC,
|
||||
label: 'Top K',
|
||||
order: 1,
|
||||
required: false,
|
||||
sensitive: false,
|
||||
tooltip:
|
||||
'Only available for anthropic, cohere, and mistral providers. Alternative to temperature.',
|
||||
type: FieldType.INTEGER,
|
||||
validations: [],
|
||||
value: null,
|
||||
ui_restrictions: [],
|
||||
default_value: null,
|
||||
depends_on: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
task_type: 'text_embedding',
|
||||
configuration: {},
|
||||
},
|
||||
],
|
||||
anthropic: [
|
||||
{
|
||||
task_type: 'completion',
|
||||
configuration: {
|
||||
max_tokens: {
|
||||
display: DisplayType.NUMERIC,
|
||||
label: 'Max tokens',
|
||||
order: 1,
|
||||
required: true,
|
||||
sensitive: false,
|
||||
tooltip: 'The maximum number of tokens to generate before stopping.',
|
||||
type: FieldType.INTEGER,
|
||||
validations: [],
|
||||
value: null,
|
||||
ui_restrictions: [],
|
||||
default_value: null,
|
||||
depends_on: [],
|
||||
},
|
||||
temperature: {
|
||||
display: DisplayType.TEXTBOX,
|
||||
label: 'Temperature',
|
||||
order: 2,
|
||||
required: false,
|
||||
sensitive: false,
|
||||
tooltip: 'The amount of randomness injected into the response.',
|
||||
type: FieldType.STRING,
|
||||
validations: [],
|
||||
value: null,
|
||||
ui_restrictions: [],
|
||||
default_value: null,
|
||||
depends_on: [],
|
||||
},
|
||||
top_p: {
|
||||
display: DisplayType.NUMERIC,
|
||||
label: 'Top P',
|
||||
order: 4,
|
||||
required: false,
|
||||
sensitive: false,
|
||||
tooltip: 'Specifies to use Anthropic’s nucleus sampling.',
|
||||
type: FieldType.INTEGER,
|
||||
validations: [],
|
||||
value: null,
|
||||
ui_restrictions: [],
|
||||
default_value: null,
|
||||
depends_on: [],
|
||||
},
|
||||
top_k: {
|
||||
display: DisplayType.NUMERIC,
|
||||
label: 'Top K',
|
||||
order: 3,
|
||||
required: false,
|
||||
sensitive: false,
|
||||
tooltip: 'Specifies to only sample from the top K options for each subsequent token.',
|
||||
type: FieldType.INTEGER,
|
||||
validations: [],
|
||||
value: null,
|
||||
ui_restrictions: [],
|
||||
default_value: null,
|
||||
depends_on: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
'alibabacloud-ai-search': [
|
||||
{
|
||||
task_type: 'text_embedding',
|
||||
configuration: {
|
||||
input_type: {
|
||||
display: DisplayType.DROPDOWN,
|
||||
label: 'Input type',
|
||||
order: 1,
|
||||
required: false,
|
||||
sensitive: false,
|
||||
tooltip: 'Specifies the type of input passed to the model.',
|
||||
type: FieldType.STRING,
|
||||
validations: [],
|
||||
options: [
|
||||
{
|
||||
label: 'ingest',
|
||||
value: 'ingest',
|
||||
},
|
||||
{
|
||||
label: 'search',
|
||||
value: 'search',
|
||||
},
|
||||
],
|
||||
value: '',
|
||||
ui_restrictions: [],
|
||||
default_value: null,
|
||||
depends_on: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
task_type: 'sparse_embedding',
|
||||
configuration: {
|
||||
input_type: {
|
||||
display: DisplayType.DROPDOWN,
|
||||
label: 'Input type',
|
||||
order: 1,
|
||||
required: false,
|
||||
sensitive: false,
|
||||
tooltip: 'Specifies the type of input passed to the model.',
|
||||
type: FieldType.STRING,
|
||||
validations: [],
|
||||
options: [
|
||||
{
|
||||
label: 'ingest',
|
||||
value: 'ingest',
|
||||
},
|
||||
{
|
||||
label: 'search',
|
||||
value: 'search',
|
||||
},
|
||||
],
|
||||
value: '',
|
||||
ui_restrictions: [],
|
||||
default_value: null,
|
||||
depends_on: [],
|
||||
},
|
||||
return_token: {
|
||||
display: DisplayType.TOGGLE,
|
||||
label: 'Return token',
|
||||
options: [],
|
||||
order: 1,
|
||||
required: false,
|
||||
sensitive: false,
|
||||
tooltip:
|
||||
'If `true`, the token name will be returned in the response. Defaults to `false` which means only the token ID will be returned in the response.',
|
||||
type: FieldType.BOOLEAN,
|
||||
validations: [],
|
||||
value: true,
|
||||
ui_restrictions: [],
|
||||
default_value: null,
|
||||
depends_on: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
task_type: 'completion',
|
||||
configuration: {},
|
||||
},
|
||||
{
|
||||
task_type: 'rerank',
|
||||
configuration: {},
|
||||
},
|
||||
],
|
||||
watsonxai: [
|
||||
{
|
||||
task_type: 'text_embedding',
|
||||
configuration: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
return Promise.resolve(providersTaskTypes[provider]);
|
||||
};
|
|
@ -0,0 +1,85 @@
|
|||
/*
|
||||
* 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 { isEmpty } from 'lodash/fp';
|
||||
import { ValidationFunc } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
|
||||
import { ConfigEntryView } from '../lib/dynamic_config/types';
|
||||
import { Config } from './types';
|
||||
import * as i18n from './translations';
|
||||
|
||||
export interface TaskTypeOption {
|
||||
id: string;
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const getTaskTypeOptions = (taskTypes: string[]): TaskTypeOption[] =>
|
||||
taskTypes.map((taskType) => ({
|
||||
id: taskType,
|
||||
label: taskType,
|
||||
value: taskType,
|
||||
}));
|
||||
|
||||
export const generateInferenceEndpointId = (
|
||||
config: Config,
|
||||
setFieldValue: (fieldName: string, value: unknown) => void
|
||||
) => {
|
||||
const taskTypeSuffix = config.taskType ? `${config.taskType}-` : '';
|
||||
const inferenceEndpointId = `${config.provider}-${taskTypeSuffix}${Math.random()
|
||||
.toString(36)
|
||||
.slice(2)}`;
|
||||
config.inferenceId = inferenceEndpointId;
|
||||
setFieldValue('config.inferenceId', inferenceEndpointId);
|
||||
};
|
||||
|
||||
export const getNonEmptyValidator = (
|
||||
schema: ConfigEntryView[],
|
||||
validationEventHandler: (fieldsWithErrors: ConfigEntryView[]) => void,
|
||||
isSubmitting: boolean = false,
|
||||
isSecrets: boolean = false
|
||||
) => {
|
||||
return (...args: Parameters<ValidationFunc>): ReturnType<ValidationFunc> => {
|
||||
const [{ value, path }] = args;
|
||||
const newSchema: ConfigEntryView[] = [];
|
||||
|
||||
const configData = (value ?? {}) as Record<string, unknown>;
|
||||
let hasErrors = false;
|
||||
if (schema) {
|
||||
schema
|
||||
.filter((f: ConfigEntryView) => f.required)
|
||||
.forEach((field: ConfigEntryView) => {
|
||||
// validate if submitting or on field edit - value is not default to null
|
||||
if (configData[field.key] !== null || isSubmitting) {
|
||||
// validate secrets fields separately from regular
|
||||
if (isSecrets ? field.sensitive : !field.sensitive) {
|
||||
if (
|
||||
!configData[field.key] ||
|
||||
(typeof configData[field.key] === 'string' && isEmpty(configData[field.key]))
|
||||
) {
|
||||
field.validationErrors = [i18n.getRequiredMessage(field.label)];
|
||||
field.isValid = false;
|
||||
hasErrors = true;
|
||||
} else {
|
||||
field.validationErrors = [];
|
||||
field.isValid = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
newSchema.push(field);
|
||||
});
|
||||
|
||||
validationEventHandler(newSchema.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)));
|
||||
if (hasErrors) {
|
||||
return {
|
||||
code: 'ERR_FIELD_MISSING',
|
||||
path,
|
||||
message: i18n.getRequiredMessage('Action'),
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
|
@ -0,0 +1,88 @@
|
|||
/*
|
||||
* 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 { HiddenField } from '@kbn/es-ui-shared-plugin/static/forms/components';
|
||||
import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
|
||||
import { getNonEmptyValidator } from './helpers';
|
||||
import { ConfigEntryView } from '../lib/dynamic_config/types';
|
||||
|
||||
export const getProviderSecretsHiddenField = (
|
||||
providerSchema: ConfigEntryView[],
|
||||
setRequiredProviderFormFields: React.Dispatch<React.SetStateAction<ConfigEntryView[]>>,
|
||||
isSubmitting: boolean
|
||||
) => (
|
||||
<UseField
|
||||
path="secrets.providerSecrets"
|
||||
component={HiddenField}
|
||||
config={{
|
||||
validations: [
|
||||
{
|
||||
validator: getNonEmptyValidator(
|
||||
providerSchema,
|
||||
setRequiredProviderFormFields,
|
||||
isSubmitting,
|
||||
true
|
||||
),
|
||||
isBlocking: true,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
export const getProviderConfigHiddenField = (
|
||||
providerSchema: ConfigEntryView[],
|
||||
setRequiredProviderFormFields: React.Dispatch<React.SetStateAction<ConfigEntryView[]>>,
|
||||
isSubmitting: boolean
|
||||
) => (
|
||||
<UseField
|
||||
path="config.providerConfig"
|
||||
component={HiddenField}
|
||||
config={{
|
||||
validations: [
|
||||
{
|
||||
validator: getNonEmptyValidator(
|
||||
providerSchema,
|
||||
setRequiredProviderFormFields,
|
||||
isSubmitting
|
||||
),
|
||||
isBlocking: true,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
export const getTaskTypeConfigHiddenField = (
|
||||
taskTypeSchema: ConfigEntryView[],
|
||||
setTaskTypeFormFields: React.Dispatch<React.SetStateAction<ConfigEntryView[]>>,
|
||||
isSubmitting: boolean
|
||||
) => (
|
||||
<UseField
|
||||
path="config.taskTypeConfig"
|
||||
component={HiddenField}
|
||||
config={{
|
||||
validations: [
|
||||
{
|
||||
validator: getNonEmptyValidator(
|
||||
taskTypeSchema,
|
||||
(requiredFormFields) => {
|
||||
const formFields = [
|
||||
...requiredFormFields,
|
||||
...(taskTypeSchema ?? []).filter((f) => !f.required),
|
||||
];
|
||||
setTaskTypeFormFields(formFields.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)));
|
||||
},
|
||||
isSubmitting
|
||||
),
|
||||
isBlocking: true,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
);
|
|
@ -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 getInferenceConnectorType } from './inference';
|
|
@ -0,0 +1,142 @@
|
|||
/*
|
||||
* 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 { experimentalFeaturesMock, registrationServicesMock } from '../../mocks';
|
||||
import { SUB_ACTION } from '../../../common/inference/constants';
|
||||
import { ExperimentalFeaturesService } from '../../common/experimental_features_service';
|
||||
|
||||
const ACTION_TYPE_ID = '.inference';
|
||||
let actionTypeModel: ActionTypeModel;
|
||||
|
||||
beforeAll(() => {
|
||||
ExperimentalFeaturesService.init({ experimentalFeatures: experimentalFeaturesMock });
|
||||
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 requests to AI providers such as Amazon Bedrock, OpenAI and more.'
|
||||
);
|
||||
expect(actionTypeModel.actionTypeTitle).toBe('AI Connector');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenAI action params validation', () => {
|
||||
test.each([
|
||||
{
|
||||
subAction: SUB_ACTION.RERANK,
|
||||
subActionParams: { input: ['message test'], query: 'foobar' },
|
||||
},
|
||||
{
|
||||
subAction: SUB_ACTION.COMPLETION,
|
||||
subActionParams: { input: 'message test' },
|
||||
},
|
||||
{
|
||||
subAction: SUB_ACTION.TEXT_EMBEDDING,
|
||||
subActionParams: { input: 'message test', inputType: 'foobar' },
|
||||
},
|
||||
{
|
||||
subAction: SUB_ACTION.SPARSE_EMBEDDING,
|
||||
subActionParams: { input: 'message test' },
|
||||
},
|
||||
])(
|
||||
'validation succeeds when params are valid for subAction $subAction',
|
||||
async ({ subAction, subActionParams }) => {
|
||||
const actionParams = {
|
||||
subAction,
|
||||
subActionParams,
|
||||
};
|
||||
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
|
||||
errors: { input: [], subAction: [], inputType: [], query: [] },
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
test('params validation fails when params is a wrong object', async () => {
|
||||
const actionParams = {
|
||||
subAction: SUB_ACTION.COMPLETION,
|
||||
subActionParams: { body: 'message {test}' },
|
||||
};
|
||||
|
||||
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
|
||||
errors: { input: ['Input is required.'], inputType: [], query: [], subAction: [] },
|
||||
});
|
||||
});
|
||||
|
||||
test('params validation fails when subAction is missing', async () => {
|
||||
const actionParams = {
|
||||
subActionParams: { input: 'message test' },
|
||||
};
|
||||
|
||||
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
|
||||
errors: {
|
||||
input: [],
|
||||
inputType: [],
|
||||
query: [],
|
||||
subAction: ['Action is required.'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('params validation fails when subAction is not in the list of the supported', async () => {
|
||||
const actionParams = {
|
||||
subAction: 'wrong',
|
||||
subActionParams: { input: 'message test' },
|
||||
};
|
||||
|
||||
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
|
||||
errors: {
|
||||
input: [],
|
||||
inputType: [],
|
||||
query: [],
|
||||
subAction: ['Invalid action name.'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('params validation fails when subActionParams is missing', async () => {
|
||||
const actionParams = {
|
||||
subAction: SUB_ACTION.RERANK,
|
||||
subActionParams: {},
|
||||
};
|
||||
|
||||
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
|
||||
errors: {
|
||||
input: ['Input is required.', 'Input does not have a valid Array format.'],
|
||||
inputType: [],
|
||||
query: ['Query is required.'],
|
||||
subAction: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('params validation fails when text_embedding inputType is missing', async () => {
|
||||
const actionParams = {
|
||||
subAction: SUB_ACTION.TEXT_EMBEDDING,
|
||||
subActionParams: { input: 'message test' },
|
||||
};
|
||||
|
||||
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
|
||||
errors: {
|
||||
input: [],
|
||||
inputType: ['Input type is required.'],
|
||||
query: [],
|
||||
subAction: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,92 @@
|
|||
/*
|
||||
* 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 { RerankParams, TextEmbeddingParams } from '../../../common/inference/types';
|
||||
import { SUB_ACTION } from '../../../common/inference/constants';
|
||||
import {
|
||||
INFERENCE_CONNECTOR_ID,
|
||||
INFERENCE_CONNECTOR_TITLE,
|
||||
} from '../../../common/inference/constants';
|
||||
import { InferenceActionParams, InferenceConnector } from './types';
|
||||
|
||||
interface ValidationErrors {
|
||||
subAction: string[];
|
||||
input: string[];
|
||||
// rerank only
|
||||
query: string[];
|
||||
// text_embedding only
|
||||
inputType: string[];
|
||||
}
|
||||
export function getConnectorType(): InferenceConnector {
|
||||
return {
|
||||
id: INFERENCE_CONNECTOR_ID,
|
||||
iconClass: 'sparkles',
|
||||
isExperimental: true,
|
||||
selectMessage: i18n.translate('xpack.stackConnectors.components.inference.selectMessageText', {
|
||||
defaultMessage: 'Send requests to AI providers such as Amazon Bedrock, OpenAI and more.',
|
||||
}),
|
||||
actionTypeTitle: INFERENCE_CONNECTOR_TITLE,
|
||||
validateParams: async (
|
||||
actionParams: InferenceActionParams
|
||||
): Promise<GenericValidationResult<ValidationErrors>> => {
|
||||
const { subAction, subActionParams } = actionParams;
|
||||
const translations = await import('./translations');
|
||||
const errors: ValidationErrors = {
|
||||
input: [],
|
||||
subAction: [],
|
||||
inputType: [],
|
||||
query: [],
|
||||
};
|
||||
|
||||
if (
|
||||
subAction === SUB_ACTION.RERANK ||
|
||||
subAction === SUB_ACTION.COMPLETION ||
|
||||
subAction === SUB_ACTION.TEXT_EMBEDDING ||
|
||||
subAction === SUB_ACTION.SPARSE_EMBEDDING
|
||||
) {
|
||||
if (!subActionParams.input?.length) {
|
||||
errors.input.push(translations.getRequiredMessage('Input'));
|
||||
}
|
||||
}
|
||||
if (subAction === SUB_ACTION.RERANK) {
|
||||
if (!Array.isArray(subActionParams.input)) {
|
||||
errors.input.push(translations.INPUT_INVALID);
|
||||
}
|
||||
|
||||
if (!(subActionParams as RerankParams).query?.length) {
|
||||
errors.query.push(translations.getRequiredMessage('Query'));
|
||||
}
|
||||
}
|
||||
if (subAction === SUB_ACTION.TEXT_EMBEDDING) {
|
||||
if (!(subActionParams as TextEmbeddingParams).inputType?.length) {
|
||||
errors.inputType.push(translations.getRequiredMessage('Input type'));
|
||||
}
|
||||
}
|
||||
if (errors.input.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.getRequiredMessage('Action'));
|
||||
} else if (
|
||||
![
|
||||
SUB_ACTION.COMPLETION,
|
||||
SUB_ACTION.SPARSE_EMBEDDING,
|
||||
SUB_ACTION.RERANK,
|
||||
SUB_ACTION.TEXT_EMBEDDING,
|
||||
].includes(subAction)
|
||||
) {
|
||||
errors.subAction.push(translations.INVALID_ACTION);
|
||||
}
|
||||
return { errors };
|
||||
},
|
||||
actionConnectorFields: lazy(() => import('./connector')),
|
||||
actionParamsFields: lazy(() => import('./params')),
|
||||
};
|
||||
}
|
|
@ -0,0 +1,167 @@
|
|||
/*
|
||||
* 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 ParamsFields from './params';
|
||||
import { SUB_ACTION } from '../../../common/inference/constants';
|
||||
|
||||
describe('Inference Params Fields renders', () => {
|
||||
test('all params fields are rendered', () => {
|
||||
const { getByTestId } = render(
|
||||
<ParamsFields
|
||||
actionParams={{
|
||||
subAction: SUB_ACTION.COMPLETION,
|
||||
subActionParams: { input: 'What is Elastic?' },
|
||||
}}
|
||||
actionConnector={{
|
||||
actionTypeId: '.inference',
|
||||
config: {
|
||||
taskType: 'completion',
|
||||
},
|
||||
id: 'test',
|
||||
isPreconfigured: false,
|
||||
isDeprecated: false,
|
||||
isSystemAction: false,
|
||||
secrets: {},
|
||||
name: 'AI Connector',
|
||||
}}
|
||||
errors={{ body: [] }}
|
||||
editAction={() => {}}
|
||||
index={0}
|
||||
/>
|
||||
);
|
||||
expect(getByTestId('inferenceInput')).toBeInTheDocument();
|
||||
expect(getByTestId('inferenceInput')).toHaveProperty('value', 'What is Elastic?');
|
||||
});
|
||||
|
||||
test.each(['openai', 'googleaistudio'])(
|
||||
'useEffect handles the case when subAction and subActionParams are undefined and provider is %p',
|
||||
(provider) => {
|
||||
const actionParams = {
|
||||
subAction: undefined,
|
||||
subActionParams: undefined,
|
||||
};
|
||||
const editAction = jest.fn();
|
||||
const errors = {};
|
||||
const actionConnector = {
|
||||
secrets: {
|
||||
providerSecrets: { apiKey: 'apiKey' },
|
||||
},
|
||||
id: 'test',
|
||||
actionTypeId: '.inference',
|
||||
isPreconfigured: false,
|
||||
isSystemAction: false as const,
|
||||
isDeprecated: false,
|
||||
name: 'My OpenAI Connector',
|
||||
config: {
|
||||
provider,
|
||||
providerConfig: {
|
||||
url: 'https://api.openai.com/v1/embeddings',
|
||||
},
|
||||
taskType: 'completion',
|
||||
},
|
||||
};
|
||||
render(
|
||||
<ParamsFields
|
||||
actionParams={actionParams}
|
||||
actionConnector={actionConnector}
|
||||
editAction={editAction}
|
||||
index={0}
|
||||
errors={errors}
|
||||
/>
|
||||
);
|
||||
expect(editAction).toHaveBeenCalledTimes(2);
|
||||
expect(editAction).toHaveBeenCalledWith('subAction', SUB_ACTION.COMPLETION, 0);
|
||||
if (provider === 'openai') {
|
||||
expect(editAction).toHaveBeenCalledWith(
|
||||
'subActionParams',
|
||||
{ input: 'What is Elastic?' },
|
||||
0
|
||||
);
|
||||
}
|
||||
if (provider === 'googleaistudio') {
|
||||
expect(editAction).toHaveBeenCalledWith(
|
||||
'subActionParams',
|
||||
{ input: 'What is Elastic?' },
|
||||
0
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('handles the case when subAction only is undefined', () => {
|
||||
const actionParams = {
|
||||
subAction: undefined,
|
||||
subActionParams: {
|
||||
input: '{"key": "value"}',
|
||||
},
|
||||
};
|
||||
const editAction = jest.fn();
|
||||
const errors = {};
|
||||
render(
|
||||
<ParamsFields
|
||||
actionParams={actionParams}
|
||||
editAction={editAction}
|
||||
index={0}
|
||||
errors={errors}
|
||||
actionConnector={{
|
||||
actionTypeId: '.inference',
|
||||
config: {
|
||||
taskType: 'completion',
|
||||
},
|
||||
id: 'test',
|
||||
isPreconfigured: false,
|
||||
isDeprecated: false,
|
||||
isSystemAction: false,
|
||||
secrets: {},
|
||||
name: 'AI Connector',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
expect(editAction).toHaveBeenCalledTimes(1);
|
||||
expect(editAction).toHaveBeenCalledWith('subAction', SUB_ACTION.COMPLETION, 0);
|
||||
});
|
||||
|
||||
it('calls editAction function with the correct arguments ', () => {
|
||||
const editAction = jest.fn();
|
||||
const errors = {};
|
||||
const { getByTestId } = render(
|
||||
<ParamsFields
|
||||
actionParams={{
|
||||
subAction: SUB_ACTION.RERANK,
|
||||
subActionParams: {
|
||||
input: ['apple', 'banana', 'cherry'],
|
||||
query: 'test',
|
||||
},
|
||||
}}
|
||||
editAction={editAction}
|
||||
index={0}
|
||||
errors={errors}
|
||||
actionConnector={{
|
||||
actionTypeId: '.inference',
|
||||
config: {
|
||||
taskType: 'rerank',
|
||||
},
|
||||
id: 'test',
|
||||
isPreconfigured: false,
|
||||
isDeprecated: false,
|
||||
isSystemAction: false,
|
||||
secrets: {},
|
||||
name: 'AI Connector',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const jsonEditor = getByTestId('inputJsonEditor');
|
||||
fireEvent.change(jsonEditor, { target: { value: `[\"apple\",\"banana\",\"tomato\"]` } });
|
||||
expect(editAction).toHaveBeenCalledWith(
|
||||
'subActionParams',
|
||||
{ input: '["apple","banana","tomato"]', query: 'test' },
|
||||
0
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,238 @@
|
|||
/*
|
||||
* 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 } from 'react';
|
||||
import {
|
||||
JsonEditorWithMessageVariables,
|
||||
type ActionParamsProps,
|
||||
} from '@kbn/triggers-actions-ui-plugin/public';
|
||||
import { EuiTextArea, EuiFormRow, EuiSpacer, EuiSelect } from '@elastic/eui';
|
||||
import { RuleFormParamsErrors } from '@kbn/alerts-ui-shared';
|
||||
import {
|
||||
ChatCompleteParams,
|
||||
RerankParams,
|
||||
SparseEmbeddingParams,
|
||||
TextEmbeddingParams,
|
||||
} from '../../../common/inference/types';
|
||||
import { DEFAULTS_BY_TASK_TYPE } from './constants';
|
||||
import * as i18n from './translations';
|
||||
import { SUB_ACTION } from '../../../common/inference/constants';
|
||||
import { InferenceActionConnector, InferenceActionParams } from './types';
|
||||
|
||||
const InferenceServiceParamsFields: React.FunctionComponent<
|
||||
ActionParamsProps<InferenceActionParams>
|
||||
> = ({ actionParams, editAction, index, errors, actionConnector }) => {
|
||||
const { subAction, subActionParams } = actionParams;
|
||||
|
||||
const { taskType } = (actionConnector as unknown as InferenceActionConnector).config;
|
||||
|
||||
useEffect(() => {
|
||||
if (!subAction) {
|
||||
editAction('subAction', taskType, index);
|
||||
}
|
||||
}, [editAction, index, subAction, taskType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!subActionParams) {
|
||||
editAction(
|
||||
'subActionParams',
|
||||
{
|
||||
...(DEFAULTS_BY_TASK_TYPE[taskType] ?? {}),
|
||||
},
|
||||
index
|
||||
);
|
||||
}
|
||||
}, [editAction, index, subActionParams, taskType]);
|
||||
|
||||
const editSubActionParams = useCallback(
|
||||
(params: Partial<InferenceActionParams['subActionParams']>) => {
|
||||
editAction('subActionParams', { ...subActionParams, ...params }, index);
|
||||
},
|
||||
[editAction, index, subActionParams]
|
||||
);
|
||||
|
||||
if (subAction === SUB_ACTION.COMPLETION) {
|
||||
return (
|
||||
<CompletionParamsFields
|
||||
errors={errors}
|
||||
editSubActionParams={editSubActionParams}
|
||||
subActionParams={subActionParams as ChatCompleteParams}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (subAction === SUB_ACTION.RERANK) {
|
||||
return (
|
||||
<RerankParamsFields
|
||||
errors={errors}
|
||||
editSubActionParams={editSubActionParams}
|
||||
subActionParams={subActionParams as RerankParams}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (subAction === SUB_ACTION.SPARSE_EMBEDDING) {
|
||||
return (
|
||||
<SparseEmbeddingParamsFields
|
||||
errors={errors}
|
||||
editSubActionParams={editSubActionParams}
|
||||
subActionParams={subActionParams as SparseEmbeddingParams}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (subAction === SUB_ACTION.TEXT_EMBEDDING) {
|
||||
return (
|
||||
<TextEmbeddingParamsFields
|
||||
errors={errors}
|
||||
editSubActionParams={editSubActionParams}
|
||||
subActionParams={subActionParams as TextEmbeddingParams}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <></>;
|
||||
};
|
||||
|
||||
const InferenceInput: React.FunctionComponent<{
|
||||
input?: string;
|
||||
inputError?: string;
|
||||
editSubActionParams: (params: Partial<InferenceActionParams['subActionParams']>) => void;
|
||||
}> = ({ input, inputError, editSubActionParams }) => {
|
||||
return (
|
||||
<EuiFormRow fullWidth error={inputError} isInvalid={false} label={i18n.INPUT}>
|
||||
<EuiTextArea
|
||||
data-test-subj="inferenceInput"
|
||||
name="input"
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
editSubActionParams({ input: e.target.value });
|
||||
}}
|
||||
isInvalid={false}
|
||||
fullWidth={true}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
);
|
||||
};
|
||||
|
||||
const CompletionParamsFields: React.FunctionComponent<{
|
||||
subActionParams: ChatCompleteParams;
|
||||
errors: RuleFormParamsErrors;
|
||||
editSubActionParams: (params: Partial<InferenceActionParams['subActionParams']>) => void;
|
||||
}> = ({ subActionParams, editSubActionParams, errors }) => {
|
||||
const { input } = subActionParams;
|
||||
|
||||
return (
|
||||
<InferenceInput
|
||||
input={input}
|
||||
editSubActionParams={editSubActionParams}
|
||||
inputError={errors.input as string}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const SparseEmbeddingParamsFields: React.FunctionComponent<{
|
||||
subActionParams: SparseEmbeddingParams;
|
||||
errors: RuleFormParamsErrors;
|
||||
editSubActionParams: (params: Partial<InferenceActionParams['subActionParams']>) => void;
|
||||
}> = ({ subActionParams, editSubActionParams, errors }) => {
|
||||
const { input } = subActionParams;
|
||||
|
||||
return (
|
||||
<InferenceInput
|
||||
input={input}
|
||||
editSubActionParams={editSubActionParams}
|
||||
inputError={errors.input as string}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const TextEmbeddingParamsFields: React.FunctionComponent<{
|
||||
subActionParams: TextEmbeddingParams;
|
||||
errors: RuleFormParamsErrors;
|
||||
editSubActionParams: (params: Partial<InferenceActionParams['subActionParams']>) => void;
|
||||
}> = ({ subActionParams, editSubActionParams, errors }) => {
|
||||
const { input, inputType } = subActionParams;
|
||||
|
||||
const options = [
|
||||
{ value: 'ingest', text: 'ingest' },
|
||||
{ value: 'search', text: 'search' },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiFormRow
|
||||
fullWidth
|
||||
error={errors.inputType as string}
|
||||
isInvalid={false}
|
||||
label={i18n.INPUT_TYPE}
|
||||
>
|
||||
<EuiSelect
|
||||
data-test-subj="inferenceInputType"
|
||||
fullWidth
|
||||
name="inputType"
|
||||
isInvalid={false}
|
||||
options={options}
|
||||
value={inputType}
|
||||
onChange={(e) => {
|
||||
editSubActionParams({ inputType: e.target.value });
|
||||
}}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiSpacer size="s" />
|
||||
<InferenceInput
|
||||
input={input}
|
||||
editSubActionParams={editSubActionParams}
|
||||
inputError={errors.input as string}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const RerankParamsFields: React.FunctionComponent<{
|
||||
subActionParams: RerankParams;
|
||||
errors: RuleFormParamsErrors;
|
||||
editSubActionParams: (params: Partial<InferenceActionParams['subActionParams']>) => void;
|
||||
}> = ({ subActionParams, editSubActionParams, errors }) => {
|
||||
const { input, query } = subActionParams;
|
||||
|
||||
return (
|
||||
<>
|
||||
<JsonEditorWithMessageVariables
|
||||
paramsProperty={'input'}
|
||||
inputTargetValue={JSON.stringify(input)}
|
||||
label={i18n.INPUT}
|
||||
errors={errors.input as string[]}
|
||||
onDocumentsChange={(json: string) => {
|
||||
editSubActionParams({ input: json.trim() });
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (!input) {
|
||||
editSubActionParams({ input: [] });
|
||||
}
|
||||
}}
|
||||
dataTestSubj="inference-inputJsonEditor"
|
||||
/>
|
||||
<EuiSpacer size="s" />
|
||||
<EuiFormRow fullWidth error={errors.input as string} isInvalid={false} label={i18n.QUERY}>
|
||||
<EuiTextArea
|
||||
data-test-subj="inferenceQuery"
|
||||
name="query"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
editSubActionParams({ query: e.target.value });
|
||||
}}
|
||||
isInvalid={false}
|
||||
fullWidth={true}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export { InferenceServiceParamsFields as default };
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.6056 7H5.99165C3.24624 7 1 9.24624 1 11.9916V20.727C1 23.4724 3.24624 25.7187 5.99165 25.7187H12.6056L11.0082 23.4475L6.19131 21.9749C5.31777 21.7004 4.74373 20.8768 4.74373 19.9783V12.7404C4.71878 11.8669 5.29282 11.0183 6.19131 10.7437L11.0082 9.2712L12.6056 7ZM26.0082 7H19.3942L20.9915 9.2712L25.8334 10.7437C26.7319 11.0183 27.306 11.8669 27.281 12.7404V19.9783C27.281 20.8768 26.707 21.7004 25.8334 21.9749L21.0165 23.4475L19.3942 25.7187H26.0082C28.7785 25.7187 30.9998 23.4724 30.9998 20.727V11.9916C31.0248 9.24624 28.7785 7 26.0082 7ZM21.0165 15.1863H11.0083V17.4325H21.0165V15.1863Z" fill="#ED6B1E"/>
|
||||
</svg>
|
After Width: | Height: | Size: 768 B |
|
@ -0,0 +1,11 @@
|
|||
<svg width="80" height="80" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1_4)">
|
||||
<path d="M74 0H6C2.68629 0 0 2.68629 0 6V74C0 77.3137 2.68629 80 6 80H74C77.3137 80 80 77.3137 80 74V6C80 2.68629 77.3137 0 74 0Z" fill="#01A88D"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M64 38.9999C62.897 38.9999 62 38.1029 62 36.9999C62 35.8969 62.897 34.9999 64 34.9999C65.103 34.9999 66 35.8969 66 36.9999C66 38.1029 65.103 38.9999 64 38.9999ZM32.113 65.9079L28.865 64.0139L35.53 59.8479L34.47 58.1519L26.913 62.8749L21 59.4259V50.5349L26.555 46.8319L25.445 45.1679L19.959 48.8249L14 45.4199V40.5799L20.496 36.8679L19.504 35.1319L14 38.2769V34.5799L20 31.1519L26 34.5799V38.4339L21.485 41.1429L22.515 42.8569L27 40.1659L31.485 42.8569L32.515 41.1429L28 38.4339V34.5349L33.555 30.8319C33.833 30.6459 34 30.3339 34 29.9999V22.9999H32V29.4649L26.959 32.8249L21 29.4199V20.5739L26 17.6579V25.9999H28V16.4909L32.113 14.0919L40 16.7209V45.4339L25.485 54.1429L26.515 55.8569L40 47.7659V63.2789L32.113 65.9079ZM62 49.9999C62 51.1029 61.103 51.9999 60 51.9999C58.897 51.9999 58 51.1029 58 49.9999C58 48.8969 58.897 47.9999 60 47.9999C61.103 47.9999 62 48.8969 62 49.9999ZM52 59.9999C52 61.1029 51.103 61.9999 50 61.9999C48.897 61.9999 48 61.1029 48 59.9999C48 58.8969 48.897 57.9999 50 57.9999C51.103 57.9999 52 58.8969 52 59.9999ZM51 19.9999C51 18.8969 51.897 17.9999 53 17.9999C54.103 17.9999 55 18.8969 55 19.9999C55 21.1029 54.103 21.9999 53 21.9999C51.897 21.9999 51 21.1029 51 19.9999ZM64 32.9999C62.141 32.9999 60.589 34.2799 60.142 35.9999H42V30.9999H53C53.553 30.9999 54 30.5519 54 29.9999V23.8579C55.72 23.4109 57 21.8579 57 19.9999C57 17.7939 55.206 15.9999 53 15.9999C50.794 15.9999 49 17.7939 49 19.9999C49 21.8579 50.28 23.4109 52 23.8579V28.9999H42V15.9999C42 15.5689 41.725 15.1879 41.316 15.0509L32.316 12.0509C32.042 11.9609 31.744 11.9909 31.496 12.1359L19.496 19.1359C19.188 19.3149 19 19.6449 19 19.9999V29.4199L12.504 33.1319C12.192 33.3099 12 33.6409 12 33.9999V45.9999C12 46.3589 12.192 46.6899 12.504 46.8679L19 50.5799V59.9999C19 60.3549 19.188 60.6849 19.496 60.8639L31.496 67.8639C31.65 67.9539 31.825 67.9999 32 67.9999C32.106 67.9999 32.213 67.9829 32.316 67.9489L41.316 64.9489C41.725 64.8119 42 64.4309 42 63.9999V51.9999H49V56.1419C47.28 56.5889 46 58.1419 46 59.9999C46 62.2059 47.794 63.9999 50 63.9999C52.206 63.9999 54 62.2059 54 59.9999C54 58.1419 52.72 56.5889 51 56.1419V50.9999C51 50.4479 50.553 49.9999 50 49.9999H42V44.9999H54.5L56.638 47.8499C56.239 48.4719 56 49.2069 56 49.9999C56 52.2059 57.794 53.9999 60 53.9999C62.206 53.9999 64 52.2059 64 49.9999C64 47.7939 62.206 45.9999 60 45.9999C59.316 45.9999 58.682 46.1879 58.119 46.4919L55.8 43.3999C55.611 43.1479 55.314 42.9999 55 42.9999H42V37.9999H60.142C60.589 39.7199 62.141 40.9999 64 40.9999C66.206 40.9999 68 39.2059 68 36.9999C68 34.7939 66.206 32.9999 64 32.9999Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1_4">
|
||||
<rect width="80" height="80" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 3 KiB |
|
@ -0,0 +1,3 @@
|
|||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.49367 20.6289L13.0015 17.5382L13.0937 17.2689L13.0015 17.12H12.7322L11.8106 17.0633L8.66329 16.9782L5.93418 16.8648L3.29013 16.723L2.6238 16.5813L2 15.759L2.0638 15.3478L2.6238 14.9722L3.42481 15.043L5.19696 15.1635L7.85519 15.3478L9.78329 15.4613L12.64 15.759H13.0937L13.1575 15.5747L13.0015 15.4613L12.881 15.3478L10.1306 13.4835L7.15342 11.5129L5.59392 10.3787L4.75038 9.80456L4.32506 9.26582L4.14076 8.08911L4.90633 7.24557L5.93418 7.31646L6.19646 7.38734L7.23848 8.18835L9.4643 9.91089L12.3706 12.0516L12.7959 12.4061L12.9661 12.2856L12.9873 12.2005L12.7959 11.8815L11.2152 9.02481L9.5281 6.11848L8.77671 4.91342L8.57823 4.19038C8.50734 3.89266 8.45772 3.64456 8.45772 3.33975L9.32962 2.15595L9.81165 2L10.9742 2.15595L11.4633 2.58127L12.1863 4.23291L13.3559 6.83443L15.1706 10.3716L15.7023 11.4208L15.9858 12.3919L16.0922 12.6896H16.2765V12.5195L16.4253 10.5276L16.7018 8.08203L16.9711 4.93468L17.0633 4.04861L17.5028 2.98532L18.3747 2.41114L19.0552 2.73722L19.6152 3.53823L19.5372 4.0557L19.2041 6.21772L18.5519 9.60608L18.1266 11.8744H18.3747L18.6582 11.5909L19.8066 10.0668L21.7347 7.65671L22.5853 6.69975L23.5777 5.64354L24.2157 5.14025H25.4208L26.3068 6.45873L25.9099 7.81975L24.6694 9.39342L23.6415 10.7261L22.1671 12.7109L21.2456 14.2987L21.3306 14.4263L21.5504 14.4051L24.882 13.6962L26.6825 13.3701L28.8304 13.0015L29.8015 13.4552L29.9078 13.9159L29.5251 14.8587L27.2284 15.4258L24.5347 15.9646L20.5225 16.9144L20.4729 16.9499L20.5296 17.0208L22.3372 17.1909L23.1099 17.2334H25.0025L28.5256 17.4957L29.4471 18.1053L30 18.8496L29.9078 19.4167L28.4901 20.1397L26.5762 19.6861L22.1104 18.6228L20.5792 18.24H20.3666V18.3676L21.6425 19.6152L23.9818 21.7276L26.9094 24.4496L27.0582 25.123L26.6825 25.6547L26.2856 25.598L23.7124 23.6628L22.72 22.7909L20.4729 20.8982H20.3241V21.0967L20.8415 21.8552L23.5777 25.9666L23.7195 27.2284L23.521 27.6395L22.8122 27.8876L22.0324 27.7458L20.4304 25.4987L18.7787 22.9681L17.4461 20.6997L17.283 20.7919L16.4962 29.2628L16.1276 29.6952L15.277 30.0213L14.5681 29.4825L14.1924 28.6106L14.5681 26.8881L15.0218 24.641L15.3904 22.8547L15.7235 20.6359L15.922 19.8987L15.9078 19.8491L15.7448 19.8704L14.0719 22.1671L11.5271 25.6051L9.51392 27.76L9.0319 27.9514L8.19544 27.519L8.27342 26.7463L8.74127 26.0587L11.5271 22.5144L13.2071 20.317L14.2916 19.0481L14.2846 18.8638H14.2208L6.82025 23.6699L5.50177 23.84L4.93468 23.3084L5.00557 22.4365L5.27494 22.1529L7.50076 20.6218L7.49367 20.6289Z" fill="#D97757"/>
|
||||
</svg>
|
After Width: | Height: | Size: 2.5 KiB |
|
@ -0,0 +1,44 @@
|
|||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.7797 2.01892C21.612 2.01892 22.3496 2.64311 22.6144 3.55102C22.8793 4.45893 24.4302 10.0766 24.4302 10.0766V21.2364H18.8125L18.9261 2H20.7797V2.01892Z" fill="url(#paint0_linear_3625_43445)"/>
|
||||
<path d="M29.0265 10.737C29.0265 10.3397 28.705 10.0371 28.3267 10.0371H25.0166C22.69 10.0371 20.7986 11.9286 20.7986 14.2551V21.2536H24.8084C27.1351 21.2536 29.0265 19.3621 29.0265 17.0356V10.737Z" fill="url(#paint1_linear_3625_43445)"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.7798 2.01758C20.1367 2.01758 19.626 2.52828 19.626 3.17139L19.5126 24.4128C19.5126 27.5148 16.9969 30.0305 13.8949 30.0305H3.69968C3.2079 30.0305 2.88634 29.5576 3.03766 29.1037L11.2089 5.78164C12.0033 3.53077 14.1217 2.01758 16.505 2.01758H20.7987H20.7798Z" fill="url(#paint2_linear_3625_43445)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_3625_43445" x1="23.1251" y1="21.6525" x2="18.3964" y2="2.71876" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#712575"/>
|
||||
<stop offset="0.09" stop-color="#9A2884"/>
|
||||
<stop offset="0.18" stop-color="#BF2C92"/>
|
||||
<stop offset="0.27" stop-color="#DA2E9C"/>
|
||||
<stop offset="0.34" stop-color="#EB30A2"/>
|
||||
<stop offset="0.4" stop-color="#F131A5"/>
|
||||
<stop offset="0.5" stop-color="#EC30A3"/>
|
||||
<stop offset="0.61" stop-color="#DF2F9E"/>
|
||||
<stop offset="0.72" stop-color="#C92D96"/>
|
||||
<stop offset="0.83" stop-color="#AA2A8A"/>
|
||||
<stop offset="0.95" stop-color="#83267C"/>
|
||||
<stop offset="1" stop-color="#712575"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_3625_43445" x1="24.922" y1="2.41679" x2="24.922" y2="29.1246" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#DA7ED0"/>
|
||||
<stop offset="0.08" stop-color="#B17BD5"/>
|
||||
<stop offset="0.19" stop-color="#8778DB"/>
|
||||
<stop offset="0.3" stop-color="#6276E1"/>
|
||||
<stop offset="0.41" stop-color="#4574E5"/>
|
||||
<stop offset="0.54" stop-color="#2E72E8"/>
|
||||
<stop offset="0.67" stop-color="#1D71EB"/>
|
||||
<stop offset="0.81" stop-color="#1471EC"/>
|
||||
<stop offset="1" stop-color="#1171ED"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_3625_43445" x1="23.3144" y1="3.02245" x2="5.61009" y2="31.4137" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#DA7ED0"/>
|
||||
<stop offset="0.05" stop-color="#B77BD4"/>
|
||||
<stop offset="0.11" stop-color="#9079DA"/>
|
||||
<stop offset="0.18" stop-color="#6E77DF"/>
|
||||
<stop offset="0.25" stop-color="#5175E3"/>
|
||||
<stop offset="0.33" stop-color="#3973E7"/>
|
||||
<stop offset="0.42" stop-color="#2772E9"/>
|
||||
<stop offset="0.54" stop-color="#1A71EB"/>
|
||||
<stop offset="0.68" stop-color="#1371EC"/>
|
||||
<stop offset="1" stop-color="#1171ED"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 2.6 KiB |
|
@ -0,0 +1,9 @@
|
|||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M2 6.2V25.8C2 28.1193 3.88067 30 6.2 30H25.8C28.1193 30 30 28.1193 30 25.8V6.2C30 3.88067 28.1193 2 25.8 2H6.2C3.88067 2 2 3.88067 2 6.2ZM18.8 2V7.6C18.8 13.7849 23.8151 18.8 30 18.8H24.4C18.2151 18.8 13.2016 23.812 13.2 29.9969V24.4C13.2 18.2151 8.18489 13.2 2 13.2H7.6C13.7849 13.2 18.8 8.18489 18.8 2Z" fill="url(#paint0_radial_3625_43439)"/>
|
||||
<defs>
|
||||
<radialGradient id="paint0_radial_3625_43439" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(14.7834 15.2861) rotate(45) scale(17.5637 23.9043)">
|
||||
<stop stop-color="#83B9F9"/>
|
||||
<stop offset="1" stop-color="#0078D4"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 779 B |
|
@ -0,0 +1,9 @@
|
|||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Frame 1502">
|
||||
<g id="Group">
|
||||
<path id="Vector" fill-rule="evenodd" clip-rule="evenodd" d="M4.69017 8.30813C5.05012 8.30813 5.76614 8.2878 6.75587 7.86999C7.9092 7.383 10.2038 6.49902 11.8591 5.59107C13.0168 4.95601 13.5242 4.11609 13.5242 2.98501C13.5243 1.41518 12.2833 0.142578 10.7525 0.142578H4.3387C2.13989 0.142578 0.357422 1.97049 0.357422 4.22534C0.357422 6.48016 2.02634 8.30813 4.69017 8.30813Z" fill="#343741"/>
|
||||
<path id="Vector_2" fill-rule="evenodd" clip-rule="evenodd" d="M5.77539 11.1217C5.77539 10.0164 6.42425 9.01987 7.41974 8.59618L9.43959 7.73656C11.4826 6.86702 13.7314 8.40671 13.7314 10.6752C13.7314 12.4326 12.3419 13.8572 10.6281 13.8568L8.44111 13.8561C6.96877 13.8558 5.77539 12.6317 5.77539 11.1217Z" fill="#343741"/>
|
||||
<path id="Vector_3" d="M2.65251 8.8457C1.38499 8.8457 0.357422 9.89945 0.357422 11.1993V11.5041C0.357422 12.8039 1.38495 13.8577 2.65247 13.8577C3.91999 13.8577 4.94757 12.8039 4.94757 11.5041V11.1993C4.94757 9.89945 3.92003 8.8457 2.65251 8.8457Z" fill="#343741"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
|
@ -0,0 +1,16 @@
|
|||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Elastic Logo" clip-path="url(#clip0_3517_16106)">
|
||||
<path id="Outline" fill-rule="evenodd" clip-rule="evenodd" d="M13.3683 5.76929C13.6877 6.22686 13.8583 6.77172 13.8569 7.32972C13.8541 7.88997 13.6804 8.43603 13.3589 8.89486C13.038 9.35285 12.5845 9.7015 12.0595 9.89386C12.2124 10.3141 12.2253 10.7725 12.0961 11.2006C11.9669 11.6287 11.7027 12.0035 11.3429 12.269C10.9833 12.5333 10.5474 12.6731 10.1011 12.6674C9.65492 12.6617 9.22272 12.5107 8.87003 12.2373C8.39436 12.9043 7.71899 13.4029 6.94145 13.661C6.16465 13.9188 5.32592 13.9222 4.54703 13.6709C3.76743 13.4189 3.08814 12.9255 2.60731 12.2621C2.12537 11.5976 1.86627 10.7975 1.86717 9.97657C1.86706 9.72855 1.89001 9.48106 1.93574 9.23729C1.40909 9.04854 0.954014 8.70098 0.633311 8.24258C0.311933 7.78314 0.140528 7.23554 0.142597 6.67486C0.145501 6.11457 0.319395 5.56851 0.641026 5.10972C0.962305 4.6517 1.41627 4.30319 1.94174 4.11115C1.78662 3.69074 1.77202 3.23136 1.90014 2.80195C2.02826 2.37254 2.29219 1.99626 2.65231 1.72958C3.01211 1.46404 3.44878 1.32328 3.89592 1.32869C4.34306 1.33411 4.77619 1.4854 5.12945 1.75958C5.64444 1.04047 6.39005 0.519302 7.24231 0.282718C8.09391 0.0467746 9.00068 0.110161 9.81117 0.46229C10.6227 0.815129 11.2891 1.43484 11.6999 2.21858C12.1115 3.00344 12.2435 3.9051 12.074 4.775C12.5977 4.96493 13.0498 5.31224 13.3683 5.76929ZM5.53403 6.05129L8.53488 7.42743L11.5632 4.76472C11.607 4.54501 11.6289 4.32148 11.6283 4.09743C11.6286 3.37229 11.3974 2.666 10.9683 2.08143C10.5403 1.49792 9.93643 1.06698 9.24546 0.851861C8.55514 0.637319 7.81416 0.650239 7.13174 0.888718C6.44837 1.12763 5.85956 1.5793 5.45174 2.17743L4.9486 4.79943L5.53445 6.05086L5.53403 6.05129ZM2.42817 9.24029C2.33332 9.70704 2.33721 10.1885 2.43958 10.6536C2.54196 11.1188 2.74055 11.5574 3.0226 11.9411C3.453 12.5265 4.05991 12.9582 4.75402 13.1729C5.44731 13.387 6.19109 13.3724 6.87545 13.1313C7.56085 12.8897 8.15062 12.4345 8.55803 11.8327L9.05774 9.21758L8.39131 7.93829L5.37845 6.56215L2.42774 9.23986L2.42817 9.24029ZM4.46602 4.51572L2.40888 4.02843L2.40974 4.02758C2.29072 3.69713 2.28112 3.33716 2.38237 3.00084C2.48362 2.66452 2.69035 2.36968 2.97202 2.15986C3.25372 1.9511 3.59548 1.83922 3.94609 1.841C4.29671 1.84278 4.63732 1.95811 4.91688 2.16972L4.46602 4.51572ZM2.2306 4.52C1.78544 4.66747 1.3969 4.94924 1.11845 5.32658C0.839302 5.70454 0.683648 6.15948 0.672756 6.62923C0.661864 7.09898 0.796264 7.56065 1.0576 7.95115C1.3186 8.34115 1.6936 8.64115 2.13117 8.80829L5.01674 6.19358L4.48745 5.05786L2.2306 4.52ZM10.0675 12.1691C9.71278 12.1685 9.36834 12.0502 9.08817 11.8327L9.5326 9.49529L11.5897 9.97743C11.6796 10.2236 11.7089 10.4877 11.6753 10.7476C11.6417 11.0075 11.546 11.2554 11.3965 11.4706C11.2477 11.6855 11.0491 11.8613 10.8177 11.983C10.5863 12.1046 10.3289 12.1685 10.0675 12.1691ZM9.50603 8.95529L11.7689 9.48586C12.2209 9.33292 12.6138 9.04253 12.8926 8.65529C13.172 8.26743 13.3238 7.80218 13.3267 7.32415C13.3266 6.86346 13.1868 6.41366 12.9256 6.03415C12.6649 5.65512 12.2949 5.36451 11.8649 5.201L8.90602 7.80243L9.50603 8.95529Z" fill="white"/>
|
||||
<path id="Vector" d="M5.53465 6.05061L8.53551 7.42676L11.5638 4.76404C11.6077 4.54434 11.6295 4.3208 11.6289 4.09676C11.6292 3.37162 11.398 2.66532 10.9689 2.08076C10.5409 1.49724 9.93705 1.0663 9.24608 0.851186C8.55576 0.636644 7.81478 0.649564 7.13236 0.888043C6.44899 1.12696 5.86018 1.57862 5.45236 2.17676L4.94922 4.79876L5.53508 6.05019L5.53465 6.05061Z" fill="#FEC514"/>
|
||||
<path id="Vector_2" d="M2.42774 9.24064C2.33289 9.70739 2.33678 10.1888 2.43915 10.654C2.54153 11.1191 2.74012 11.5577 3.02217 11.9415C3.45257 12.5268 4.05948 12.9586 4.75359 13.1732C5.44688 13.3873 6.19066 13.3728 6.87502 13.1316C7.56042 12.89 8.15019 12.4349 8.55759 11.8331L9.05731 9.21793L8.39088 7.93864L5.37802 6.5625L2.42731 9.24021L2.42774 9.24064Z" fill="#00BFB3"/>
|
||||
<path id="Vector_3" d="M2.40851 4.02925L4.46565 4.51654L4.91651 2.17054C4.63695 1.95893 4.29634 1.84359 3.94572 1.84182C3.59511 1.84004 3.25335 1.95191 2.97165 2.16068C2.68998 2.3705 2.48325 2.66534 2.382 3.00166C2.28075 3.33798 2.29034 3.69795 2.40937 4.02839" fill="#F04E98"/>
|
||||
<path id="Vector_4" d="M2.23033 4.51953C1.78517 4.66699 1.39664 4.94877 1.11819 5.3261C0.839035 5.70407 0.683381 6.15901 0.672488 6.62876C0.661596 7.09851 0.795996 7.56017 1.05733 7.95067C1.31833 8.34067 1.69333 8.64067 2.1309 8.80782L5.01647 6.1931L4.48719 5.05739L2.23033 4.51953Z" fill="#1BA9F5"/>
|
||||
<path id="Vector_5" d="M9.08789 11.8335C9.36806 12.051 9.7125 12.1693 10.0672 12.17C10.3286 12.1693 10.586 12.1054 10.8174 11.9838C11.0488 11.8621 11.2474 11.6863 11.3962 11.4714C11.5457 11.2562 11.6414 11.0083 11.675 10.7484C11.7087 10.4885 11.6793 10.2244 11.5895 9.97824L9.53232 9.49609L9.08789 11.8335Z" fill="#93C90E"/>
|
||||
<path id="Vector_6" d="M9.50625 8.95546L11.7691 9.48603C12.2211 9.33309 12.614 9.04269 12.8928 8.65546C13.1723 8.2676 13.324 7.80235 13.327 7.32431C13.3269 6.86363 13.187 6.41382 12.9258 6.03431C12.6651 5.65529 12.2951 5.36468 11.8651 5.20117L8.90625 7.8026L9.50625 8.95546Z" fill="#0077CC"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_3517_16106">
|
||||
<rect width="13.7143" height="13.7143" fill="white" transform="translate(0.142578 0.142578)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 5.2 KiB |
|
@ -0,0 +1,6 @@
|
|||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20.3054 10.1595H21.2814L24.063 7.35127L24.1996 6.15898C22.6062 4.73904 20.6804 3.75265 18.6044 3.293C16.5283 2.83335 14.3704 2.91561 12.3346 3.53202C10.2988 4.14842 8.45224 5.27861 6.96936 6.81583C5.48648 8.35306 4.41624 10.2466 3.85974 12.3175C4.16962 12.1892 4.5129 12.1684 4.83574 12.2584L10.399 11.3321C10.399 11.3321 10.682 10.8591 10.8284 10.8887C12.0204 9.56701 13.6678 8.7553 15.4332 8.61975C17.1986 8.48419 18.9486 9.03505 20.3249 10.1595H20.3054Z" fill="#EA4335"/>
|
||||
<path d="M28.0256 12.3166C27.3863 9.93956 26.0735 7.80262 24.2485 6.16797L20.3445 10.1094C21.1581 10.7806 21.8102 11.6296 22.2514 12.5922C22.6927 13.5549 22.9117 14.6062 22.8919 15.6668V16.3664C23.3481 16.3664 23.8 16.4572 24.2215 16.6334C24.6431 16.8097 25.0261 17.0681 25.3487 17.3939C25.6714 17.7196 25.9273 18.1063 26.1019 18.5319C26.2766 18.9575 26.3664 19.4136 26.3664 19.8743C26.3664 20.335 26.2766 20.7911 26.1019 21.2167C25.9273 21.6423 25.6714 22.029 25.3487 22.3547C25.0261 22.6805 24.6431 22.9389 24.2215 23.1152C23.8 23.2914 23.3481 23.3822 22.8919 23.3822H15.9427L15.2498 24.0916V28.2991L15.9427 28.9987H22.8919C24.8324 29.014 26.7262 28.3982 28.2933 27.2426C29.8603 26.0869 31.0173 24.4528 31.593 22.5818C32.1688 20.7108 32.1328 18.7025 31.4903 16.8538C30.8479 15.0051 29.6331 13.4142 28.0256 12.3166Z" fill="#4285F4"/>
|
||||
<path d="M8.98381 28.9612H15.9329V23.3446H8.98381C8.48871 23.3445 7.99942 23.237 7.54908 23.0293L6.57308 23.3348L3.77195 26.143L3.52795 27.1284C5.09876 28.3259 7.01548 28.9698 8.98381 28.9612Z" fill="#34A853"/>
|
||||
<path d="M8.9838 10.7403C7.10091 10.7516 5.26856 11.3564 3.74266 12.4702C2.21676 13.5839 1.07351 15.151 0.472601 16.9526C-0.128309 18.7542 -0.156875 20.7002 0.390891 22.519C0.938658 24.3377 2.0354 25.9383 3.52794 27.0972L7.55883 23.0277C7.04659 22.7941 6.59814 22.4384 6.25197 21.9913C5.90579 21.5443 5.67222 21.019 5.5713 20.4608C5.47038 19.9025 5.50511 19.3279 5.67253 18.7861C5.83995 18.2444 6.13505 17.7518 6.53251 17.3505C6.92997 16.9492 7.41793 16.6513 7.95451 16.4823C8.4911 16.3133 9.06029 16.2782 9.61325 16.3801C10.1662 16.482 10.6864 16.7178 11.1293 17.0673C11.5721 17.4168 11.9244 17.8695 12.1558 18.3867L16.1867 14.3171C15.3406 13.2005 14.2502 12.2966 13.0007 11.6761C11.7511 11.0556 10.3763 10.7353 8.9838 10.7403Z" fill="#FBBC05"/>
|
||||
</svg>
|
After Width: | Height: | Size: 2.3 KiB |
After Width: | Height: | Size: 35 KiB |
|
@ -0,0 +1,3 @@
|
|||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.83305 10H8.29864e-05L8.29864e-05 10.8333L5.83305 10.8333V10ZM0 11.5898H5.83297V12.4232L0 12.4232L0 11.5898ZM1.66401 13.1796H4.16597V14.0129H1.66401V13.1796ZM4.16597 14.7725H1.66401V15.6058H4.16597V14.7725ZM1.66401 16.3625H4.16597V17.1958H1.66401V16.3625ZM4.16597 17.9522H1.66401V18.7856H4.16597L4.16597 17.9522ZM0 19.542H5.83297V20.3753H0L0 19.542ZM0 21.1346H5.83297V21.965H0L0 21.1346ZM15.6357 20.3756H6.66482V19.5422H15.9685C15.9001 19.8377 15.7872 20.1185 15.6357 20.3756ZM14.9373 16.3627H8.33175V17.1961H15.6356C15.4454 16.8769 15.2107 16.5962 14.9373 16.3627ZM8.33175 15.6063V14.773H15.6356C15.4514 15.0921 15.2166 15.3729 14.9373 15.6063H8.33175ZM15.6357 11.5901H6.66482V12.4234H15.9685C15.8912 12.1279 15.7783 11.8472 15.6357 11.5901ZM6.66482 10.0003H12.8782C13.7042 10.0003 14.4619 10.3195 15.0206 10.8337H6.66482V10.0003ZM10.8307 13.1801H8.33175V14.0134H10.8307V13.1801ZM15.9479 14.0134H13.333V13.1801H16.0608C16.0608 13.4667 16.0222 13.7475 15.9479 14.0134ZM8.33175 17.9522H10.8307V18.7856H8.33175V17.9522ZM13.333 18.7856V17.9522H15.9479C16.0222 18.2182 16.0608 18.4989 16.0608 18.7856H13.333ZM6.66482 21.9594L12.8782 21.9682C13.7102 21.9682 14.4619 21.6491 15.0235 21.1349H6.66482V21.9594ZM20.8329 21.1346H16.6669V21.965H20.8329V21.1346ZM16.6669 19.5422H20.8329V20.3755H16.6669V19.5422ZM20.8329 17.9525H18.3309V18.7858H20.8329V17.9525ZM18.3309 16.3627H20.8329V17.1961H18.3309V16.3627ZM16.6669 11.5898L21.9918 11.5898L22.28 12.4232L16.6669 12.4232V11.5898ZM21.4391 10.0003H16.6669V10.8337L21.7273 10.8337L21.4391 10.0003ZM29.9999 21.1349H25.8309V21.9653H29.9999V21.1349ZM25.8309 19.542H29.9999V20.3753H25.8309V19.542ZM28.3329 17.9525H25.8309V18.7858H28.3329V17.9525ZM25.8309 16.3627H28.3329V17.1961H25.8309V16.3627ZM28.3329 15.6058H25.8309V14.8405L25.5665 15.6058L21.0974 15.6058L20.8329 14.8405V15.6058H18.3309V14.7725H20.8329H23.0972L23.3319 15.4463L23.5667 14.7725L25.8309 14.7725H28.3329V15.6058ZM28.3326 13.1801H24.1191L23.8309 14.0134L28.3326 14.0134V13.1801ZM24.9393 10.8337L25.2275 10.0003H29.9997V10.8337H24.9393ZM23.3322 21.9594L23.6204 21.1349H23.0439L23.3322 21.9594ZM23.8844 20.3753H22.7791L22.4849 19.542H24.1816L23.8844 20.3753ZM22.2176 18.7858H24.4462L24.7403 17.9525H21.9234L22.2176 18.7858ZM25.0077 17.1958H21.6589L21.3707 16.3625H25.293L25.0077 17.1958ZM18.3309 14.0134H22.8327L22.5445 13.1801L18.3309 13.1801V14.0134ZM30.0001 12.4232H24.387L24.6723 11.5898H30.0001V12.4232Z" fill="black"/>
|
||||
</svg>
|
After Width: | Height: | Size: 2.5 KiB |
|
@ -0,0 +1,34 @@
|
|||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.2415 18.6003H7.03916L7.0416 13.4005H12.2441L12.2415 18.6003Z" fill="black"/>
|
||||
<path d="M17.4412 23.8001H12.2389L12.2415 18.6003L17.4438 18.6004L17.4412 23.8001Z" fill="black"/>
|
||||
<path d="M17.4438 18.6004L12.2415 18.6003L12.2441 13.4005H17.4464L17.4438 18.6004Z" fill="black"/>
|
||||
<path d="M22.6461 18.6003L17.4438 18.6004L17.4464 13.4005H22.6487L22.6461 18.6003Z" fill="black"/>
|
||||
<path d="M12.2441 13.4005H7.0416L7.04408 8.20071H12.2466L12.2441 13.4005Z" fill="black"/>
|
||||
<path d="M22.6487 13.4005H17.4464L17.4488 8.20071H22.6513L22.6487 13.4005Z" fill="black"/>
|
||||
<path d="M7.03916 18.6003H1.83674L1.83918 13.4005H7.0416L7.03916 18.6003Z" fill="black"/>
|
||||
<path d="M7.0416 13.4005H1.83918L1.84176 8.20071H7.04408L7.0416 13.4005Z" fill="black"/>
|
||||
<path d="M7.04408 8.20071H1.84176L1.84435 3.00088H7.04684L7.04408 8.20071Z" fill="black"/>
|
||||
<path d="M27.8536 8.20061L22.6513 8.20071L22.6537 3.00088H27.8562L27.8536 8.20061Z" fill="black"/>
|
||||
<path d="M7.0366 23.8001H1.83425L1.83674 18.6003H7.03916L7.0366 23.8001Z" fill="black"/>
|
||||
<path d="M7.03401 28.9999H1.83167L1.83425 23.8001H7.0366L7.03401 28.9999Z" fill="black"/>
|
||||
<path d="M27.8485 18.6003H22.6461L22.6487 13.4005H27.851L27.8485 18.6003Z" fill="black"/>
|
||||
<path d="M27.851 13.4005H22.6487L22.6513 8.20071L27.8536 8.20061L27.851 13.4005Z" fill="black"/>
|
||||
<path d="M27.846 23.8001H22.6436L22.6461 18.6003H27.8485L27.846 23.8001Z" fill="black"/>
|
||||
<path d="M27.8434 28.9999H22.641L22.6436 23.8001H27.846L27.8434 28.9999Z" fill="black"/>
|
||||
<path d="M14.405 18.5994H9.20275L9.20519 13.3997H14.4077L14.405 18.5994Z" fill="#FF7000"/>
|
||||
<path d="M19.6049 23.7992H14.4026L14.405 18.5994L19.6075 18.5995L19.6049 23.7992Z" fill="#FF4900"/>
|
||||
<path d="M19.6075 18.5995L14.405 18.5994L14.4077 13.3997L19.61 13.4007L19.6075 18.5995Z" fill="#FF7000"/>
|
||||
<path d="M24.8099 18.6004L19.6075 18.5995L19.61 13.4007H24.8125L24.8099 18.6004Z" fill="#FF7000"/>
|
||||
<path d="M14.4077 13.3997H9.20519L9.20783 8.1992H14.4103L14.4077 13.3997Z" fill="#FFA300"/>
|
||||
<path d="M24.8124 13.3989H19.61L19.6125 8.1992H24.815L24.8124 13.3989Z" fill="#FFA300"/>
|
||||
<path d="M9.20275 18.5994H4.00043L4.00286 13.3997H9.20519L9.20275 18.5994Z" fill="#FF7000"/>
|
||||
<path d="M9.20519 13.3997H4.00286L4.00535 8.19983L9.20783 8.1992L9.20519 13.3997Z" fill="#FFA300"/>
|
||||
<path d="M9.20783 8.1992L4.00535 8.19983L4.00794 3H9.21043L9.20783 8.1992Z" fill="#FFCE00"/>
|
||||
<path d="M30.0172 8.19973L24.815 8.1992L24.8173 3H30.0198L30.0172 8.19973Z" fill="#FFCE00"/>
|
||||
<path d="M9.20005 23.7992H3.99784L4.00043 18.5994H9.20275L9.20005 23.7992Z" fill="#FF4900"/>
|
||||
<path d="M9.19756 28.9991H3.99535L3.99784 23.7992H9.20005L9.19756 28.9991Z" fill="#FF0107"/>
|
||||
<path d="M30.0122 18.5994L24.8099 18.6004L24.8124 13.3989L30.0147 13.3997L30.0122 18.5994Z" fill="#FF7000"/>
|
||||
<path d="M30.0147 13.3997L24.8124 13.3989L24.815 8.1992L30.0172 8.19973L30.0147 13.3997Z" fill="#FFA300"/>
|
||||
<path d="M30.0096 23.7992H24.8072L24.8099 18.6004L30.0122 18.5994L30.0096 23.7992Z" fill="#FF4900"/>
|
||||
<path d="M30.0071 28.9991H24.8047L24.8072 23.7992H30.0096L30.0071 28.9991Z" fill="#FF0107"/>
|
||||
</svg>
|
After Width: | Height: | Size: 3.1 KiB |
|
@ -0,0 +1,3 @@
|
|||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M28.1537 13.4601C28.4721 12.5178 28.5826 11.5176 28.4776 10.5285C28.3727 9.53937 28.0547 8.58469 27.5457 7.73014C26.7865 6.43008 25.6346 5.40425 24.2557 4.80014C22.8693 4.19126 21.327 4.03247 19.8457 4.34614C19.175 3.60107 18.3535 3.00725 17.4357 2.60414C16.5153 2.19969 15.52 1.99388 14.5147 2.00014C13.0027 1.99593 11.5276 2.46676 10.2977 3.34614C9.07577 4.21901 8.16395 5.4587 7.69468 6.88514C6.70973 7.08379 5.77797 7.48866 4.96068 8.07314C4.14712 8.65428 3.46726 9.40268 2.96668 10.2681C2.20588 11.5589 1.88068 13.0602 2.03916 14.5501C2.19764 16.04 2.83138 17.4393 3.84668 18.5411C3.52841 19.4833 3.41787 20.4832 3.52265 21.4721C3.62743 22.4611 3.94505 23.4156 4.45368 24.2701C5.21284 25.5702 6.36472 26.596 7.74368 27.2001C9.13 27.809 10.6724 27.9678 12.1537 27.6541C12.824 28.3992 13.6452 28.993 14.5627 29.3961C15.4827 29.8001 16.4787 30.0061 17.4857 30.0001C18.9986 30.0053 20.4748 29.5348 21.7057 28.6551C22.9291 27.7816 23.8417 26.5404 24.3107 25.1121C25.2956 24.9139 26.2274 24.5094 27.0447 23.9251C27.858 23.3436 28.5376 22.5949 29.0377 21.7291C29.7974 20.4383 30.1216 18.9373 29.9624 17.448C29.8032 15.9587 29.1691 14.5602 28.1537 13.4591V13.4601ZM17.5437 28.1701C16.1317 28.1701 15.0387 27.7421 14.0837 26.9551C14.1267 26.9321 14.2027 26.8911 14.2517 26.8611L19.9017 23.6411C20.0423 23.5619 20.1594 23.4468 20.241 23.3074C20.3225 23.1681 20.3655 23.0096 20.3657 22.8481V14.9881L22.7547 16.3481C22.767 16.3545 22.7776 16.3638 22.7856 16.3751C22.7936 16.3864 22.7987 16.3994 22.8007 16.4131V22.9211C22.8007 25.8731 20.3097 28.1701 17.5437 28.1701ZM6.06268 23.3541C5.43989 22.2948 5.21478 21.0484 5.42768 19.8381C5.46968 19.8631 5.54268 19.9081 5.59568 19.9381L11.2457 23.1581C11.3867 23.2396 11.5468 23.2825 11.7097 23.2825C11.8726 23.2825 12.0326 23.2396 12.1737 23.1581L19.0717 19.2281V21.9481C19.0725 21.962 19.0698 21.9759 19.0639 21.9885C19.0579 22.0011 19.0489 22.012 19.0377 22.0201L13.3267 25.2751C12.1017 25.97 10.6532 26.1574 9.29168 25.7971C7.9348 25.441 6.77475 24.5628 6.06268 23.3541ZM4.57368 11.1841C5.19855 10.1168 6.17811 9.30248 7.34168 8.88314V15.5151C7.34012 15.6769 7.38245 15.8361 7.46415 15.9757C7.54586 16.1154 7.66388 16.2302 7.80568 16.3081L14.7027 20.2381L12.3147 21.5981C12.303 21.6058 12.2896 21.6105 12.2757 21.6119C12.2617 21.6133 12.2477 21.6113 12.2347 21.6061L6.52068 18.3491C5.91893 18.0071 5.39058 17.5497 4.96588 17.0032C4.54118 16.4567 4.22847 15.8317 4.04568 15.1641C3.86493 14.499 3.81844 13.8045 3.90892 13.1211C3.9994 12.4378 4.22604 11.7793 4.57368 11.1841ZM24.1967 15.6901L17.2987 11.7601L19.6867 10.4001C19.6984 10.3925 19.7118 10.3877 19.7257 10.3863C19.7396 10.385 19.7537 10.3869 19.7667 10.3921L25.4797 13.6471C26.3517 14.1436 27.0642 14.8783 27.5337 15.7651C28.0004 16.6478 28.2026 17.6464 28.116 18.641C28.0295 19.6357 27.6578 20.5844 27.0457 21.3731C26.4299 22.1683 25.6011 22.7723 24.6557 23.1151V16.4821C24.6574 16.3211 24.6157 16.1625 24.535 16.0231C24.4542 15.8837 24.3373 15.7687 24.1967 15.6901ZM26.5737 12.1571C26.5181 12.1235 26.4621 12.0905 26.4057 12.0581L20.7557 8.83814C20.6145 8.75688 20.4545 8.71412 20.2917 8.71412C20.1288 8.71412 19.9688 8.75688 19.8277 8.83814L12.9297 12.7681V10.0461C12.9289 10.0322 12.9315 10.0184 12.9375 10.0058C12.9434 9.99321 12.9524 9.98233 12.9637 9.97414L18.6757 6.72314C19.5524 6.22471 20.5517 5.98299 21.5593 6.02561C22.5669 6.06823 23.5422 6.39346 24.3737 6.96414C25.1997 7.53109 25.8467 8.32209 26.2387 9.24414C26.6287 10.1641 26.7447 11.1741 26.5737 12.1571ZM11.6317 17.0091L9.24168 15.6491C9.22917 15.643 9.21841 15.6339 9.21037 15.6225C9.20233 15.6112 9.19728 15.598 9.19568 15.5841V9.07514C9.19668 8.07814 9.48568 7.10214 10.0277 6.26114C10.5729 5.41768 11.3466 4.7466 12.2587 4.32614C13.1746 3.90382 14.1908 3.74739 15.1914 3.8747C16.192 4.002 17.1366 4.40791 17.9177 5.04614C17.8611 5.07604 17.8051 5.10704 17.7497 5.13914L12.0997 8.35914C11.959 8.43838 11.8418 8.55353 11.7601 8.69284C11.6784 8.83216 11.6351 8.99064 11.6347 9.15214L11.6317 17.0091ZM12.9287 14.2491L16.0007 12.5001L19.0727 14.2501V17.7501L16.0007 19.4991L12.9287 17.7491V14.2491Z" fill="black"/>
|
||||
</svg>
|
After Width: | Height: | Size: 4.1 KiB |
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import React from 'react';
|
||||
import * as ReactQuery from '@tanstack/react-query';
|
||||
import { renderHook } from '@testing-library/react-hooks/dom';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { httpServiceMock, notificationServiceMock } from '@kbn/core/public/mocks';
|
||||
import { useProviders } from './get_providers';
|
||||
|
||||
const http = httpServiceMock.createStartContract();
|
||||
const toasts = notificationServiceMock.createStartContract();
|
||||
const useQuerySpy = jest.spyOn(ReactQuery, 'useQuery');
|
||||
|
||||
beforeEach(() => jest.resetAllMocks());
|
||||
|
||||
const { getProviders } = jest.requireMock('./get_providers');
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
|
||||
describe('useProviders', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should call useQuery', async () => {
|
||||
renderHook(() => useProviders(http, toasts.toasts), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
return expect(useQuerySpy).toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should return isError = true if api fails', async () => {
|
||||
getProviders.mockResolvedValue('This is an error.');
|
||||
|
||||
renderHook(() => useProviders(http, toasts.toasts), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(useQuerySpy).toHaveBeenCalled());
|
||||
});
|
||||
});
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* 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 { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { ServiceProviderIcon, ServiceProviderName } from './service_provider';
|
||||
import { ServiceProviderKeys } from '../../../../../common/inference/constants';
|
||||
|
||||
jest.mock('../assets/images/elastic.svg', () => 'elasticIcon.svg');
|
||||
jest.mock('../assets/images/hugging_face.svg', () => 'huggingFaceIcon.svg');
|
||||
jest.mock('../assets/images/cohere.svg', () => 'cohereIcon.svg');
|
||||
jest.mock('../assets/images/open_ai.svg', () => 'openAIIcon.svg');
|
||||
|
||||
describe('ServiceProviderIcon component', () => {
|
||||
it('renders Hugging Face icon and name when providerKey is hugging_face', () => {
|
||||
render(<ServiceProviderIcon providerKey={ServiceProviderKeys.hugging_face} />);
|
||||
const icon = screen.getByTestId('icon-service-provider-hugging_face');
|
||||
expect(icon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Open AI icon and name when providerKey is openai', () => {
|
||||
render(<ServiceProviderIcon providerKey={ServiceProviderKeys.openai} />);
|
||||
const icon = screen.getByTestId('icon-service-provider-openai');
|
||||
expect(icon).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ServiceProviderName component', () => {
|
||||
it('renders Hugging Face icon and name when providerKey is hugging_face', () => {
|
||||
render(<ServiceProviderName providerKey={ServiceProviderKeys.hugging_face} />);
|
||||
expect(screen.getByText('Hugging Face')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Open AI icon and name when providerKey is openai', () => {
|
||||
render(<ServiceProviderName providerKey={ServiceProviderKeys.openai} />);
|
||||
expect(screen.getByText('OpenAI')).toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,126 @@
|
|||
/*
|
||||
* 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 { EuiHighlight, EuiIcon } from '@elastic/eui';
|
||||
import React from 'react';
|
||||
import { ServiceProviderKeys } from '../../../../../common/inference/constants';
|
||||
import elasticIcon from '../assets/images/elastic.svg';
|
||||
import huggingFaceIcon from '../assets/images/hugging_face.svg';
|
||||
import cohereIcon from '../assets/images/cohere.svg';
|
||||
import openAIIcon from '../assets/images/open_ai.svg';
|
||||
import azureAIStudioIcon from '../assets/images/azure_ai_studio.svg';
|
||||
import azureOpenAIIcon from '../assets/images/azure_open_ai.svg';
|
||||
import googleAIStudioIcon from '../assets/images/google_ai_studio.svg';
|
||||
import mistralIcon from '../assets/images/mistral.svg';
|
||||
import amazonBedrockIcon from '../assets/images/amazon_bedrock.svg';
|
||||
import anthropicIcon from '../assets/images/anthropic.svg';
|
||||
import alibabaCloudIcon from '../assets/images/alibaba_cloud.svg';
|
||||
import ibmWatsonxIcon from '../assets/images/ibm_watsonx.svg';
|
||||
|
||||
interface ServiceProviderProps {
|
||||
providerKey: ServiceProviderKeys;
|
||||
searchValue?: string;
|
||||
}
|
||||
|
||||
type ProviderSolution = 'Observability' | 'Security' | 'Search';
|
||||
|
||||
interface ServiceProviderRecord {
|
||||
icon: string;
|
||||
name: string;
|
||||
solutions: ProviderSolution[];
|
||||
}
|
||||
|
||||
export const SERVICE_PROVIDERS: Record<ServiceProviderKeys, ServiceProviderRecord> = {
|
||||
[ServiceProviderKeys.amazonbedrock]: {
|
||||
icon: amazonBedrockIcon,
|
||||
name: 'Amazon Bedrock',
|
||||
solutions: ['Observability', 'Security', 'Search'],
|
||||
},
|
||||
[ServiceProviderKeys.azureaistudio]: {
|
||||
icon: azureAIStudioIcon,
|
||||
name: 'Azure AI Studio',
|
||||
solutions: ['Search'],
|
||||
},
|
||||
[ServiceProviderKeys.azureopenai]: {
|
||||
icon: azureOpenAIIcon,
|
||||
name: 'Azure OpenAI',
|
||||
solutions: ['Observability', 'Security', 'Search'],
|
||||
},
|
||||
[ServiceProviderKeys.anthropic]: {
|
||||
icon: anthropicIcon,
|
||||
name: 'Anthropic',
|
||||
solutions: ['Search'],
|
||||
},
|
||||
[ServiceProviderKeys.cohere]: {
|
||||
icon: cohereIcon,
|
||||
name: 'Cohere',
|
||||
solutions: ['Search'],
|
||||
},
|
||||
[ServiceProviderKeys.elasticsearch]: {
|
||||
icon: elasticIcon,
|
||||
name: 'Elasticsearch',
|
||||
solutions: ['Search'],
|
||||
},
|
||||
[ServiceProviderKeys.googleaistudio]: {
|
||||
icon: googleAIStudioIcon,
|
||||
name: 'Google AI Studio',
|
||||
solutions: ['Search'],
|
||||
},
|
||||
[ServiceProviderKeys.googlevertexai]: {
|
||||
icon: googleAIStudioIcon,
|
||||
name: 'Google Vertex AI',
|
||||
solutions: ['Observability', 'Security', 'Search'],
|
||||
},
|
||||
[ServiceProviderKeys.hugging_face]: {
|
||||
icon: huggingFaceIcon,
|
||||
name: 'Hugging Face',
|
||||
solutions: ['Search'],
|
||||
},
|
||||
[ServiceProviderKeys.mistral]: {
|
||||
icon: mistralIcon,
|
||||
name: 'Mistral',
|
||||
solutions: ['Search'],
|
||||
},
|
||||
[ServiceProviderKeys.openai]: {
|
||||
icon: openAIIcon,
|
||||
name: 'OpenAI',
|
||||
solutions: ['Observability', 'Security', 'Search'],
|
||||
},
|
||||
[ServiceProviderKeys['alibabacloud-ai-search']]: {
|
||||
icon: alibabaCloudIcon,
|
||||
name: 'AlibabaCloud AI Search',
|
||||
solutions: ['Search'],
|
||||
},
|
||||
[ServiceProviderKeys.watsonxai]: {
|
||||
icon: ibmWatsonxIcon,
|
||||
name: 'IBM Watsonx',
|
||||
solutions: ['Search'],
|
||||
},
|
||||
};
|
||||
|
||||
export const ServiceProviderIcon: React.FC<ServiceProviderProps> = ({ providerKey }) => {
|
||||
const provider = SERVICE_PROVIDERS[providerKey];
|
||||
|
||||
return provider ? (
|
||||
<EuiIcon data-test-subj={`icon-service-provider-${providerKey}`} type={provider.icon} />
|
||||
) : (
|
||||
<span>{providerKey}</span>
|
||||
);
|
||||
};
|
||||
|
||||
export const ServiceProviderName: React.FC<ServiceProviderProps> = ({
|
||||
providerKey,
|
||||
searchValue,
|
||||
}) => {
|
||||
const provider = SERVICE_PROVIDERS[providerKey];
|
||||
|
||||
return provider ? (
|
||||
<EuiHighlight search={searchValue ?? ''}>{provider.name}</EuiHighlight>
|
||||
) : (
|
||||
<span>{providerKey}</span>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* 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 type { EuiSelectableProps } from '@elastic/eui';
|
||||
import React from 'react';
|
||||
import type { ShallowWrapper } from 'enzyme';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { SelectableProvider } from '.';
|
||||
|
||||
describe('SelectableProvider', () => {
|
||||
const props = {
|
||||
isLoading: false,
|
||||
onClosePopover: jest.fn(),
|
||||
onProviderChange: jest.fn(),
|
||||
getSelectableOptions: jest.fn().mockReturnValue([]),
|
||||
};
|
||||
|
||||
describe('should render', () => {
|
||||
let wrapper: ShallowWrapper;
|
||||
|
||||
describe('provider', () => {
|
||||
beforeAll(() => {
|
||||
wrapper = shallow(<SelectableProvider {...props} />);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('render placeholder', () => {
|
||||
const searchProps: EuiSelectableProps['searchProps'] = wrapper
|
||||
.find('[data-test-subj="selectable-provider-input"]')
|
||||
.prop('searchProps');
|
||||
expect(searchProps?.placeholder).toEqual('Search');
|
||||
});
|
||||
});
|
||||
|
||||
describe('template', () => {
|
||||
beforeAll(() => {
|
||||
wrapper = shallow(<SelectableProvider {...props} />);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('render placeholder', () => {
|
||||
const searchProps: EuiSelectableProps['searchProps'] = wrapper
|
||||
.find('[data-test-subj="selectable-provider-input"]')
|
||||
.prop('searchProps');
|
||||
expect(searchProps?.placeholder).toEqual('Search');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,136 @@
|
|||
/*
|
||||
* 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 type { EuiSelectableOption, EuiSelectableProps } from '@elastic/eui';
|
||||
import { EuiSelectable, EuiFlexGroup, EuiFlexItem, EuiBadge } from '@elastic/eui';
|
||||
import React, { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { ServiceProviderKeys } from '../../../../../common/inference/constants';
|
||||
import {
|
||||
SERVICE_PROVIDERS,
|
||||
ServiceProviderIcon,
|
||||
ServiceProviderName,
|
||||
} from '../render_service_provider/service_provider';
|
||||
|
||||
/**
|
||||
* Modifies options by creating new property `providerTitle`(with value of `title`), and by setting `title` to undefined.
|
||||
* Thus prevents appearing default browser tooltip on option hover (attribute `title` that gets rendered on li element)
|
||||
*
|
||||
* @param {EuiSelectableOption[]} options
|
||||
* @returns {EuiSelectableOption[]} modified options
|
||||
*/
|
||||
|
||||
export interface SelectableProviderProps {
|
||||
isLoading: boolean;
|
||||
getSelectableOptions: (searchProviderValue?: string) => EuiSelectableOption[];
|
||||
onClosePopover: () => void;
|
||||
onProviderChange: (provider?: string) => void;
|
||||
}
|
||||
|
||||
const SelectableProviderComponent: React.FC<SelectableProviderProps> = ({
|
||||
isLoading,
|
||||
getSelectableOptions,
|
||||
onClosePopover,
|
||||
onProviderChange,
|
||||
}) => {
|
||||
const [searchProviderValue, setSearchProviderValue] = useState<string>('');
|
||||
const onSearchProvider = useCallback(
|
||||
(val: string) => {
|
||||
setSearchProviderValue(val);
|
||||
},
|
||||
[setSearchProviderValue]
|
||||
);
|
||||
|
||||
const renderProviderOption = useCallback<NonNullable<EuiSelectableProps['renderOption']>>(
|
||||
(option, searchValue) => {
|
||||
const provider = SERVICE_PROVIDERS[option.label as ServiceProviderKeys];
|
||||
return (
|
||||
<EuiFlexGroup gutterSize="s" alignItems="center" responsive={false}>
|
||||
<EuiFlexItem grow={false}>
|
||||
<ServiceProviderIcon providerKey={option.label as ServiceProviderKeys} />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={true}>
|
||||
<EuiFlexGroup gutterSize="none" direction="column" responsive={false}>
|
||||
<EuiFlexItem data-test-subj="provider">
|
||||
<ServiceProviderName
|
||||
providerKey={option.label as ServiceProviderKeys}
|
||||
searchValue={searchValue}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFlexGroup gutterSize="xs" responsive={false}>
|
||||
{provider &&
|
||||
provider.solutions.map((solution) => (
|
||||
<EuiFlexItem>
|
||||
<EuiBadge color="hollow">{solution}</EuiBadge>
|
||||
</EuiFlexItem>
|
||||
))}
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleProviderChange = useCallback<NonNullable<EuiSelectableProps['onChange']>>(
|
||||
(options) => {
|
||||
const selectedProvider = options.filter((option) => option.checked === 'on');
|
||||
if (selectedProvider != null && selectedProvider.length > 0) {
|
||||
onProviderChange(selectedProvider[0].label);
|
||||
}
|
||||
onClosePopover();
|
||||
},
|
||||
[onClosePopover, onProviderChange]
|
||||
);
|
||||
|
||||
const EuiSelectableContent = useCallback<NonNullable<EuiSelectableProps['children']>>(
|
||||
(list, search) => (
|
||||
<>
|
||||
{search}
|
||||
{list}
|
||||
</>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const searchProps: EuiSelectableProps['searchProps'] = useMemo(
|
||||
() => ({
|
||||
'data-test-subj': 'provider-super-select-search-box',
|
||||
placeholder: i18n.translate(
|
||||
'xpack.stackConnectors.components.inference.selectable.providerSearch',
|
||||
{
|
||||
defaultMessage: 'Search',
|
||||
}
|
||||
),
|
||||
onSearch: onSearchProvider,
|
||||
incremental: false,
|
||||
compressed: true,
|
||||
fullWidth: true,
|
||||
}),
|
||||
[onSearchProvider]
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiSelectable
|
||||
data-test-subj="selectable-provider-input"
|
||||
isLoading={isLoading}
|
||||
renderOption={renderProviderOption}
|
||||
onChange={handleProviderChange}
|
||||
searchable
|
||||
searchProps={searchProps}
|
||||
singleSelection={true}
|
||||
options={getSelectableOptions(searchProviderValue)}
|
||||
>
|
||||
{EuiSelectableContent}
|
||||
</EuiSelectable>
|
||||
);
|
||||
};
|
||||
|
||||
export const SelectableProvider = memo(SelectableProviderComponent);
|
|
@ -0,0 +1,107 @@
|
|||
/*
|
||||
* 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 getRequiredMessage = (field: string) => {
|
||||
return i18n.translate('xpack.stackConnectors.components.inference.requiredGenericTextField', {
|
||||
defaultMessage: '{field} is required.',
|
||||
values: { field },
|
||||
});
|
||||
};
|
||||
|
||||
export const INPUT_INVALID = i18n.translate(
|
||||
'xpack.stackConnectors.inference.params.error.invalidInputText',
|
||||
{
|
||||
defaultMessage: 'Input does not have a valid Array format.',
|
||||
}
|
||||
);
|
||||
|
||||
export const INVALID_ACTION = i18n.translate(
|
||||
'xpack.stackConnectors.components.inference.invalidActionText',
|
||||
{
|
||||
defaultMessage: 'Invalid action name.',
|
||||
}
|
||||
);
|
||||
|
||||
export const BODY = i18n.translate('xpack.stackConnectors.components.inference.bodyFieldLabel', {
|
||||
defaultMessage: 'Body',
|
||||
});
|
||||
|
||||
export const INPUT = i18n.translate(
|
||||
'xpack.stackConnectors.components.inference.completionInputLabel',
|
||||
{
|
||||
defaultMessage: 'Input',
|
||||
}
|
||||
);
|
||||
|
||||
export const INPUT_TYPE = i18n.translate(
|
||||
'xpack.stackConnectors.components.inference.completionInputTypeLabel',
|
||||
{
|
||||
defaultMessage: 'Input type',
|
||||
}
|
||||
);
|
||||
|
||||
export const QUERY = i18n.translate('xpack.stackConnectors.components.inference.rerankQueryLabel', {
|
||||
defaultMessage: 'Query',
|
||||
});
|
||||
|
||||
export const BODY_DESCRIPTION = i18n.translate(
|
||||
'xpack.stackConnectors.components.inference.bodyCodeEditorAriaLabel',
|
||||
{
|
||||
defaultMessage: 'Code editor',
|
||||
}
|
||||
);
|
||||
|
||||
export const TASK_TYPE = i18n.translate(
|
||||
'xpack.stackConnectors.components.inference.taskTypeFieldLabel',
|
||||
{
|
||||
defaultMessage: 'Task type',
|
||||
}
|
||||
);
|
||||
|
||||
export const PROVIDER = i18n.translate(
|
||||
'xpack.stackConnectors.components.inference.providerFieldLabel',
|
||||
{
|
||||
defaultMessage: 'Provider',
|
||||
}
|
||||
);
|
||||
|
||||
export const PROVIDER_REQUIRED = i18n.translate(
|
||||
'xpack.stackConnectors.components.inference.error.requiredProviderText',
|
||||
{
|
||||
defaultMessage: 'Provider is required.',
|
||||
}
|
||||
);
|
||||
|
||||
export const DOCUMENTATION = i18n.translate(
|
||||
'xpack.stackConnectors.components.inference.documentation',
|
||||
{
|
||||
defaultMessage: 'Inference API documentation',
|
||||
}
|
||||
);
|
||||
|
||||
export const SELECT_PROVIDER = i18n.translate(
|
||||
'xpack.stackConnectors.components.inference.selectProvider',
|
||||
{
|
||||
defaultMessage: 'Select a service',
|
||||
}
|
||||
);
|
||||
|
||||
export const COPY_TOOLTIP = i18n.translate(
|
||||
'xpack.stackConnectors.components.inference.copy.tooltip',
|
||||
{
|
||||
defaultMessage: 'Copy to clipboard',
|
||||
}
|
||||
);
|
||||
|
||||
export const COPIED_TOOLTIP = i18n.translate(
|
||||
'xpack.stackConnectors.components.inference.copied.tooltip',
|
||||
{
|
||||
defaultMessage: 'Copied!',
|
||||
}
|
||||
);
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* 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 { UserConfiguredActionConnector } from '@kbn/triggers-actions-ui-plugin/public/types';
|
||||
import { ActionTypeModel as ConnectorTypeModel } from '@kbn/triggers-actions-ui-plugin/public';
|
||||
import { SUB_ACTION } from '../../../common/inference/constants';
|
||||
import {
|
||||
ChatCompleteParams,
|
||||
RerankParams,
|
||||
SparseEmbeddingParams,
|
||||
TextEmbeddingParams,
|
||||
} from '../../../common/inference/types';
|
||||
import { ConfigProperties } from '../lib/dynamic_config/types';
|
||||
|
||||
export type InferenceActionParams =
|
||||
| { subAction: SUB_ACTION.COMPLETION; subActionParams: ChatCompleteParams }
|
||||
| { subAction: SUB_ACTION.RERANK; subActionParams: RerankParams }
|
||||
| { subAction: SUB_ACTION.SPARSE_EMBEDDING; subActionParams: SparseEmbeddingParams }
|
||||
| { subAction: SUB_ACTION.TEXT_EMBEDDING; subActionParams: TextEmbeddingParams };
|
||||
|
||||
export type FieldsConfiguration = Record<string, ConfigProperties>;
|
||||
|
||||
export interface Config {
|
||||
taskType: string;
|
||||
taskTypeConfig?: Record<string, unknown>;
|
||||
inferenceId: string;
|
||||
provider: string;
|
||||
providerConfig?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Secrets {
|
||||
providerSecrets?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type InferenceConnector = ConnectorTypeModel<Config, Secrets, InferenceActionParams>;
|
||||
|
||||
export type InferenceActionConnector = UserConfiguredActionConnector<Config, Secrets>;
|
|
@ -0,0 +1,360 @@
|
|||
/*
|
||||
* 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, { useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
EuiAccordion,
|
||||
EuiFieldText,
|
||||
EuiFieldPassword,
|
||||
EuiSwitch,
|
||||
EuiTextArea,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiIcon,
|
||||
EuiFieldNumber,
|
||||
EuiCheckableCard,
|
||||
useGeneratedHtmlId,
|
||||
EuiSpacer,
|
||||
EuiSuperSelect,
|
||||
EuiText,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import { isEmpty } from 'lodash/fp';
|
||||
import {
|
||||
ensureBooleanType,
|
||||
ensureCorrectTyping,
|
||||
ensureStringType,
|
||||
} from './connector_configuration_utils';
|
||||
import { ConfigEntryView, DisplayType } from './types';
|
||||
|
||||
interface ConnectorConfigurationFieldProps {
|
||||
configEntry: ConfigEntryView;
|
||||
isLoading: boolean;
|
||||
setConfigValue: (value: number | string | boolean | null) => void;
|
||||
}
|
||||
|
||||
interface ConfigInputFieldProps {
|
||||
configEntry: ConfigEntryView;
|
||||
isLoading: boolean;
|
||||
validateAndSetConfigValue: (value: string | boolean) => void;
|
||||
}
|
||||
export const ConfigInputField: React.FC<ConfigInputFieldProps> = ({
|
||||
configEntry,
|
||||
isLoading,
|
||||
validateAndSetConfigValue,
|
||||
}) => {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
const { isValid, placeholder, value, default_value, key } = configEntry;
|
||||
const [innerValue, setInnerValue] = useState(
|
||||
!value || value.toString().length === 0 ? default_value : value
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setInnerValue(!value || value.toString().length === 0 ? default_value : value);
|
||||
}, [default_value, value]);
|
||||
return (
|
||||
<EuiFieldText
|
||||
disabled={isLoading}
|
||||
data-test-subj={`${key}-input`}
|
||||
fullWidth
|
||||
value={ensureStringType(innerValue)}
|
||||
isInvalid={!isValid}
|
||||
onChange={(event) => {
|
||||
setInnerValue(event.target.value);
|
||||
validateAndSetConfigValue(event.target.value);
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const ConfigSwitchField: React.FC<ConfigInputFieldProps> = ({
|
||||
configEntry,
|
||||
isLoading,
|
||||
validateAndSetConfigValue,
|
||||
}) => {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
const { label, value, default_value, key } = configEntry;
|
||||
const [innerValue, setInnerValue] = useState(value ?? default_value);
|
||||
useEffect(() => {
|
||||
setInnerValue(value ?? default_value);
|
||||
}, [default_value, value]);
|
||||
return (
|
||||
<EuiSwitch
|
||||
checked={ensureBooleanType(innerValue)}
|
||||
data-test-subj={`${key}-switch`}
|
||||
disabled={isLoading}
|
||||
label={<p>{label}</p>}
|
||||
onChange={(event) => {
|
||||
setInnerValue(event.target.checked);
|
||||
validateAndSetConfigValue(event.target.checked);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const ConfigInputTextArea: React.FC<ConfigInputFieldProps> = ({
|
||||
isLoading,
|
||||
configEntry,
|
||||
validateAndSetConfigValue,
|
||||
}) => {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
const { isValid, placeholder, value, default_value, key } = configEntry;
|
||||
const [innerValue, setInnerValue] = useState(value ?? default_value);
|
||||
useEffect(() => {
|
||||
setInnerValue(value ?? default_value);
|
||||
}, [default_value, value]);
|
||||
return (
|
||||
<EuiTextArea
|
||||
disabled={isLoading}
|
||||
fullWidth
|
||||
data-test-subj={`${key}-textarea`}
|
||||
// ensures placeholder shows up when value is empty string
|
||||
value={ensureStringType(innerValue)}
|
||||
isInvalid={!isValid}
|
||||
onChange={(event) => {
|
||||
setInnerValue(event.target.value);
|
||||
validateAndSetConfigValue(event.target.value);
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const ConfigNumberField: React.FC<ConfigInputFieldProps> = ({
|
||||
configEntry,
|
||||
isLoading,
|
||||
validateAndSetConfigValue,
|
||||
}) => {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
const { isValid, placeholder, value, default_value, key } = configEntry;
|
||||
const [innerValue, setInnerValue] = useState(value ?? default_value);
|
||||
useEffect(() => {
|
||||
setInnerValue(!value || value.toString().length === 0 ? default_value : value);
|
||||
}, [default_value, value]);
|
||||
return (
|
||||
<EuiFieldNumber
|
||||
fullWidth
|
||||
disabled={isLoading}
|
||||
data-test-subj={`${key}-number`}
|
||||
value={innerValue as number}
|
||||
isInvalid={!isValid}
|
||||
onChange={(event) => {
|
||||
const newValue = isEmpty(event.target.value) ? '0' : event.target.value;
|
||||
setInnerValue(newValue);
|
||||
validateAndSetConfigValue(newValue);
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const ConfigCheckableField: React.FC<ConfigInputFieldProps> = ({
|
||||
configEntry,
|
||||
validateAndSetConfigValue,
|
||||
}) => {
|
||||
const radioCardId = useGeneratedHtmlId({ prefix: 'radioCard' });
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
const { value, options, default_value } = configEntry;
|
||||
const [innerValue, setInnerValue] = useState(value ?? default_value);
|
||||
useEffect(() => {
|
||||
setInnerValue(value ?? default_value);
|
||||
}, [default_value, value]);
|
||||
return (
|
||||
<>
|
||||
{options?.map((o) => (
|
||||
<>
|
||||
<EuiCheckableCard
|
||||
id={radioCardId}
|
||||
label={o.label}
|
||||
value={innerValue as any}
|
||||
checked={innerValue === o.value}
|
||||
onChange={(event) => {
|
||||
setInnerValue(o.value);
|
||||
validateAndSetConfigValue(o.value);
|
||||
}}
|
||||
/>
|
||||
<EuiSpacer size="s" />
|
||||
</>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ConfigSensitiveTextArea: React.FC<ConfigInputFieldProps> = ({
|
||||
isLoading,
|
||||
configEntry,
|
||||
validateAndSetConfigValue,
|
||||
}) => {
|
||||
const { key, label } = configEntry;
|
||||
return (
|
||||
<EuiAccordion id={key + '-accordion'} buttonContent={<p>{label}</p>}>
|
||||
<ConfigInputTextArea
|
||||
isLoading={isLoading}
|
||||
configEntry={configEntry}
|
||||
validateAndSetConfigValue={validateAndSetConfigValue}
|
||||
/>
|
||||
</EuiAccordion>
|
||||
);
|
||||
};
|
||||
|
||||
export const ConfigInputPassword: React.FC<ConfigInputFieldProps> = ({
|
||||
isLoading,
|
||||
configEntry,
|
||||
validateAndSetConfigValue,
|
||||
}) => {
|
||||
const { value, key } = configEntry;
|
||||
const [innerValue, setInnerValue] = useState(value ?? null);
|
||||
useEffect(() => {
|
||||
setInnerValue(value ?? null);
|
||||
}, [value]);
|
||||
return (
|
||||
<>
|
||||
<EuiFieldPassword
|
||||
fullWidth
|
||||
disabled={isLoading}
|
||||
data-test-subj={`${key}-password`}
|
||||
type="dual"
|
||||
value={ensureStringType(innerValue)}
|
||||
onChange={(event) => {
|
||||
setInnerValue(event.target.value);
|
||||
validateAndSetConfigValue(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ConfigSelectField: React.FC<ConfigInputFieldProps> = ({
|
||||
configEntry,
|
||||
isLoading,
|
||||
validateAndSetConfigValue,
|
||||
}) => {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
const { isValid, options, value, default_value } = configEntry;
|
||||
const [innerValue, setInnerValue] = useState(value ?? default_value);
|
||||
const optionsRes = options?.map((o) => ({
|
||||
value: o.value,
|
||||
inputDisplay: (
|
||||
<EuiFlexGroup gutterSize="s" alignItems="center" responsive={false}>
|
||||
{o.icon ? (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIcon color="subdued" style={{ lineHeight: 'inherit' }} type={o.icon} />
|
||||
</EuiFlexItem>
|
||||
) : null}
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiText>{o.label}</EuiText>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
),
|
||||
}));
|
||||
return (
|
||||
<EuiSuperSelect
|
||||
fullWidth
|
||||
isInvalid={!isValid}
|
||||
disabled={isLoading}
|
||||
options={optionsRes as any}
|
||||
valueOfSelected={innerValue as any}
|
||||
onChange={(newValue) => {
|
||||
setInnerValue(newValue);
|
||||
validateAndSetConfigValue(newValue);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const ConnectorConfigurationField: React.FC<ConnectorConfigurationFieldProps> = ({
|
||||
configEntry,
|
||||
isLoading,
|
||||
setConfigValue,
|
||||
}) => {
|
||||
const validateAndSetConfigValue = (value: number | string | boolean) => {
|
||||
setConfigValue(ensureCorrectTyping(configEntry.type, value));
|
||||
};
|
||||
|
||||
const { key, display, sensitive } = configEntry;
|
||||
|
||||
switch (display) {
|
||||
case DisplayType.DROPDOWN:
|
||||
return (
|
||||
<ConfigSelectField
|
||||
key={key}
|
||||
isLoading={isLoading}
|
||||
configEntry={configEntry}
|
||||
validateAndSetConfigValue={validateAndSetConfigValue}
|
||||
/>
|
||||
);
|
||||
|
||||
case DisplayType.CHECKABLE:
|
||||
return (
|
||||
<ConfigCheckableField
|
||||
key={key}
|
||||
isLoading={isLoading}
|
||||
configEntry={configEntry}
|
||||
validateAndSetConfigValue={validateAndSetConfigValue}
|
||||
/>
|
||||
);
|
||||
|
||||
case DisplayType.NUMERIC:
|
||||
return (
|
||||
<ConfigNumberField
|
||||
key={key}
|
||||
isLoading={isLoading}
|
||||
configEntry={configEntry}
|
||||
validateAndSetConfigValue={validateAndSetConfigValue}
|
||||
/>
|
||||
);
|
||||
|
||||
case DisplayType.TEXTAREA:
|
||||
const textarea = (
|
||||
<ConfigInputTextArea
|
||||
key={sensitive ? key + '-sensitive-text-area' : key + 'text-area'}
|
||||
isLoading={isLoading}
|
||||
configEntry={configEntry}
|
||||
validateAndSetConfigValue={validateAndSetConfigValue}
|
||||
/>
|
||||
);
|
||||
|
||||
return sensitive ? (
|
||||
<>
|
||||
<ConfigSensitiveTextArea
|
||||
isLoading={isLoading}
|
||||
configEntry={configEntry}
|
||||
validateAndSetConfigValue={validateAndSetConfigValue}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
textarea
|
||||
);
|
||||
|
||||
case DisplayType.TOGGLE:
|
||||
return (
|
||||
<ConfigSwitchField
|
||||
isLoading={isLoading}
|
||||
configEntry={configEntry}
|
||||
validateAndSetConfigValue={validateAndSetConfigValue}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return sensitive ? (
|
||||
<ConfigInputPassword
|
||||
isLoading={isLoading}
|
||||
configEntry={configEntry}
|
||||
validateAndSetConfigValue={validateAndSetConfigValue}
|
||||
/>
|
||||
) : (
|
||||
<ConfigInputField
|
||||
key={key}
|
||||
isLoading={isLoading}
|
||||
configEntry={configEntry}
|
||||
validateAndSetConfigValue={validateAndSetConfigValue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
|
@ -0,0 +1,136 @@
|
|||
/*
|
||||
* 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 {
|
||||
EuiCallOut,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiFormRow,
|
||||
EuiPanel,
|
||||
EuiSpacer,
|
||||
EuiText,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { ConfigEntryView, DisplayType } from './types';
|
||||
import { ConnectorConfigurationField } from './connector_configuration_field';
|
||||
|
||||
interface ConnectorConfigurationFormItemsProps {
|
||||
isLoading: boolean;
|
||||
items: ConfigEntryView[];
|
||||
setConfigEntry: (key: string, value: string | number | boolean | null) => void;
|
||||
direction?: 'column' | 'row' | 'rowReverse' | 'columnReverse' | undefined;
|
||||
itemsGrow?: boolean;
|
||||
}
|
||||
|
||||
export const ConnectorConfigurationFormItems: React.FC<ConnectorConfigurationFormItemsProps> = ({
|
||||
isLoading,
|
||||
items,
|
||||
setConfigEntry,
|
||||
direction,
|
||||
itemsGrow,
|
||||
}) => {
|
||||
return (
|
||||
<EuiFlexGroup direction={direction} data-test-subj="connector-configuration-fields">
|
||||
{items.map((configEntry) => {
|
||||
const {
|
||||
depends_on: dependencies,
|
||||
key,
|
||||
display,
|
||||
isValid,
|
||||
label,
|
||||
sensitive,
|
||||
tooltip,
|
||||
validationErrors,
|
||||
required,
|
||||
} = configEntry;
|
||||
|
||||
const helpText = tooltip;
|
||||
// toggle and sensitive textarea labels go next to the element, not in the row
|
||||
const rowLabel =
|
||||
display === DisplayType.TOGGLE || (display === DisplayType.TEXTAREA && sensitive) ? (
|
||||
<></>
|
||||
) : tooltip ? (
|
||||
<EuiFlexGroup gutterSize="xs">
|
||||
<EuiFlexItem>
|
||||
<p>{label}</p>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
) : (
|
||||
<p>{label}</p>
|
||||
);
|
||||
|
||||
const optionalLabel = !required ? (
|
||||
<EuiText color="subdued" size="xs">
|
||||
{i18n.translate('xpack.stackConnectors.components.inference.config.optionalValue', {
|
||||
defaultMessage: 'Optional',
|
||||
})}
|
||||
</EuiText>
|
||||
) : undefined;
|
||||
|
||||
if (dependencies?.length > 0) {
|
||||
return (
|
||||
<EuiFlexItem key={key} grow={itemsGrow}>
|
||||
<EuiPanel color="subdued" borderRadius="none">
|
||||
<EuiFormRow
|
||||
fullWidth={true}
|
||||
label={rowLabel}
|
||||
helpText={helpText}
|
||||
error={validationErrors}
|
||||
isInvalid={!isValid}
|
||||
labelAppend={optionalLabel}
|
||||
data-test-subj={`connector-configuration-formrow-${key}`}
|
||||
>
|
||||
<ConnectorConfigurationField
|
||||
configEntry={configEntry}
|
||||
isLoading={isLoading}
|
||||
setConfigValue={(value) => {
|
||||
setConfigEntry(configEntry.key, value);
|
||||
}}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiPanel>
|
||||
</EuiFlexItem>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<EuiFlexItem key={key}>
|
||||
<EuiFormRow
|
||||
label={rowLabel}
|
||||
fullWidth
|
||||
helpText={helpText}
|
||||
error={validationErrors}
|
||||
isInvalid={!isValid}
|
||||
labelAppend={optionalLabel}
|
||||
data-test-subj={`connector-configuration-formrow-${key}`}
|
||||
>
|
||||
<ConnectorConfigurationField
|
||||
configEntry={configEntry}
|
||||
isLoading={isLoading}
|
||||
setConfigValue={(value) => {
|
||||
setConfigEntry(configEntry.key, value);
|
||||
}}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
{configEntry.sensitive ? (
|
||||
<>
|
||||
<EuiSpacer size="s" />
|
||||
<EuiCallOut
|
||||
size="s"
|
||||
color="warning"
|
||||
title={`You will need to reenter you ${configEntry.label} each time you edit the connector`}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</EuiFlexItem>
|
||||
);
|
||||
})}
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,55 @@
|
|||
/*
|
||||
* 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 { ConfigProperties, FieldType } from './types';
|
||||
|
||||
export type ConnectorConfigEntry = ConfigProperties & { key: string };
|
||||
|
||||
export const validIntInput = (value: string | number | boolean | null): boolean => {
|
||||
// reject non integers (including x.0 floats), but don't validate if empty
|
||||
return (value !== null || value !== '') &&
|
||||
(isNaN(Number(value)) ||
|
||||
!Number.isSafeInteger(Number(value)) ||
|
||||
ensureStringType(value).indexOf('.') >= 0)
|
||||
? false
|
||||
: true;
|
||||
};
|
||||
|
||||
export const ensureCorrectTyping = (
|
||||
type: FieldType,
|
||||
value: string | number | boolean | null
|
||||
): string | number | boolean | null => {
|
||||
switch (type) {
|
||||
case FieldType.INTEGER:
|
||||
return validIntInput(value) ? ensureIntType(value) : value;
|
||||
case FieldType.BOOLEAN:
|
||||
return ensureBooleanType(value);
|
||||
default:
|
||||
return ensureStringType(value);
|
||||
}
|
||||
};
|
||||
|
||||
export const ensureStringType = (value: string | number | boolean | null): string => {
|
||||
return value !== null ? String(value) : '';
|
||||
};
|
||||
|
||||
export const ensureIntType = (value: string | number | boolean | null): number | null => {
|
||||
// int is null-safe to prevent empty values from becoming zeroes
|
||||
if (value === null || value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parseInt(String(value), 10);
|
||||
};
|
||||
|
||||
export const ensureBooleanType = (value: string | number | boolean | null): boolean => {
|
||||
return Boolean(value);
|
||||
};
|
||||
|
||||
export const hasUiRestrictions = (configEntry: Partial<ConnectorConfigEntry>) => {
|
||||
return (configEntry.ui_restrictions ?? []).length > 0;
|
||||
};
|
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
* 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 enum DisplayType {
|
||||
TEXTBOX = 'textbox',
|
||||
TEXTAREA = 'textarea',
|
||||
NUMERIC = 'numeric',
|
||||
TOGGLE = 'toggle',
|
||||
DROPDOWN = 'dropdown',
|
||||
CHECKABLE = 'checkable',
|
||||
}
|
||||
|
||||
export interface SelectOption {
|
||||
label: string;
|
||||
value: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface Dependency {
|
||||
field: string;
|
||||
value: string | number | boolean | null;
|
||||
}
|
||||
|
||||
export enum FieldType {
|
||||
STRING = 'str',
|
||||
INTEGER = 'int',
|
||||
LIST = 'list',
|
||||
BOOLEAN = 'bool',
|
||||
}
|
||||
|
||||
export interface ConfigCategoryProperties {
|
||||
label: string;
|
||||
order: number;
|
||||
type: 'category';
|
||||
}
|
||||
|
||||
export interface Validation {
|
||||
constraint: string | number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface ConfigProperties {
|
||||
category?: string;
|
||||
default_value: string | number | boolean | null;
|
||||
depends_on: Dependency[];
|
||||
display: DisplayType;
|
||||
label: string;
|
||||
options?: SelectOption[];
|
||||
order?: number | null;
|
||||
placeholder?: string;
|
||||
required: boolean;
|
||||
sensitive: boolean;
|
||||
tooltip: string | null;
|
||||
type: FieldType;
|
||||
ui_restrictions: string[];
|
||||
validations: Validation[];
|
||||
value: string | number | boolean | null;
|
||||
}
|
||||
|
||||
interface ConfigEntry extends ConfigProperties {
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface ConfigEntryView extends ConfigEntry {
|
||||
isValid: boolean;
|
||||
validationErrors: string[];
|
||||
}
|
|
@ -20,6 +20,7 @@ import { getConnectorType as getIndexConnectorType } from './es_index';
|
|||
import { getConnectorType as getOpenAIConnectorType } from './openai';
|
||||
import { getConnectorType as getBedrockConnectorType } from './bedrock';
|
||||
import { getConnectorType as getGeminiConnectorType } from './gemini';
|
||||
import { getConnectorType as getInferenceConnectorType } from './inference';
|
||||
import { getConnectorType as getPagerDutyConnectorType } from './pagerduty';
|
||||
import { getConnectorType as getSwimlaneConnectorType } from './swimlane';
|
||||
import { getConnectorType as getServerLogConnectorType } from './server_log';
|
||||
|
@ -118,4 +119,7 @@ export function registerConnectorTypes({
|
|||
if (experimentalFeatures.crowdstrikeConnectorOn) {
|
||||
actions.registerSubActionConnectorType(getCrowdstrikeConnectorType());
|
||||
}
|
||||
if (experimentalFeatures.inferenceConnectorOn) {
|
||||
actions.registerSubActionConnectorType(getInferenceConnectorType());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,85 @@
|
|||
/*
|
||||
* 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 { configValidator, getConnectorType } from '.';
|
||||
import { Config, Secrets } from '../../../common/inference/types';
|
||||
import { SubActionConnectorType } from '@kbn/actions-plugin/server/sub_action_framework/types';
|
||||
import { DEFAULT_PROVIDER, DEFAULT_TASK_TYPE } from '../../../common/inference/constants';
|
||||
import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks';
|
||||
import { InferencePutResponse } from '@elastic/elasticsearch/lib/api/types';
|
||||
|
||||
let connectorType: SubActionConnectorType<Config, Secrets>;
|
||||
let configurationUtilities: jest.Mocked<ActionsConfigurationUtilities>;
|
||||
|
||||
const mockEsClient = elasticsearchClientMock.createClusterClient().asScoped().asInternalUser;
|
||||
|
||||
const mockResponse: Promise<InferencePutResponse> = Promise.resolve({
|
||||
inference_id: 'test',
|
||||
service: 'openai',
|
||||
service_settings: {},
|
||||
task_settings: {},
|
||||
task_type: 'completion',
|
||||
});
|
||||
|
||||
describe('AI Connector', () => {
|
||||
beforeEach(() => {
|
||||
configurationUtilities = actionsConfigMock.create();
|
||||
connectorType = getConnectorType();
|
||||
});
|
||||
test('exposes the connector as `AI Connector` with id `.inference`', () => {
|
||||
mockEsClient.inference.put.mockResolvedValue(mockResponse);
|
||||
expect(connectorType.id).toEqual('.inference');
|
||||
expect(connectorType.name).toEqual('AI Connector');
|
||||
});
|
||||
describe('config validation', () => {
|
||||
test('config validation passes when only required fields are provided', () => {
|
||||
const config: Config = {
|
||||
providerConfig: {
|
||||
url: 'https://api.openai.com/v1/chat/completions',
|
||||
},
|
||||
provider: DEFAULT_PROVIDER,
|
||||
taskType: DEFAULT_TASK_TYPE,
|
||||
inferenceId: 'test',
|
||||
taskTypeConfig: {},
|
||||
};
|
||||
|
||||
expect(configValidator(config, { configurationUtilities })).toEqual(config);
|
||||
});
|
||||
|
||||
test('config validation failed when the task type is empty', () => {
|
||||
const config: Config = {
|
||||
providerConfig: {},
|
||||
provider: 'openai',
|
||||
taskType: '',
|
||||
inferenceId: 'test',
|
||||
taskTypeConfig: {},
|
||||
};
|
||||
expect(() => {
|
||||
configValidator(config, { configurationUtilities });
|
||||
}).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Error configuring Inference API action: Error: Task type is not supported by Inference Endpoint."`
|
||||
);
|
||||
});
|
||||
|
||||
test('config validation failed when the provider is empty', () => {
|
||||
const config: Config = {
|
||||
providerConfig: {},
|
||||
provider: '',
|
||||
taskType: DEFAULT_TASK_TYPE,
|
||||
inferenceId: 'test',
|
||||
taskTypeConfig: {},
|
||||
};
|
||||
expect(() => {
|
||||
configValidator(config, { configurationUtilities });
|
||||
}).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Error configuring Inference API action: Error: API Provider is not supported by Inference Endpoint."`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,182 @@
|
|||
/*
|
||||
* 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 {
|
||||
GenerativeAIForSearchPlaygroundConnectorFeatureId,
|
||||
GenerativeAIForSecurityConnectorFeatureId,
|
||||
} from '@kbn/actions-plugin/common';
|
||||
import { ValidatorServices } from '@kbn/actions-plugin/server/types';
|
||||
import { GenerativeAIForObservabilityConnectorFeatureId } from '@kbn/actions-plugin/common/connector_feature_config';
|
||||
import { InferenceTaskType } from '@elastic/elasticsearch/lib/api/types';
|
||||
import { ElasticsearchClient, Logger } from '@kbn/core/server';
|
||||
import {
|
||||
INFERENCE_CONNECTOR_TITLE,
|
||||
INFERENCE_CONNECTOR_ID,
|
||||
ServiceProviderKeys,
|
||||
SUB_ACTION,
|
||||
} from '../../../common/inference/constants';
|
||||
import { ConfigSchema, SecretsSchema } from '../../../common/inference/schema';
|
||||
import { Config, Secrets } from '../../../common/inference/types';
|
||||
import { InferenceConnector } from './inference';
|
||||
import { unflattenObject } from '../lib/unflatten_object';
|
||||
|
||||
const deleteInferenceEndpoint = async (
|
||||
inferenceId: string,
|
||||
taskType: InferenceTaskType,
|
||||
logger: Logger,
|
||||
esClient: ElasticsearchClient
|
||||
) => {
|
||||
try {
|
||||
await esClient.inference.delete({
|
||||
task_type: taskType,
|
||||
inference_id: inferenceId,
|
||||
});
|
||||
logger.debug(
|
||||
`Inference endpoint for task type "${taskType}" and inference id ${inferenceId} was successfuly deleted`
|
||||
);
|
||||
} catch (e) {
|
||||
logger.warn(
|
||||
`Failed to delete inference endpoint for task type "${taskType}" and inference id ${inferenceId}. Error: ${e.message}`
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
export const getConnectorType = (): SubActionConnectorType<Config, Secrets> => ({
|
||||
id: INFERENCE_CONNECTOR_ID,
|
||||
name: INFERENCE_CONNECTOR_TITLE,
|
||||
getService: (params) => new InferenceConnector(params),
|
||||
schema: {
|
||||
config: ConfigSchema,
|
||||
secrets: SecretsSchema,
|
||||
},
|
||||
validators: [{ type: ValidatorType.CONFIG, validator: configValidator }],
|
||||
supportedFeatureIds: [
|
||||
GenerativeAIForSecurityConnectorFeatureId,
|
||||
GenerativeAIForSearchPlaygroundConnectorFeatureId,
|
||||
GenerativeAIForObservabilityConnectorFeatureId,
|
||||
],
|
||||
minimumLicenseRequired: 'enterprise' as const,
|
||||
preSaveHook: async ({ config, secrets, logger, services, isUpdate }) => {
|
||||
const esClient = services.scopedClusterClient.asInternalUser;
|
||||
try {
|
||||
const taskSettings = config?.taskTypeConfig
|
||||
? {
|
||||
...unflattenObject(config?.taskTypeConfig),
|
||||
}
|
||||
: {};
|
||||
const serviceSettings = {
|
||||
...unflattenObject(config?.providerConfig ?? {}),
|
||||
...unflattenObject(secrets?.providerSecrets ?? {}),
|
||||
};
|
||||
|
||||
let inferenceExists = false;
|
||||
try {
|
||||
await esClient?.inference.get({
|
||||
inference_id: config?.inferenceId,
|
||||
task_type: config?.taskType as InferenceTaskType,
|
||||
});
|
||||
inferenceExists = true;
|
||||
} catch (e) {
|
||||
/* throws error if inference endpoint by id does not exist */
|
||||
}
|
||||
if (!isUpdate && inferenceExists) {
|
||||
throw new Error(
|
||||
`Inference with id ${config?.inferenceId} and task type ${config?.taskType} already exists.`
|
||||
);
|
||||
}
|
||||
|
||||
if (isUpdate && inferenceExists && config && config.provider) {
|
||||
// TODO: replace, when update API for inference endpoint exists
|
||||
await deleteInferenceEndpoint(
|
||||
config.inferenceId,
|
||||
config.taskType as InferenceTaskType,
|
||||
logger,
|
||||
esClient
|
||||
);
|
||||
}
|
||||
|
||||
await esClient?.inference.put({
|
||||
inference_id: config?.inferenceId ?? '',
|
||||
task_type: config?.taskType as InferenceTaskType,
|
||||
inference_config: {
|
||||
service: config!.provider,
|
||||
service_settings: serviceSettings,
|
||||
task_settings: taskSettings,
|
||||
},
|
||||
});
|
||||
logger.debug(
|
||||
`Inference endpoint for task type "${config?.taskType}" and inference id ${
|
||||
config?.inferenceId
|
||||
} was successfuly ${isUpdate ? 'updated' : 'created'}`
|
||||
);
|
||||
} catch (e) {
|
||||
logger.warn(
|
||||
`Failed to ${isUpdate ? 'update' : 'create'} inference endpoint for task type "${
|
||||
config?.taskType
|
||||
}" and inference id ${config?.inferenceId}. Error: ${e.message}`
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
postSaveHook: async ({ config, logger, services, wasSuccessful, isUpdate }) => {
|
||||
if (!wasSuccessful && !isUpdate) {
|
||||
const esClient = services.scopedClusterClient.asInternalUser;
|
||||
await deleteInferenceEndpoint(
|
||||
config.inferenceId,
|
||||
config.taskType as InferenceTaskType,
|
||||
logger,
|
||||
esClient
|
||||
);
|
||||
}
|
||||
},
|
||||
postDeleteHook: async ({ config, logger, services }) => {
|
||||
const esClient = services.scopedClusterClient.asInternalUser;
|
||||
await deleteInferenceEndpoint(
|
||||
config.inferenceId,
|
||||
config.taskType as InferenceTaskType,
|
||||
logger,
|
||||
esClient
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const configValidator = (configObject: Config, validatorServices: ValidatorServices) => {
|
||||
try {
|
||||
const { provider, taskType } = configObject;
|
||||
if (!Object.keys(ServiceProviderKeys).includes(provider)) {
|
||||
throw new Error(
|
||||
`API Provider is not supported${
|
||||
provider && provider.length ? `: ${provider}` : ``
|
||||
} by Inference Endpoint.`
|
||||
);
|
||||
}
|
||||
|
||||
if (!Object.keys(SUB_ACTION).includes(taskType.toUpperCase())) {
|
||||
throw new Error(
|
||||
`Task type is not supported${
|
||||
taskType && taskType.length ? `: ${taskType}` : ``
|
||||
} by Inference Endpoint.`
|
||||
);
|
||||
}
|
||||
return configObject;
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
i18n.translate('xpack.stackConnectors.inference.configurationErrorApiProvider', {
|
||||
defaultMessage: 'Error configuring Inference API action: {err}',
|
||||
values: {
|
||||
err: err.toString(),
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
|
@ -0,0 +1,310 @@
|
|||
/*
|
||||
* 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 { InferenceConnector } from './inference';
|
||||
import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.mock';
|
||||
import { loggingSystemMock } from '@kbn/core-logging-server-mocks';
|
||||
import { actionsMock } from '@kbn/actions-plugin/server/mocks';
|
||||
import { PassThrough, Transform } from 'stream';
|
||||
import {} from '@kbn/actions-plugin/server/types';
|
||||
import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks';
|
||||
import { InferenceInferenceResponse } from '@elastic/elasticsearch/lib/api/types';
|
||||
|
||||
const OPENAI_CONNECTOR_ID = '123';
|
||||
const DEFAULT_OPENAI_MODEL = 'gpt-4o';
|
||||
|
||||
describe('InferenceConnector', () => {
|
||||
let mockError: jest.Mock;
|
||||
const logger = loggingSystemMock.createLogger();
|
||||
const mockResponse: InferenceInferenceResponse = {
|
||||
completion: [
|
||||
{
|
||||
result:
|
||||
'Elastic is a company known for developing the Elasticsearch search and analytics engine, which allows for real-time data search, analysis, and visualization. Elasticsearch is part of the larger Elastic Stack (also known as the ELK Stack), which includes:\n\n1. **Elasticsearch**: A distributed, RESTful search and analytics engine capable of addressing a growing number of use cases. As the heart of the Elastic Stack, it centrally stores your data so you can discover the expected and uncover the unexpected.\n \n2. **Logstash**: A server-side data processing pipeline that ingests data from multiple sources simultaneously, transforms it, and sends it to your preferred "stash," such as Elasticsearch.\n \n3. **Kibana**: A data visualization dashboard for Elasticsearch. It allows you to search, view, and interact with data stored in Elasticsearch indices. You can perform advanced data analysis and visualize data in various charts, tables, and maps.\n\n4. **Beats**: Lightweight data shippers for different types of data. They send data from hundreds or thousands of machines and systems to Elasticsearch or Logstash.\n\nThe Elastic Stack is commonly used for various applications, such as log and event data analysis, full-text search, security analytics, business analytics, and more. It is employed across many industries to derive insights from large volumes of structured and unstructured data.\n\nElastic offers both open-source and paid versions of its software, providing a variety of features ranging from basic data ingestion and visualization to advanced machine learning and security capabilities.',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('performApiCompletion', () => {
|
||||
const mockEsClient = elasticsearchClientMock.createClusterClient().asScoped().asInternalUser;
|
||||
|
||||
beforeEach(() => {
|
||||
mockEsClient.inference.inference.mockResolvedValue(mockResponse);
|
||||
mockError = jest.fn().mockImplementation(() => {
|
||||
throw new Error('API Error');
|
||||
});
|
||||
});
|
||||
|
||||
const services = actionsMock.createServices();
|
||||
services.scopedClusterClient = mockEsClient;
|
||||
const connector = new InferenceConnector({
|
||||
configurationUtilities: actionsConfigMock.create(),
|
||||
connector: { id: '1', type: OPENAI_CONNECTOR_ID },
|
||||
config: {
|
||||
provider: 'openai',
|
||||
providerConfig: {
|
||||
url: 'https://api.openai.com/v1/chat/completions',
|
||||
model_id: DEFAULT_OPENAI_MODEL,
|
||||
},
|
||||
taskType: 'completion',
|
||||
inferenceId: 'test',
|
||||
taskTypeConfig: {},
|
||||
},
|
||||
secrets: { providerSecrets: { api_key: '123' } },
|
||||
logger,
|
||||
services,
|
||||
});
|
||||
|
||||
it('uses the completion task_type is supplied', async () => {
|
||||
const response = await connector.performApiCompletion({
|
||||
input: 'What is Elastic?',
|
||||
});
|
||||
expect(mockEsClient.inference.inference).toBeCalledTimes(1);
|
||||
expect(mockEsClient.inference.inference).toHaveBeenCalledWith(
|
||||
{
|
||||
inference_id: 'test',
|
||||
input: 'What is Elastic?',
|
||||
task_type: 'completion',
|
||||
},
|
||||
{ asStream: false }
|
||||
);
|
||||
expect(response).toEqual(mockResponse.completion);
|
||||
});
|
||||
|
||||
it('errors during API calls are properly handled', async () => {
|
||||
// @ts-ignore
|
||||
mockEsClient.inference.inference = mockError;
|
||||
|
||||
await expect(connector.performApiCompletion({ input: 'What is Elastic?' })).rejects.toThrow(
|
||||
'API Error'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('performApiRerank', () => {
|
||||
const mockEsClient = elasticsearchClientMock.createClusterClient().asScoped().asInternalUser;
|
||||
const mockResponseRerank = {
|
||||
rerank: [
|
||||
{
|
||||
index: 2,
|
||||
score: 0.011597361,
|
||||
text: 'leia',
|
||||
},
|
||||
{
|
||||
index: 0,
|
||||
score: 0.006338922,
|
||||
text: 'luke',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockEsClient.inference.inference.mockResolvedValue(mockResponseRerank);
|
||||
mockError = jest.fn().mockImplementation(() => {
|
||||
throw new Error('API Error');
|
||||
});
|
||||
});
|
||||
const services = actionsMock.createServices();
|
||||
services.scopedClusterClient = mockEsClient;
|
||||
it('the API call is successful with correct parameters', async () => {
|
||||
const connectorRerank = new InferenceConnector({
|
||||
configurationUtilities: actionsConfigMock.create(),
|
||||
connector: { id: '1', type: '123' },
|
||||
config: {
|
||||
provider: 'googlevertexai',
|
||||
providerConfig: {
|
||||
model_id: DEFAULT_OPENAI_MODEL,
|
||||
},
|
||||
taskType: 'rerank',
|
||||
inferenceId: 'test-rerank',
|
||||
taskTypeConfig: {},
|
||||
},
|
||||
secrets: { providerSecrets: { api_key: '123' } },
|
||||
logger,
|
||||
services,
|
||||
});
|
||||
const response = await connectorRerank.performApiRerank({
|
||||
input: ['apple', 'banana'],
|
||||
query: 'test',
|
||||
});
|
||||
expect(mockEsClient.inference.inference).toHaveBeenCalledWith(
|
||||
{
|
||||
inference_id: 'test-rerank',
|
||||
input: ['apple', 'banana'],
|
||||
query: 'test',
|
||||
task_type: 'rerank',
|
||||
},
|
||||
{ asStream: false }
|
||||
);
|
||||
expect(response).toEqual(mockResponseRerank.rerank);
|
||||
});
|
||||
});
|
||||
|
||||
describe('performApiTextEmbedding', () => {
|
||||
const mockEsClient = elasticsearchClientMock.createClusterClient().asScoped().asInternalUser;
|
||||
|
||||
beforeEach(() => {
|
||||
mockEsClient.inference.inference.mockResolvedValue(mockResponse);
|
||||
mockError = jest.fn().mockImplementation(() => {
|
||||
throw new Error('API Error');
|
||||
});
|
||||
});
|
||||
|
||||
const services = actionsMock.createServices();
|
||||
services.scopedClusterClient = mockEsClient;
|
||||
const connectorTextEmbedding = new InferenceConnector({
|
||||
configurationUtilities: actionsConfigMock.create(),
|
||||
connector: { id: '1', type: OPENAI_CONNECTOR_ID },
|
||||
config: {
|
||||
providerConfig: {
|
||||
url: 'https://My-test-resource-123.openai.azure.com/openai/deployments/NEW-DEPLOYMENT-321/chat/completions?api-version=2023-05-15',
|
||||
},
|
||||
provider: 'elasticsearch',
|
||||
taskType: '',
|
||||
inferenceId: '',
|
||||
taskTypeConfig: {},
|
||||
},
|
||||
secrets: { providerSecrets: {} },
|
||||
logger: loggingSystemMock.createLogger(),
|
||||
services,
|
||||
});
|
||||
|
||||
it('test the AzureAI API call is successful with correct parameters', async () => {
|
||||
const response = await connectorTextEmbedding.performApiTextEmbedding({
|
||||
input: 'Hello world',
|
||||
inputType: 'ingest',
|
||||
});
|
||||
expect(mockEsClient.inference.inference).toHaveBeenCalledWith(
|
||||
{
|
||||
inference_id: '',
|
||||
input: 'Hello world',
|
||||
task_settings: {
|
||||
input_type: 'ingest',
|
||||
},
|
||||
task_type: 'text_embedding',
|
||||
},
|
||||
{ asStream: false }
|
||||
);
|
||||
expect(response).toEqual(mockResponse.text_embedding);
|
||||
});
|
||||
|
||||
it('errors during API calls are properly handled', async () => {
|
||||
// @ts-ignore
|
||||
mockEsClient.inference.inference = mockError;
|
||||
|
||||
await expect(
|
||||
connectorTextEmbedding.performApiTextEmbedding({
|
||||
input: 'Hello world',
|
||||
inputType: 'ingest',
|
||||
})
|
||||
).rejects.toThrow('API Error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('performApiCompletionStream', () => {
|
||||
const mockEsClient = elasticsearchClientMock.createClusterClient().asScoped().asInternalUser;
|
||||
|
||||
const mockStream = (
|
||||
dataToStream: string[] = [
|
||||
'data: {"object":"chat.completion.chunk","choices":[{"delta":{"content":"My"}}]}\ndata: {"object":"chat.completion.chunk","choices":[{"delta":{"content":" new"}}]}',
|
||||
]
|
||||
) => {
|
||||
const streamMock = createStreamMock();
|
||||
dataToStream.forEach((chunk) => {
|
||||
streamMock.write(chunk);
|
||||
});
|
||||
streamMock.complete();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockEsClient.inference.inference.mockResolvedValue(streamMock.transform as any);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// @ts-ignore
|
||||
mockStream();
|
||||
});
|
||||
|
||||
const services = actionsMock.createServices();
|
||||
services.scopedClusterClient = mockEsClient;
|
||||
const connector = new InferenceConnector({
|
||||
configurationUtilities: actionsConfigMock.create(),
|
||||
connector: { id: '1', type: OPENAI_CONNECTOR_ID },
|
||||
config: {
|
||||
providerConfig: {
|
||||
url: 'https://My-test-resource-123.openai.azure.com/openai/deployments/NEW-DEPLOYMENT-321/chat/completions?api-version=2023-05-15',
|
||||
},
|
||||
provider: 'elasticsearch',
|
||||
taskType: 'completion',
|
||||
inferenceId: '',
|
||||
taskTypeConfig: {},
|
||||
},
|
||||
secrets: { providerSecrets: {} },
|
||||
logger: loggingSystemMock.createLogger(),
|
||||
services,
|
||||
});
|
||||
|
||||
it('the API call is successful with correct request parameters', async () => {
|
||||
await connector.performApiCompletionStream({ input: 'Hello world' });
|
||||
expect(mockEsClient.inference.inference).toBeCalledTimes(1);
|
||||
expect(mockEsClient.inference.inference).toHaveBeenCalledWith(
|
||||
{
|
||||
inference_id: '',
|
||||
input: 'Hello world',
|
||||
task_type: 'completion',
|
||||
},
|
||||
{ asStream: true }
|
||||
);
|
||||
});
|
||||
|
||||
it('signal is properly passed to streamApi', async () => {
|
||||
const signal = jest.fn() as unknown as AbortSignal;
|
||||
await connector.performApiCompletionStream({ input: 'Hello world', signal });
|
||||
|
||||
expect(mockEsClient.inference.inference).toHaveBeenCalledWith(
|
||||
{
|
||||
inference_id: '',
|
||||
input: 'Hello world',
|
||||
task_type: 'completion',
|
||||
},
|
||||
{ asStream: true, signal }
|
||||
);
|
||||
});
|
||||
|
||||
it('errors during API calls are properly handled', async () => {
|
||||
// @ts-ignore
|
||||
mockEsClient.inference.inference = mockError;
|
||||
|
||||
await expect(
|
||||
connector.performApiCompletionStream({ input: 'What is Elastic?' })
|
||||
).rejects.toThrow('API Error');
|
||||
});
|
||||
|
||||
it('responds with a readable stream', async () => {
|
||||
const response = await connector.performApiCompletionStream({
|
||||
input: 'What is Elastic?',
|
||||
});
|
||||
expect(response instanceof PassThrough).toEqual(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createStreamMock() {
|
||||
const transform: Transform = new Transform({});
|
||||
|
||||
return {
|
||||
write: (data: string) => {
|
||||
transform.push(data);
|
||||
},
|
||||
fail: () => {
|
||||
transform.emit('error', new Error('Stream failed'));
|
||||
transform.end();
|
||||
},
|
||||
transform,
|
||||
complete: () => {
|
||||
transform.end();
|
||||
},
|
||||
};
|
||||
}
|
|
@ -0,0 +1,232 @@
|
|||
/*
|
||||
* 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 { PassThrough, Stream } from 'stream';
|
||||
import { IncomingMessage } from 'http';
|
||||
|
||||
import { AxiosError } from 'axios';
|
||||
import {
|
||||
InferenceInferenceRequest,
|
||||
InferenceInferenceResponse,
|
||||
InferenceTaskType,
|
||||
} from '@elastic/elasticsearch/lib/api/types';
|
||||
import {
|
||||
ChatCompleteParamsSchema,
|
||||
RerankParamsSchema,
|
||||
SparseEmbeddingParamsSchema,
|
||||
TextEmbeddingParamsSchema,
|
||||
} from '../../../common/inference/schema';
|
||||
import {
|
||||
Config,
|
||||
Secrets,
|
||||
ChatCompleteParams,
|
||||
ChatCompleteResponse,
|
||||
StreamingResponse,
|
||||
RerankParams,
|
||||
RerankResponse,
|
||||
SparseEmbeddingParams,
|
||||
SparseEmbeddingResponse,
|
||||
TextEmbeddingParams,
|
||||
TextEmbeddingResponse,
|
||||
} from '../../../common/inference/types';
|
||||
import { SUB_ACTION } from '../../../common/inference/constants';
|
||||
|
||||
export class InferenceConnector extends SubActionConnector<Config, Secrets> {
|
||||
// Not using Axios
|
||||
protected getResponseErrorMessage(error: AxiosError): string {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
private inferenceId;
|
||||
private taskType;
|
||||
|
||||
constructor(params: ServiceParams<Config, Secrets>) {
|
||||
super(params);
|
||||
|
||||
this.provider = this.config.provider;
|
||||
this.taskType = this.config.taskType;
|
||||
this.inferenceId = this.config.inferenceId;
|
||||
this.logger = this.logger;
|
||||
this.connectorID = this.connector.id;
|
||||
this.connectorTokenClient = params.services.connectorTokenClient;
|
||||
|
||||
this.registerSubActions();
|
||||
}
|
||||
|
||||
private registerSubActions() {
|
||||
this.registerSubAction({
|
||||
name: SUB_ACTION.COMPLETION,
|
||||
method: 'performApiCompletion',
|
||||
schema: ChatCompleteParamsSchema,
|
||||
});
|
||||
|
||||
this.registerSubAction({
|
||||
name: SUB_ACTION.RERANK,
|
||||
method: 'performApiRerank',
|
||||
schema: RerankParamsSchema,
|
||||
});
|
||||
|
||||
this.registerSubAction({
|
||||
name: SUB_ACTION.SPARSE_EMBEDDING,
|
||||
method: 'performApiSparseEmbedding',
|
||||
schema: SparseEmbeddingParamsSchema,
|
||||
});
|
||||
|
||||
this.registerSubAction({
|
||||
name: SUB_ACTION.TEXT_EMBEDDING,
|
||||
method: 'performApiTextEmbedding',
|
||||
schema: TextEmbeddingParamsSchema,
|
||||
});
|
||||
|
||||
this.registerSubAction({
|
||||
name: SUB_ACTION.COMPLETION_STREAM,
|
||||
method: 'performApiCompletionStream',
|
||||
schema: ChatCompleteParamsSchema,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* responsible for making a esClient inference method to perform chat completetion task endpoint and returning the service response data
|
||||
* @param input the text on which you want to perform the inference task.
|
||||
* @signal abort signal
|
||||
*/
|
||||
public async performApiCompletion({
|
||||
input,
|
||||
signal,
|
||||
}: ChatCompleteParams & { signal?: AbortSignal }): Promise<ChatCompleteResponse> {
|
||||
const response = await this.performInferenceApi(
|
||||
{ inference_id: this.inferenceId, input, task_type: 'completion' },
|
||||
false,
|
||||
signal
|
||||
);
|
||||
return response.completion!;
|
||||
}
|
||||
|
||||
/**
|
||||
* responsible for making a esClient inference method to rerank task endpoint and returning the response data
|
||||
* @param input the text on which you want to perform the inference task. input can be a single string or an array.
|
||||
* @query the search query text
|
||||
* @signal abort signal
|
||||
*/
|
||||
public async performApiRerank({
|
||||
input,
|
||||
query,
|
||||
signal,
|
||||
}: RerankParams & { signal?: AbortSignal }): Promise<RerankResponse> {
|
||||
const response = await this.performInferenceApi(
|
||||
{
|
||||
query,
|
||||
inference_id: this.inferenceId,
|
||||
input,
|
||||
task_type: 'rerank',
|
||||
},
|
||||
false,
|
||||
signal
|
||||
);
|
||||
return response.rerank!;
|
||||
}
|
||||
|
||||
/**
|
||||
* responsible for making a esClient inference method sparse embedding task endpoint and returning the response data
|
||||
* @param input the text on which you want to perform the inference task.
|
||||
* @signal abort signal
|
||||
*/
|
||||
public async performApiSparseEmbedding({
|
||||
input,
|
||||
signal,
|
||||
}: SparseEmbeddingParams & { signal?: AbortSignal }): Promise<SparseEmbeddingResponse> {
|
||||
const response = await this.performInferenceApi(
|
||||
{ inference_id: this.inferenceId, input, task_type: 'sparse_embedding' },
|
||||
false,
|
||||
signal
|
||||
);
|
||||
return response.sparse_embedding!;
|
||||
}
|
||||
|
||||
/**
|
||||
* responsible for making a esClient inference method text embedding task endpoint and returning the response data
|
||||
* @param input the text on which you want to perform the inference task.
|
||||
* @signal abort signal
|
||||
*/
|
||||
public async performApiTextEmbedding({
|
||||
input,
|
||||
inputType,
|
||||
signal,
|
||||
}: TextEmbeddingParams & { signal?: AbortSignal }): Promise<TextEmbeddingResponse> {
|
||||
const response = await this.performInferenceApi(
|
||||
{
|
||||
inference_id: this.inferenceId,
|
||||
input,
|
||||
task_type: 'text_embedding',
|
||||
task_settings: {
|
||||
input_type: inputType,
|
||||
},
|
||||
},
|
||||
false,
|
||||
signal
|
||||
);
|
||||
return response.text_embedding!;
|
||||
}
|
||||
|
||||
/**
|
||||
* private generic method to avoid duplication esClient inference inference execute.
|
||||
* @param params InferenceInferenceRequest params.
|
||||
* @param asStream defines the type of the responce, regular or stream
|
||||
* @signal abort signal
|
||||
*/
|
||||
private async performInferenceApi(
|
||||
params: InferenceInferenceRequest,
|
||||
asStream: boolean = false,
|
||||
signal?: AbortSignal
|
||||
): Promise<InferenceInferenceResponse> {
|
||||
try {
|
||||
const response = await this.esClient?.inference.inference(params, { asStream, signal });
|
||||
this.logger.info(
|
||||
`Perform Inference endpoint for task type "${this.taskType}" and inference id ${this.inferenceId}`
|
||||
);
|
||||
// TODO: const usageMetadata = response?.data?.usageMetadata;
|
||||
return response;
|
||||
} catch (err) {
|
||||
this.logger.error(`error perform inference endpoint API: ${err}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async streamAPI({
|
||||
input,
|
||||
signal,
|
||||
}: ChatCompleteParams & { signal?: AbortSignal }): Promise<StreamingResponse> {
|
||||
const response = await this.performInferenceApi(
|
||||
{ inference_id: this.inferenceId, input, task_type: this.taskType as InferenceTaskType },
|
||||
true,
|
||||
signal
|
||||
);
|
||||
|
||||
return (response as unknown as Stream).pipe(new PassThrough());
|
||||
}
|
||||
|
||||
/**
|
||||
* takes input. It calls the streamApi method to make a
|
||||
* request to the Inference API with the message. It then returns a Transform stream
|
||||
* that pipes the response from the API through the transformToString function,
|
||||
* which parses the proprietary response into a string of the response text alone
|
||||
* @param input A message to be sent to the API
|
||||
* @signal abort signal
|
||||
*/
|
||||
public async performApiCompletionStream({
|
||||
input,
|
||||
signal,
|
||||
}: ChatCompleteParams & { signal?: AbortSignal }): Promise<IncomingMessage> {
|
||||
const res = (await this.streamAPI({
|
||||
input,
|
||||
signal,
|
||||
})) as unknown as IncomingMessage;
|
||||
return res;
|
||||
}
|
||||
}
|
|
@ -24,7 +24,7 @@ export const initDashboard = async ({
|
|||
logger: Logger;
|
||||
savedObjectsClient: SavedObjectsClientContract;
|
||||
dashboardId: string;
|
||||
genAIProvider: 'OpenAI' | 'Bedrock' | 'Gemini';
|
||||
genAIProvider: 'OpenAI' | 'Bedrock' | 'Gemini' | 'Inference';
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
error?: OutputError;
|
||||
|
|
|
@ -11,11 +11,15 @@ import { SavedObject } from '@kbn/core-saved-objects-common/src/server_types';
|
|||
import { OPENAI_TITLE, OPENAI_CONNECTOR_ID } from '../../../../common/openai/constants';
|
||||
import { BEDROCK_TITLE, BEDROCK_CONNECTOR_ID } from '../../../../common/bedrock/constants';
|
||||
import { GEMINI_TITLE, GEMINI_CONNECTOR_ID } from '../../../../common/gemini/constants';
|
||||
import {
|
||||
INFERENCE_CONNECTOR_TITLE,
|
||||
INFERENCE_CONNECTOR_ID,
|
||||
} from '../../../../common/inference/constants';
|
||||
|
||||
export const getDashboardTitle = (title: string) => `${title} Token Usage`;
|
||||
|
||||
export const getDashboard = (
|
||||
genAIProvider: 'OpenAI' | 'Bedrock' | 'Gemini',
|
||||
genAIProvider: 'OpenAI' | 'Bedrock' | 'Gemini' | 'Inference',
|
||||
dashboardId: string
|
||||
): SavedObject<DashboardAttributes> => {
|
||||
let attributes = {
|
||||
|
@ -42,6 +46,12 @@ export const getDashboard = (
|
|||
dashboardTitle: getDashboardTitle(GEMINI_TITLE),
|
||||
actionTypeId: GEMINI_CONNECTOR_ID,
|
||||
};
|
||||
} else if (genAIProvider === 'Inference') {
|
||||
attributes = {
|
||||
provider: INFERENCE_CONNECTOR_TITLE,
|
||||
dashboardTitle: getDashboardTitle(INFERENCE_CONNECTOR_TITLE),
|
||||
actionTypeId: INFERENCE_CONNECTOR_ID,
|
||||
};
|
||||
}
|
||||
|
||||
const ids: Record<string, string> = {
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { unflattenObject } from './unflatten_object';
|
||||
|
||||
describe('unflattenObject', () => {
|
||||
test('should unflatten an object', () => {
|
||||
const obj = {
|
||||
a: true,
|
||||
'b.baz[0].a': false,
|
||||
'b.baz[0].b': 'foo',
|
||||
'b.baz[1]': 'bar',
|
||||
'b.baz[2]': true,
|
||||
'b.foo': 'bar',
|
||||
'b.baz[3][0]': 1,
|
||||
'b.baz[3][1]': 2,
|
||||
'c.b.foo': 'cheese',
|
||||
};
|
||||
|
||||
expect(unflattenObject(obj)).toEqual({
|
||||
a: true,
|
||||
b: {
|
||||
foo: 'bar',
|
||||
baz: [
|
||||
{
|
||||
a: false,
|
||||
b: 'foo',
|
||||
},
|
||||
'bar',
|
||||
true,
|
||||
[1, 2],
|
||||
],
|
||||
},
|
||||
c: {
|
||||
b: {
|
||||
foo: 'cheese',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { set } from '@kbn/safer-lodash-set';
|
||||
|
||||
interface GenericObject {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
export const unflattenObject = <T extends object = GenericObject>(object: object): T =>
|
||||
Object.entries(object).reduce((acc, [key, value]) => {
|
||||
set(acc, key, value);
|
||||
return acc;
|
||||
}, {} as T);
|
|
@ -131,7 +131,7 @@ describe('Stack Connectors Plugin', () => {
|
|||
name: 'Torq',
|
||||
})
|
||||
);
|
||||
expect(actionsSetup.registerSubActionConnectorType).toHaveBeenCalledTimes(10);
|
||||
expect(actionsSetup.registerSubActionConnectorType).toHaveBeenCalledTimes(11);
|
||||
expect(actionsSetup.registerSubActionConnectorType).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
|
|
|
@ -42,6 +42,8 @@
|
|||
"@kbn/utility-types",
|
||||
"@kbn/task-manager-plugin",
|
||||
"@kbn/alerting-types",
|
||||
"@kbn/alerts-ui-shared",
|
||||
"@kbn/core-notifications-browser",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*",
|
||||
|
|
|
@ -106,6 +106,10 @@ Array [
|
|||
"cost": 1,
|
||||
"taskType": "actions:.crowdstrike",
|
||||
},
|
||||
Object {
|
||||
"cost": 1,
|
||||
"taskType": "actions:.inference",
|
||||
},
|
||||
Object {
|
||||
"cost": 1,
|
||||
"taskType": "actions:.cases",
|
||||
|
|
|
@ -51,13 +51,17 @@ const FlyoutHeaderComponent: React.FC<Props> = ({
|
|||
<EuiFlexItem grow={false}>
|
||||
<EuiTitle size="s">
|
||||
<h3 id="flyoutTitle">
|
||||
<FormattedMessage
|
||||
defaultMessage="{actionTypeName} connector"
|
||||
id="xpack.triggersActionsUI.sections.addConnectorForm.flyoutTitle"
|
||||
values={{
|
||||
actionTypeName,
|
||||
}}
|
||||
/>
|
||||
{actionTypeName && actionTypeName.toLowerCase().includes('connector') ? (
|
||||
actionTypeName
|
||||
) : (
|
||||
<FormattedMessage
|
||||
defaultMessage="{actionTypeName} connector"
|
||||
id="xpack.triggersActionsUI.sections.addConnectorForm.flyoutTitle"
|
||||
values={{
|
||||
actionTypeName,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</h3>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
|
|
|
@ -15,17 +15,23 @@ const renderWithSecretFields = ({
|
|||
isEdit,
|
||||
isMissingSecrets,
|
||||
numberOfSecretFields,
|
||||
isSecretFieldsHidden = false,
|
||||
}: {
|
||||
isEdit: boolean;
|
||||
isMissingSecrets: boolean;
|
||||
numberOfSecretFields: number;
|
||||
isSecretFieldsHidden?: boolean;
|
||||
}): RenderResult => {
|
||||
return render(
|
||||
<FormTestProvider>
|
||||
<UseField path="config.foo" config={{ label: 'labelFoo' }} />
|
||||
{Array.from({ length: numberOfSecretFields }).map((_, index) => {
|
||||
return (
|
||||
<UseField path={`secrets.${index}`} config={{ label: `label${index}` }} key={index} />
|
||||
<UseField
|
||||
path={`secrets.${index}`}
|
||||
config={isSecretFieldsHidden ? {} : { label: `label${index}` }}
|
||||
key={index}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<EncryptedFieldsCallout isEdit={isEdit} isMissingSecrets={isMissingSecrets} />
|
||||
|
@ -67,10 +73,16 @@ describe('EncryptedFieldsCallout', () => {
|
|||
],
|
||||
];
|
||||
|
||||
const noSecretsTests: Array<[{ isEdit: boolean; isMissingSecrets: boolean }, string]> = [
|
||||
const noSecretsTests: Array<
|
||||
[{ isEdit: boolean; isMissingSecrets: boolean; isSecretFieldsHidden?: boolean }, string]
|
||||
> = [
|
||||
[{ isEdit: false, isMissingSecrets: false }, 'create-connector-secrets-callout'],
|
||||
[{ isEdit: true, isMissingSecrets: false }, 'edit-connector-secrets-callout'],
|
||||
[{ isEdit: false, isMissingSecrets: true }, 'missing-secrets-callout'],
|
||||
[
|
||||
{ isEdit: true, isMissingSecrets: true, isSecretFieldsHidden: true },
|
||||
'edit-connector-secrets-callout',
|
||||
],
|
||||
];
|
||||
|
||||
it.each(isCreateTests)(
|
||||
|
|
|
@ -96,7 +96,7 @@ const EncryptedFieldsCalloutComponent: React.FC<EncryptedFieldsCalloutProps> = (
|
|||
);
|
||||
}
|
||||
|
||||
if (!isEdit) {
|
||||
if (!isEdit && secretFieldsLabel.length) {
|
||||
return (
|
||||
<Callout
|
||||
title={i18n.translate(
|
||||
|
@ -112,7 +112,7 @@ const EncryptedFieldsCalloutComponent: React.FC<EncryptedFieldsCalloutProps> = (
|
|||
);
|
||||
}
|
||||
|
||||
if (isEdit) {
|
||||
if (isEdit && secretFieldsLabel.length) {
|
||||
return (
|
||||
<Callout
|
||||
title={i18n.translate(
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import http from 'http';
|
||||
|
||||
import { ProxyArgs, Simulator } from './simulator';
|
||||
|
||||
export class InferenceSimulator 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 InferenceSimulator.sendErrorResponse(response);
|
||||
}
|
||||
|
||||
return InferenceSimulator.sendResponse(response);
|
||||
}
|
||||
|
||||
private static sendResponse(response: http.ServerResponse) {
|
||||
response.statusCode = 202;
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(JSON.stringify(inferenceSuccessResponse, null, 4));
|
||||
}
|
||||
|
||||
private static sendErrorResponse(response: http.ServerResponse) {
|
||||
response.statusCode = 422;
|
||||
response.setHeader('Content-Type', 'application/json;charset=UTF-8');
|
||||
response.end(JSON.stringify(inferenceFailedResponse, null, 4));
|
||||
}
|
||||
}
|
||||
|
||||
export const inferenceSuccessResponse = {
|
||||
refid: '80be4a0d-5f0e-4d6c-b00e-8cb918f7df1f',
|
||||
};
|
||||
export const inferenceFailedResponse = {
|
||||
error: {
|
||||
statusMessage: 'Bad job',
|
||||
},
|
||||
};
|
|
@ -0,0 +1,518 @@
|
|||
/*
|
||||
* 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 { IValidatedEvent } from '@kbn/event-log-plugin/server';
|
||||
|
||||
import {
|
||||
InferenceSimulator,
|
||||
inferenceSuccessResponse,
|
||||
} from '@kbn/actions-simulators-plugin/server/inference_simulation';
|
||||
import { TaskErrorSource } from '@kbn/task-manager-plugin/common';
|
||||
import { FtrProviderContext } from '../../../../../common/ftr_provider_context';
|
||||
import { getUrlPrefix, ObjectRemover } from '../../../../../common/lib';
|
||||
import { getEventLog } from '../../../../../common/lib';
|
||||
|
||||
const connectorTypeId = '.inference';
|
||||
const name = 'AI connector action';
|
||||
const secrets = {
|
||||
apiKey: 'genAiApiKey',
|
||||
};
|
||||
|
||||
const defaultConfig = { provider: 'openai' };
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default function InferenceConnectorTest({ getService }: FtrProviderContext) {
|
||||
const supertest = getService('supertest');
|
||||
const objectRemover = new ObjectRemover(supertest);
|
||||
const supertestWithoutAuth = getService('supertestWithoutAuth');
|
||||
const configService = getService('config');
|
||||
const retry = getService('retry');
|
||||
const createConnector = async (apiUrl: string, spaceId?: string) => {
|
||||
const { body } = await supertest
|
||||
.post(`${getUrlPrefix(spaceId ?? 'default')}/api/actions/connector`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
name,
|
||||
connector_type_id: connectorTypeId,
|
||||
config: { ...defaultConfig, apiUrl },
|
||||
secrets,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
objectRemover.add(spaceId ?? 'default', body.id, 'connector', 'actions');
|
||||
|
||||
return body.id;
|
||||
};
|
||||
|
||||
describe('OpenAI', () => {
|
||||
after(async () => {
|
||||
await objectRemover.removeAll();
|
||||
});
|
||||
describe('action creation', () => {
|
||||
const simulator = new InferenceSimulator({
|
||||
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 without a default model', async () => {
|
||||
const { body: createdAction } = await supertest
|
||||
.post('/api/actions/connector')
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
name,
|
||||
connector_type_id: connectorTypeId,
|
||||
config,
|
||||
secrets,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(createdAction).to.eql({
|
||||
id: createdAction.id,
|
||||
is_preconfigured: false,
|
||||
is_system_action: false,
|
||||
is_deprecated: false,
|
||||
name,
|
||||
connector_type_id: connectorTypeId,
|
||||
is_missing_secrets: false,
|
||||
config: {
|
||||
...config,
|
||||
defaultModel: 'gpt-4o',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 200 when creating the connector with a default model', async () => {
|
||||
const { body: createdAction } = await supertest
|
||||
.post('/api/actions/connector')
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
name,
|
||||
connector_type_id: connectorTypeId,
|
||||
config: {
|
||||
...config,
|
||||
defaultModel: 'gpt-3.5-turbo',
|
||||
},
|
||||
secrets,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(createdAction).to.eql({
|
||||
id: createdAction.id,
|
||||
is_preconfigured: false,
|
||||
is_system_action: false,
|
||||
is_deprecated: false,
|
||||
name,
|
||||
connector_type_id: connectorTypeId,
|
||||
is_missing_secrets: false,
|
||||
config: {
|
||||
...config,
|
||||
defaultModel: 'gpt-3.5-turbo',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
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: types that failed validation:\n- [0.apiProvider]: expected at least one defined value but got [undefined]\n- [1.apiProvider]: expected at least one defined value 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: types that failed validation:\n- [0.apiProvider]: expected value to equal [Azure OpenAI]\n- [1.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 OpenAI 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 InferenceSimulator({
|
||||
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,
|
||||
errorSource: TaskErrorSource.FRAMEWORK,
|
||||
});
|
||||
});
|
||||
|
||||
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',
|
||||
errorSource: TaskErrorSource.FRAMEWORK,
|
||||
service_message: `Sub action "invalidAction" is not registered. Connector id: ${genAiActionId}. Connector name: OpenAI. Connector type: .gen-ai`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('execution', () => {
|
||||
describe('successful response simulator', () => {
|
||||
const simulator = new InferenceSimulator({
|
||||
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: inferenceSuccessResponse,
|
||||
});
|
||||
|
||||
const events: IValidatedEvent[] = await retry.try(async () => {
|
||||
return await getEventLog({
|
||||
getService,
|
||||
spaceId: 'default',
|
||||
type: 'action',
|
||||
id: genAiActionId,
|
||||
provider: 'actions',
|
||||
actions: new Map([
|
||||
['execute-start', { equal: 1 }],
|
||||
['execute', { equal: 1 }],
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
const executeEvent = events[1];
|
||||
expect(executeEvent?.kibana?.action?.execution?.usage?.request_body_bytes).to.be(78);
|
||||
});
|
||||
describe('Token tracking dashboard', () => {
|
||||
const dashboardId = 'specific-dashboard-id-default';
|
||||
|
||||
it('should not create a dashboard when user does not have kibana event log permissions', async () => {
|
||||
const { body } = await supertestWithoutAuth
|
||||
.post(`/api/actions/connector/${genAiActionId}/_execute`)
|
||||
.auth('global_read', 'global_read-password')
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
params: {
|
||||
subAction: 'getDashboard',
|
||||
subActionParams: {
|
||||
dashboardId,
|
||||
},
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
// check dashboard has not been created
|
||||
await supertest
|
||||
.get(`/api/saved_objects/dashboard/${dashboardId}`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.expect(404);
|
||||
|
||||
expect(body).to.eql({
|
||||
status: 'ok',
|
||||
connector_id: genAiActionId,
|
||||
data: { available: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('should create a dashboard when user has correct permissions', async () => {
|
||||
const { body } = await supertest
|
||||
.post(`/api/actions/connector/${genAiActionId}/_execute`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
params: {
|
||||
subAction: 'getDashboard',
|
||||
subActionParams: {
|
||||
dashboardId,
|
||||
},
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
// check dashboard has been created
|
||||
await retry.try(async () =>
|
||||
supertest
|
||||
.get(`/api/saved_objects/dashboard/${dashboardId}`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.expect(200)
|
||||
);
|
||||
|
||||
objectRemover.add('default', dashboardId, 'dashboard', 'saved_objects');
|
||||
|
||||
expect(body).to.eql({
|
||||
status: 'ok',
|
||||
connector_id: genAiActionId,
|
||||
data: { available: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('non-default space simulator', () => {
|
||||
const simulator = new InferenceSimulator({
|
||||
proxy: {
|
||||
config: configService.get('kbnTestServer.serverArgs'),
|
||||
},
|
||||
});
|
||||
let apiUrl: string;
|
||||
let genAiActionId: string;
|
||||
|
||||
before(async () => {
|
||||
apiUrl = await simulator.start();
|
||||
genAiActionId = await createConnector(apiUrl, 'space1');
|
||||
});
|
||||
after(() => {
|
||||
simulator.close();
|
||||
});
|
||||
|
||||
const dashboardId = 'specific-dashboard-id-space1';
|
||||
|
||||
it('should create a dashboard in non-default space', async () => {
|
||||
const { body } = await supertest
|
||||
.post(`${getUrlPrefix('space1')}/api/actions/connector/${genAiActionId}/_execute`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
params: {
|
||||
subAction: 'getDashboard',
|
||||
subActionParams: {
|
||||
dashboardId,
|
||||
},
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
// check dashboard has been created
|
||||
await retry.try(
|
||||
async () =>
|
||||
await supertest
|
||||
.get(`${getUrlPrefix('space1')}/api/saved_objects/dashboard/${dashboardId}`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.expect(200)
|
||||
);
|
||||
objectRemover.add('space1', dashboardId, 'dashboard', 'saved_objects');
|
||||
|
||||
expect(body).to.eql({
|
||||
status: 'ok',
|
||||
connector_id: genAiActionId,
|
||||
data: { available: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('error response simulator', () => {
|
||||
const simulator = new InferenceSimulator({
|
||||
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,
|
||||
errorSource: TaskErrorSource.FRAMEWORK,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return a error when error happens', 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(body).to.eql({
|
||||
status: 'error',
|
||||
connector_id: genAiActionId,
|
||||
message: 'an error occurred while running the action',
|
||||
retry: true,
|
||||
errorSource: TaskErrorSource.FRAMEWORK,
|
||||
service_message:
|
||||
'Status code: 422. Message: API Error: Unprocessable Entity - The model `bad model` does not exist',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -53,6 +53,7 @@ export default function createRegisteredConnectorTypeTests({ getService }: FtrPr
|
|||
'.gen-ai',
|
||||
'.bedrock',
|
||||
'.gemini',
|
||||
'.inference',
|
||||
'.sentinelone',
|
||||
'.cases',
|
||||
'.crowdstrike',
|
||||
|
|
|
@ -61,6 +61,7 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
'actions:.gemini',
|
||||
'actions:.gen-ai',
|
||||
'actions:.index',
|
||||
'actions:.inference',
|
||||
'actions:.jira',
|
||||
'actions:.observability-ai-assistant',
|
||||
'actions:.opsgenie',
|
||||
|
|