Gemini connector integration (#183668)

This commit is contained in:
honeyn303 2024-06-06 19:52:35 +02:00 committed by GitHub
parent 9a4ceaf59d
commit 1ff87eb551
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
54 changed files with 3353 additions and 28 deletions

5
.github/CODEOWNERS vendored
View file

@ -1497,6 +1497,11 @@ x-pack/test/security_solution_cypress/cypress/tasks/expandable_flyout @elastic/
/x-pack/plugins/stack_connectors/server/connector_types/bedrock @elastic/security-generative-ai
/x-pack/plugins/stack_connectors/common/bedrock @elastic/security-generative-ai
# Gemini
/x-pack/plugins/stack_connectors/public/connector_types/gemini @elastic/security-generative-ai
/x-pack/plugins/stack_connectors/server/connector_types/gemini @elastic/security-generative-ai
/x-pack/plugins/stack_connectors/common/gemini @elastic/security-generative-ai
## 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

View file

@ -20,6 +20,10 @@ a| <<d3security-action-type,D3 Security>>
| Send a request to D3 Security.
a| <<gemini-action-type,{gemini}>>
| Send a request to {gemini}.
a| <<email-action-type,Email>>
| Send email from your server.

View file

@ -0,0 +1,74 @@
[[gemini-action-type]]
== {gemini} connector and action
++++
<titleabbrev>{gemini}</titleabbrev>
++++
:frontmatter-description: Add a connector that can send requests to {gemini}.
:frontmatter-tags-products: [kibana]
:frontmatter-tags-content-type: [how-to]
:frontmatter-tags-user-goals: [configure]
The {gemini} connector uses https://github.com/axios/axios[axios] to send a POST request to {gemini}. The connector uses the <<execute-connector-api,run connector API>> to send the request.
[float]
[[define-gemini-ui]]
=== Create connectors in {kib}
You can create connectors in *{stack-manage-app} > {connectors-ui}*. For example:
[role="screenshot"]
image::management/connectors/images/gemini-connector.png[{gemini} connector]
// NOTE: This is an autogenerated screenshot. Do not edit it directly.
[float]
[[gemini-connector-configuration]]
==== Connector configuration
{gemini} connectors have the following configuration properties:
Name:: The name of the connector.
API URL:: The {gemini} request URL.
PROJECT ID:: The project which has Vertex AI endpoint enabled.
Region:: The GCP region where the Vertex AI endpoint enabled.
Default model:: The GAI model for {gemini} to use. Current support is for the Google Gemini models, defaulting to gemini-1.5-pro-preview-0409. The model can be set on a per request basis by including a "model" parameter alongside the request body.
Credentials JSON:: The GCP service account JSON file for authentication.
[float]
[[gemini-action-configuration]]
=== Test connectors
You can test connectors with the <<execute-connector-api,run connector API>> or
as you're creating or editing the connector in {kib}. For example:
[role="screenshot"]
image::management/connectors/images/gemini-params.png[{gemini} params test]
// NOTE: This is an autogenerated screenshot. Do not edit it directly.
The {gemini} actions have the following configuration properties.
Body:: A stringified JSON payload sent to the {gemini} Invoke Model API URL. For example:
+
[source,text]
--
{
body: JSON.stringify({
contents: [{
role: user,
parts: [{ text: 'Write the first line of a story about a magic backpack.' }]
}],
generation_config: {
temperature: 0,
maxOutputTokens: 8192
}
})
}
--
Model:: An optional string that will overwrite the connector's default model. For
[float]
[[gemini-connector-networking-configuration]]
=== Connector networking configuration
Use the <<action-settings, Action configuration settings>> to customize connector networking configurations, such as proxies, certificates, or TLS settings. You can set configurations that apply to all your connectors or use `xpack.actions.customHostSettings` to set per-host configurations.

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

View file

@ -2,6 +2,7 @@ include::action-types/bedrock.asciidoc[leveloffset=+1]
include::action-types/cases-action-type.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/resilient.asciidoc[leveloffset=+1]
include::action-types/index.asciidoc[leveloffset=+1]
include::action-types/jira.asciidoc[leveloffset=+1]

View file

@ -138,7 +138,7 @@ WARNING: This feature is available in {kib} 7.17.4 and 8.3.0 onwards but is not
A boolean value indicating that a footer with a relevant link should be added to emails sent as alerting actions. Default: true.
`xpack.actions.enabledActionTypes` {ess-icon}::
A list of action types that are enabled. It defaults to `["*"]`, enabling all types. The names for built-in {kib} action types are prefixed with a `.` and include: `.email`, `.index`, `.jira`, `.opsgenie`, `.pagerduty`, `.resilient`, `.server-log`, `.servicenow`, .`servicenow-itom`, `.servicenow-sir`, `.slack`, `.swimlane`, `.teams`, `.tines`, `.torq`, `.xmatters`, `.gen-ai`, `.bedrock`, `.d3security`, and `.webhook`. An empty list `[]` will disable all action types.
A list of action types that are enabled. It defaults to `["*"]`, enabling all types. The names for built-in {kib} action types are prefixed with a `.` and include: `.email`, `.index`, `.jira`, `.opsgenie`, `.pagerduty`, `.resilient`, `.server-log`, `.servicenow`, .`servicenow-itom`, `.servicenow-sir`, `.slack`, `.swimlane`, `.teams`, `.tines`, `.torq`, `.xmatters`, `.gen-ai`, `.bedrock`, `.gemini`, `.d3security`, and `.webhook`. An empty list `[]` will disable all action types.
+
Disabled action types will not appear as an option when creating new connectors, but existing connectors and actions of that type will remain in {kib} and will not function.
@ -277,6 +277,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 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.
@ -341,6 +342,7 @@ The default model to use for requests, which varies by connector:
+
--
* For an <<bedrock-action-type,{bedrock} connector>>, current support is for the Anthropic Claude models. Defaults to `anthropic.claude-3-sonnet-20240229-v1:0`.
* For a <<gemini-action-type,{gemini} connector>>, current support is for the Gemini models. Defaults to `gemini-1.5-pro-preview-0409`.
* For a <<openai-action-type,OpenAI connector>>, it is optional and applicable only when `xpack.actions.preconfigured.<connector-id>.config.apiProvider` is `OpenAI`.
--
@ -483,6 +485,9 @@ For an <<bedrock-action-type,{bedrock} connector>>, specifies the AWS access key
`xpack.actions.preconfigured.<connector-id>.secrets.apikey`::
An API key secret that varies by connector:
`xpack.actions.preconfigured.<connector-id>.secrets.credentialsJSON`::
For an <<gemini-action-type,{gemini} connector>>, specifies the GCP service account credentials JSON file for authentication.
+
--
* For a <<openai-action-type,OpenAI connector>>, specifies the OpenAI or Azure OpenAI API key for authentication.

View file

@ -1030,6 +1030,7 @@
"getopts": "^2.2.5",
"getos": "^3.1.0",
"globby": "^11.1.0",
"google-auth-library": "^9.10.0",
"gpt-tokenizer": "^2.1.2",
"handlebars": "4.7.8",
"he": "^1.2.0",

View file

@ -29,6 +29,16 @@ export const mockActionTypes = [
isSystemActionType: true,
supportedFeatureIds: ['generativeAI'],
} as ActionType,
{
id: '.gemini',
name: 'Gemini',
enabled: true,
enabledInConfig: true,
enabledInLicense: true,
minimumLicenseRequired: 'basic',
isSystemActionType: true,
supportedFeatureIds: ['generativeAI'],
} as ActionType,
];
export const mockConnectors: AIConnector[] = [

View file

@ -480,6 +480,42 @@
}
}
},
"create_connector_request_gemini": {
"title": "Create Google Gemini connector request",
"description": "The Google Gemini connector uses axios to send a POST request to Google Gemini.",
"type": "object",
"required": [
"config",
"connector_type_id",
"name",
"secrets"
],
"properties": {
"config": {
"$ref": "#/components/schemas/config_properties_gemini"
},
"connector_type_id": {
"type": "string",
"description": "The type of connector.",
"enum": [
".gemini"
],
"examples": [
".gemini"
]
},
"name": {
"type": "string",
"description": "The display name for the connector.",
"examples": [
"my-connector"
]
},
"secrets": {
"$ref": "#/components/schemas/secrets_properties_gemini"
}
}
},
"create_connector_request_cases_webhook": {
"title": "Create Webhook - Case Managment connector request",
"description": "The Webhook - Case Management connector uses axios to send POST, PUT, and GET requests to a case management RESTful API web service.\n",
@ -1289,6 +1325,49 @@
}
}
},
"config_properties_gemini": {
"title": "Connector request properties for an Google Gemini connector",
"description": "Defines properties for connectors when type is `.gemini`.",
"type": "object",
"required": [
"apiUrl",
"gcpRegion",
"gcpProjectID"
],
"properties": {
"apiUrl": {
"type": "string",
"description": "The Google Gemini request URL."
},
"defaultModel": {
"type": "string",
"description": "The generative artificial intelligence model for Google Gemini to use.\n",
"default": "gemini-1.5-pro-preview-0409"
},
"gcpRegion": {
"type": "string",
"description": "The GCP region that has Vertex AI endpoint enabled."
},
"gcpProjectID": {
"type": "string",
"description": "The Google ProjectID that has Vertex AI endpoint enabled."
}
}
},
"secrets_properties_gemini": {
"title": "Connector secrets properties for an Google Gemini connector",
"description": "Defines secrets for connectors when type is `.gemini`.",
"type": "object",
"required": [
"credentialsJSON"
],
"properties": {
"credentialsJSON": {
"type": "string",
"description": "The service account credentials JSON file. The service account should have Vertex AI user IAM role assigned to it."
}
}
},
"config_properties_cases_webhook": {
"title": "Connector request properties for Webhook - Case Management connector",
"required": [
@ -2411,6 +2490,9 @@
{
"$ref": "#/components/schemas/create_connector_request_bedrock"
},
{
"$ref": "#/components/schemas/create_connector_request_gemini"
},
{
"$ref": "#/components/schemas/create_connector_request_cases_webhook"
},
@ -2482,6 +2564,7 @@
"propertyName": "connector_type_id",
"mapping": {
".bedrock": "#/components/schemas/create_connector_request_bedrock",
".gemini": "#/components/schemas/create_connector_request_gemini",
".cases-webhook": "#/components/schemas/create_connector_request_cases_webhook",
".d3security": "#/components/schemas/create_connector_request_d3security",
".email": "#/components/schemas/create_connector_request_email",
@ -2551,6 +2634,50 @@
}
}
},
"connector_response_properties_gemini": {
"title": "Connector response properties for an Google Gemini connector",
"type": "object",
"required": [
"config",
"connector_type_id",
"id",
"is_deprecated",
"is_preconfigured",
"name"
],
"properties": {
"config": {
"$ref": "#/components/schemas/config_properties_gemini"
},
"connector_type_id": {
"type": "string",
"description": "The type of connector.",
"enum": [
".gemini"
]
},
"id": {
"type": "string",
"description": "The identifier for the connector."
},
"is_deprecated": {
"$ref": "#/components/schemas/is_deprecated"
},
"is_missing_secrets": {
"$ref": "#/components/schemas/is_missing_secrets"
},
"is_preconfigured": {
"$ref": "#/components/schemas/is_preconfigured"
},
"is_system_action": {
"$ref": "#/components/schemas/is_system_action"
},
"name": {
"type": "string",
"description": "The display name for the connector."
}
}
},
"connector_response_properties_cases_webhook": {
"title": "Connector request properties for a Webhook - Case Management connector",
"type": "object",
@ -3605,6 +3732,9 @@
{
"$ref": "#/components/schemas/connector_response_properties_bedrock"
},
{
"$ref": "#/components/schemas/connector_response_properties_gemini"
},
{
"$ref": "#/components/schemas/connector_response_properties_cases_webhook"
},
@ -3676,6 +3806,7 @@
"propertyName": "connector_type_id",
"mapping": {
".bedrock": "#/components/schemas/connector_response_properties_bedrock",
".gemini": "#/components/schemas/connector_response_properties_gemini",
".cases-webhook": "#/components/schemas/connector_response_properties_cases_webhook",
".d3security": "#/components/schemas/connector_response_properties_d3security",
".email": "#/components/schemas/connector_response_properties_email",
@ -3721,6 +3852,26 @@
}
}
},
"update_connector_request_gemini": {
"title": "Update Google Gemini connector request",
"type": "object",
"required": [
"config",
"name"
],
"properties": {
"config": {
"$ref": "#/components/schemas/config_properties_gemini"
},
"name": {
"type": "string",
"description": "The display name for the connector."
},
"secrets": {
"$ref": "#/components/schemas/secrets_properties_gemini"
}
}
},
"update_connector_request_cases_webhook": {
"title": "Update Webhook - Case Managment connector request",
"type": "object",
@ -4131,6 +4282,9 @@
{
"$ref": "#/components/schemas/update_connector_request_bedrock"
},
{
"$ref": "#/components/schemas/update_connector_request_gemini"
},
{
"$ref": "#/components/schemas/update_connector_request_cases_webhook"
},
@ -4213,6 +4367,7 @@
"description": "The type of connector. For example, `.email`, `.index`, `.jira`, `.opsgenie`, or `.server-log`.",
"enum": [
".bedrock",
".gemini",
".cases-webhook",
".d3security",
".email",
@ -4456,6 +4611,18 @@
"generativeAI"
],
"is_system_action_type": false
},
{
"id": ".gemini",
"name": "Google Gemini",
"enabled": true,
"enabled_in_config": true,
"enabled_in_license": true,
"minimum_license_required": "enterprise",
"supported_feature_ids": [
"generativeAI"
],
"is_system_action_type": false
}
]
}

View file

@ -18,3 +18,13 @@ value:
supported_feature_ids:
- generativeAI
is_system_action_type: false
- id: .gemini
name: Google Gemini
enabled: true
enabled_in_config: true
enabled_in_license: true
minimum_license_required: enterprise
supported_feature_ids:
- generativeAI
is_system_action_type: false

View file

@ -2,6 +2,7 @@ title: Connector response properties
description: The properties vary depending on the connector type.
oneOf:
- $ref: 'connector_response_properties_bedrock.yaml'
- $ref: 'connector_response_properties_gemini.yaml'
- $ref: 'connector_response_properties_cases_webhook.yaml'
- $ref: 'connector_response_properties_d3security.yaml'
- $ref: 'connector_response_properties_email.yaml'
@ -28,6 +29,7 @@ discriminator:
propertyName: connector_type_id
mapping:
.bedrock: 'connector_response_properties_bedrock.yaml'
.gemini: 'connector_response_properties_gemini.yaml'
.cases-webhook: 'connector_response_properties_cases_webhook.yaml'
.d3security: 'connector_response_properties_d3security.yaml'
.email: 'connector_response_properties_email.yaml'

View file

@ -3,6 +3,7 @@ type: string
description: The type of connector. For example, `.email`, `.index`, `.jira`, `.opsgenie`, or `.server-log`.
enum:
- .bedrock
- .gemini
- .cases-webhook
- .d3security
- .email

View file

@ -2,6 +2,7 @@ title: Create connector request body properties
description: The properties vary depending on the connector type.
oneOf:
- $ref: 'create_connector_request_bedrock.yaml'
- $ref: 'create_connector_request_gemini.yaml'
- $ref: 'create_connector_request_cases_webhook.yaml'
- $ref: 'create_connector_request_d3security.yaml'
- $ref: 'create_connector_request_email.yaml'
@ -28,6 +29,7 @@ discriminator:
propertyName: connector_type_id
mapping:
.bedrock: 'create_connector_request_bedrock.yaml'
.gemini: 'create_connector_request_gemini.yaml'
.cases-webhook: 'create_connector_request_cases_webhook.yaml'
.d3security: 'create_connector_request_d3security.yaml'
.email: 'create_connector_request_email.yaml'

View file

@ -2,6 +2,7 @@ title: Update connector request body properties
description: The properties vary depending on the connector type.
oneOf:
- $ref: 'update_connector_request_bedrock.yaml'
- $ref: 'update_connector_request_gemini.yaml'
- $ref: 'update_connector_request_cases_webhook.yaml'
- $ref: 'update_connector_request_d3security.yaml'
- $ref: 'update_connector_request_email.yaml'

View file

@ -2950,6 +2950,371 @@ Object {
}
`;
exports[`Connector type config checks detect connector type changes for: .gemini 1`] = `
Object {
"flags": Object {
"default": Object {
"special": "deep",
},
"error": [Function],
"presence": "optional",
},
"keys": Object {
"body": Object {
"flags": Object {
"error": [Function],
},
"rules": Array [
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
],
"type": "string",
},
"model": Object {
"flags": Object {
"default": [Function],
"error": [Function],
"presence": "optional",
},
"metas": Array [
Object {
"x-oas-optional": true,
},
],
"rules": Array [
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
],
"type": "string",
},
"signal": Object {
"flags": Object {
"default": [Function],
"error": [Function],
"presence": "optional",
},
"metas": Array [
Object {
"x-oas-any-type": true,
},
Object {
"x-oas-optional": true,
},
],
"type": "any",
},
"timeout": Object {
"flags": Object {
"default": [Function],
"error": [Function],
"presence": "optional",
},
"metas": Array [
Object {
"x-oas-optional": true,
},
],
"type": "number",
},
},
"preferences": Object {
"stripUnknown": Object {
"objects": false,
},
},
"type": "object",
}
`;
exports[`Connector type config checks detect connector type changes for: .gemini 2`] = `
Object {
"flags": Object {
"default": Object {
"special": "deep",
},
"error": [Function],
"presence": "optional",
},
"keys": Object {
"dashboardId": Object {
"flags": Object {
"error": [Function],
},
"rules": Array [
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
],
"type": "string",
},
},
"preferences": Object {
"stripUnknown": Object {
"objects": false,
},
},
"type": "object",
}
`;
exports[`Connector type config checks detect connector type changes for: .gemini 3`] = `
Object {
"flags": Object {
"default": Object {
"special": "deep",
},
"error": [Function],
"presence": "optional",
},
"keys": Object {
"body": Object {
"flags": Object {
"error": [Function],
},
"rules": Array [
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
],
"type": "string",
},
"model": Object {
"flags": Object {
"default": [Function],
"error": [Function],
"presence": "optional",
},
"metas": Array [
Object {
"x-oas-optional": true,
},
],
"rules": Array [
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
],
"type": "string",
},
"signal": Object {
"flags": Object {
"default": [Function],
"error": [Function],
"presence": "optional",
},
"metas": Array [
Object {
"x-oas-any-type": true,
},
Object {
"x-oas-optional": true,
},
],
"type": "any",
},
"timeout": Object {
"flags": Object {
"default": [Function],
"error": [Function],
"presence": "optional",
},
"metas": Array [
Object {
"x-oas-optional": true,
},
],
"type": "number",
},
},
"preferences": Object {
"stripUnknown": Object {
"objects": false,
},
},
"type": "object",
}
`;
exports[`Connector type config checks detect connector type changes for: .gemini 4`] = `
Object {
"flags": Object {
"default": Object {
"special": "deep",
},
"error": [Function],
"presence": "optional",
},
"keys": Object {
"apiUrl": Object {
"flags": Object {
"error": [Function],
},
"rules": Array [
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
],
"type": "string",
},
"defaultModel": Object {
"flags": Object {
"default": "gemini-1.5-pro-preview-0409",
"error": [Function],
"presence": "optional",
},
"rules": Array [
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
],
"type": "string",
},
"gcpProjectID": Object {
"flags": Object {
"error": [Function],
},
"rules": Array [
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
],
"type": "string",
},
"gcpRegion": Object {
"flags": Object {
"error": [Function],
},
"rules": Array [
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
],
"type": "string",
},
},
"preferences": Object {
"stripUnknown": Object {
"objects": false,
},
},
"type": "object",
}
`;
exports[`Connector type config checks detect connector type changes for: .gemini 5`] = `
Object {
"flags": Object {
"default": Object {
"special": "deep",
},
"error": [Function],
"presence": "optional",
},
"keys": Object {
"credentialsJson": Object {
"flags": Object {
"error": [Function],
},
"rules": Array [
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
],
"type": "string",
},
},
"preferences": Object {
"stripUnknown": Object {
"objects": false,
},
},
"type": "object",
}
`;
exports[`Connector type config checks detect connector type changes for: .gemini 6`] = `
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",
},
},
"preferences": Object {
"stripUnknown": Object {
"objects": false,
},
},
"type": "object",
}
`;
exports[`Connector type config checks detect connector type changes for: .index 1`] = `
Object {
"flags": Object {

View file

@ -26,6 +26,7 @@ export const connectorTypes: string[] = [
'.tines',
'.gen-ai',
'.bedrock',
'.gemini',
'.d3security',
'.resilient',
'.sentinelone',

View file

@ -310,6 +310,56 @@ describe('getGenAiTokenTracking', () => {
expect(logger.error).toHaveBeenCalled();
});
it('should return the total, prompt, and completion token counts when given a valid Gemini response', async () => {
const actionTypeId = '.gemini';
const result = {
actionId: '123',
status: 'ok' as const,
data: {
usageMetadata: {
promptTokenCount: 50,
candidatesTokenCount: 50,
totalTokenCount: 100,
},
},
};
const validatedParams = {};
const tokenTracking = await getGenAiTokenTracking({
actionTypeId,
logger,
result,
validatedParams,
});
expect(tokenTracking).toEqual({
total_tokens: 100,
prompt_tokens: 50,
completion_tokens: 50,
});
});
it('should return null when given an invalid Gemini response', async () => {
const actionTypeId = '.gemini';
const result = {
actionId: '123',
status: 'ok' as const,
data: {},
};
const validatedParams = {};
const tokenTracking = await getGenAiTokenTracking({
actionTypeId,
logger,
result,
validatedParams,
});
expect(tokenTracking).toBeNull();
expect(logger.error).toHaveBeenCalled();
});
describe('shouldTrackGenAiToken', () => {
it('should be true with OpenAI action', () => {
expect(shouldTrackGenAiToken('.gen-ai')).toEqual(true);
@ -317,6 +367,9 @@ describe('getGenAiTokenTracking', () => {
it('should be true with bedrock action', () => {
expect(shouldTrackGenAiToken('.bedrock')).toEqual(true);
});
it('should be true with Gemini action', () => {
expect(shouldTrackGenAiToken('.gemini')).toEqual(true);
});
it('should be false with any other action', () => {
expect(shouldTrackGenAiToken('.jira')).toEqual(false);
});

View file

@ -173,6 +173,26 @@ export const getGenAiTokenTracking = async ({
}
}
// Process non-streamed Gemini response from `usageMetadata` object
if (actionTypeId === '.gemini') {
const data = result.data as unknown as {
usageMetadata: {
promptTokenCount?: number;
candidatesTokenCount?: number;
totalTokenCount?: number;
};
};
if (data.usageMetadata == null) {
logger.error('Response did not contain usage metadata object');
return null;
}
return {
total_tokens: data.usageMetadata?.totalTokenCount ?? 0,
prompt_tokens: data.usageMetadata?.promptTokenCount ?? 0,
completion_tokens: data.usageMetadata?.candidatesTokenCount ?? 0,
};
}
// this is a non-streamed Bedrock response used by security solution
if (actionTypeId === '.bedrock' && validatedParams.subAction === 'invokeAI') {
try {
@ -215,4 +235,4 @@ export const getGenAiTokenTracking = async ({
};
export const shouldTrackGenAiToken = (actionTypeId: string) =>
actionTypeId === '.gen-ai' || actionTypeId === '.bedrock';
actionTypeId === '.gen-ai' || actionTypeId === '.bedrock' || actionTypeId === '.gemini';

View file

@ -0,0 +1,172 @@
/*
* 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 { Logger } from '@kbn/core/server';
import { connectorTokenClientMock } from './connector_token_client.mock';
import { loggingSystemMock } from '@kbn/core/server/mocks';
describe('getGoogleOAuthJwtAccessToken', () => {
const logger = loggingSystemMock.create().get() as jest.Mocked<Logger>;
const credentialsJson = {
type: 'service_account',
project_id: '',
private_key_id: '',
private_key: '-----BEGIN PRIVATE KEY----------END PRIVATE KEY-----\n',
client_email: '',
client_id: '',
auth_uri: 'https://accounts.google.com/o/oauth2/auth',
token_uri: 'https://oauth2.googleapis.com/token',
auth_provider_x509_cert_url: 'https://www.googleapis.com/oauth2/v1/certs',
client_x509_cert_url: '',
};
const connectorTokenClient = connectorTokenClientMock.create();
const getGoogleOAuthJwtAccessTokenOptions = {
connectorId: '123',
logger,
credentials: credentialsJson,
connectorTokenClient,
};
beforeEach(() => {
jest.resetModules();
jest.clearAllMocks();
});
it('should get access token successfully', async () => {
connectorTokenClient.get.mockResolvedValueOnce({
hasErrors: false,
connectorToken: null,
});
jest.mock('google-auth-library', () => ({
GoogleAuth: jest.fn().mockImplementation(() => ({
getAccessToken: jest.fn().mockResolvedValue('mocked_access_token'), // Success case
})),
}));
// Dynamically import the function after mocking
const { getGoogleOAuthJwtAccessToken } = await import('./get_gcp_oauth_access_token');
const accessToken = await getGoogleOAuthJwtAccessToken(getGoogleOAuthJwtAccessTokenOptions);
expect(accessToken).toBe('mocked_access_token');
expect(connectorTokenClient.updateOrReplace).toHaveBeenCalledWith(
expect.objectContaining({
connectorId: '123',
token: null,
newToken: 'mocked_access_token',
deleteExisting: false,
expiresInSec: 3500,
})
);
});
it('uses stored access token if it exists', async () => {
const createdAt = new Date();
createdAt.setHours(createdAt.getHours() - 1);
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 1);
connectorTokenClient.get.mockResolvedValueOnce({
hasErrors: false,
connectorToken: {
id: '1',
connectorId: '123',
tokenType: 'access_token',
token: 'testtokenvalue',
createdAt: createdAt.toISOString(),
expiresAt: expiresAt.toISOString(),
},
});
// Dynamically import the function
const { getGoogleOAuthJwtAccessToken } = await import('./get_gcp_oauth_access_token');
const accessToken = await getGoogleOAuthJwtAccessToken(getGoogleOAuthJwtAccessTokenOptions);
expect(accessToken).toEqual('testtokenvalue');
});
it('should get access token if token expires', async () => {
connectorTokenClient.get.mockResolvedValueOnce({
hasErrors: false,
connectorToken: {
id: '1',
connectorId: '123',
tokenType: 'access_token',
token: 'testtokenvalue',
createdAt: new Date('2021-01-01T08:00:00.000Z').toISOString(),
expiresAt: new Date('2021-01-02T13:00:00.000Z').toISOString(),
},
});
jest.mock('google-auth-library', () => ({
GoogleAuth: jest.fn().mockImplementation(() => ({
getAccessToken: jest.fn().mockResolvedValue('mocked_access_token'), // Success case
})),
}));
// Dynamically import the function after mocking
const { getGoogleOAuthJwtAccessToken } = await import('./get_gcp_oauth_access_token');
const accessToken = await getGoogleOAuthJwtAccessToken(getGoogleOAuthJwtAccessTokenOptions);
expect(accessToken).toBe('mocked_access_token');
expect(connectorTokenClient.updateOrReplace).toHaveBeenCalledWith(
expect.objectContaining({
connectorId: '123',
newToken: 'mocked_access_token',
deleteExisting: false,
expiresInSec: 3500,
})
);
});
it('logs warning when getting connector token fails', async () => {
const mockError = new Error('Failed to fetch token');
connectorTokenClient.get.mockRejectedValue(mockError); // Simulate failure
jest.mock('google-auth-library', () => ({
GoogleAuth: jest.fn().mockImplementation(() => ({
getAccessToken: jest.fn().mockResolvedValue('mocked_access_token'), // Success case
})),
}));
// Dynamically import the function after mocking
const { getGoogleOAuthJwtAccessToken } = await import('./get_gcp_oauth_access_token');
const accessToken = await getGoogleOAuthJwtAccessToken({
connectorId: 'failing_connector',
logger,
credentials: credentialsJson,
connectorTokenClient,
});
expect(accessToken).toBeDefined(); // Should still return a token (likely a new one)
expect(logger.warn).toHaveBeenCalledWith(
`Failed to get connector token for connectorId: failing_connector. Error: ${mockError.message}`
);
});
it('throws an error when Google Auth fails', async () => {
jest.mock('google-auth-library', () => ({
GoogleAuth: jest.fn().mockImplementation(() => ({
getAccessToken: jest.fn().mockRejectedValue(new Error('Google Auth Error')),
})),
}));
connectorTokenClient.get.mockResolvedValue({ connectorToken: null, hasErrors: false });
// Dynamically import the function after mocking
const { getGoogleOAuthJwtAccessToken } = await import('./get_gcp_oauth_access_token');
await expect(
getGoogleOAuthJwtAccessToken({
connectorId: 'test_connector',
logger,
credentials: {},
connectorTokenClient,
})
).rejects.toThrowError(
'Unable to retrieve access token. Ensure the service account has the right permissions and the Vertex AI endpoint is enabled in the GCP project. Error: Google Auth Error'
);
expect(connectorTokenClient.updateOrReplace).not.toHaveBeenCalled(); // No update
});
});

View file

@ -0,0 +1,89 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Logger } from '@kbn/core/server';
import { GoogleAuth } from 'google-auth-library';
import { ConnectorToken, ConnectorTokenClientContract } from '../types';
interface GetOAuthJwtAccessTokenOpts {
connectorId?: string;
logger: Logger;
credentials: object;
connectorTokenClient?: ConnectorTokenClientContract;
}
export const getGoogleOAuthJwtAccessToken = async ({
connectorId,
logger,
credentials,
connectorTokenClient,
}: GetOAuthJwtAccessTokenOpts) => {
let accessToken;
let connectorToken: ConnectorToken | null = null;
let hasErrors: boolean = false;
const expiresInSec = 3500;
if (connectorId && connectorTokenClient) {
try {
// Check if there is a token stored for this connector
const { connectorToken: token, hasErrors: errors } = await connectorTokenClient.get({
connectorId,
});
connectorToken = token;
hasErrors = errors;
} catch (error) {
logger.warn(
`Failed to get connector token for connectorId: ${connectorId}. Error: ${error.message}`
);
}
}
if (!connectorToken || Date.parse(connectorToken.expiresAt) <= Date.now()) {
const requestTokenStart = Date.now();
// Request access token with service account credentials file
const auth = new GoogleAuth({
credentials,
scopes: 'https://www.googleapis.com/auth/cloud-platform',
});
try {
accessToken = await auth.getAccessToken();
} catch (error) {
throw new Error(
`Unable to retrieve access token. Ensure the service account has the right permissions and the Vertex AI endpoint is enabled in the GCP project. Error: ${error.message}`
);
}
if (!accessToken) {
throw new Error(
`Error occurred while retrieving the access token. Ensure that the credentials are vaild.`
);
}
// Try to update connector token
if (connectorId && connectorTokenClient) {
try {
await connectorTokenClient.updateOrReplace({
connectorId,
token: connectorToken,
newToken: accessToken,
tokenRequestDate: requestTokenStart,
expiresInSec,
deleteExisting: hasErrors,
});
} catch (err) {
logger.warn(
`Not able to update connector token for connectorId: ${connectorId} due to error: ${err.message}`
);
}
}
} else {
// Use existing valid token
accessToken = connectorToken.token;
}
return accessToken;
};

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const GEMINI_TITLE = i18n.translate(
'xpack.stackConnectors.components.gemini.connectorTypeTitle',
{
defaultMessage: 'Google Gemini',
}
);
export const GEMINI_CONNECTOR_ID = '.gemini';
export enum SUB_ACTION {
RUN = 'run',
DASHBOARD = 'getDashboard',
TEST = 'test',
}
export const DEFAULT_TOKEN_LIMIT = 8192;
export const DEFAULT_TIMEOUT_MS = 60000;
export const DEFAULT_GCP_REGION = 'us-central1';
export const DEFAULT_GEMINI_MODEL = 'gemini-1.5-pro-preview-0409';
export const DEFAULT_GEMINI_URL = `https://us-central1-aiplatform.googleapis.com` as const;

View file

@ -0,0 +1,59 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { schema } from '@kbn/config-schema';
import { DEFAULT_GEMINI_MODEL } from './constants';
export const ConfigSchema = schema.object({
apiUrl: schema.string(),
defaultModel: schema.string({ defaultValue: DEFAULT_GEMINI_MODEL }),
gcpRegion: schema.string(),
gcpProjectID: schema.string(),
});
export const SecretsSchema = schema.object({
credentialsJson: schema.string(),
});
export const RunActionParamsSchema = schema.object({
body: schema.string(),
model: schema.maybe(schema.string()),
signal: schema.maybe(schema.any()),
timeout: schema.maybe(schema.number()),
});
export const RunApiResponseSchema = schema.object({
candidates: schema.any(),
usageMetadata: schema.object({
promptTokenCount: schema.number(),
candidatesTokenCount: schema.number(),
totalTokenCount: schema.number(),
}),
});
export const RunActionResponseSchema = schema.object(
{
completion: schema.string(),
stop_reason: schema.maybe(schema.string()),
usageMetadata: schema.maybe(
schema.object({
promptTokenCount: schema.number(),
candidatesTokenCount: schema.number(),
totalTokenCount: schema.number(),
})
),
},
{ unknowns: 'ignore' }
);
export const DashboardActionParamsSchema = schema.object({
dashboardId: schema.string(),
});
export const DashboardActionResponseSchema = schema.object({
available: schema.boolean(),
});

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { TypeOf } from '@kbn/config-schema';
import {
ConfigSchema,
DashboardActionParamsSchema,
DashboardActionResponseSchema,
SecretsSchema,
RunActionParamsSchema,
RunActionResponseSchema,
RunApiResponseSchema,
} from './schema';
export type Config = TypeOf<typeof ConfigSchema>;
export type Secrets = TypeOf<typeof SecretsSchema>;
export type RunActionParams = TypeOf<typeof RunActionParamsSchema>;
export type RunApiResponse = TypeOf<typeof RunApiResponseSchema>;
export type RunActionResponse = TypeOf<typeof RunActionResponseSchema>;
export type DashboardActionParams = TypeOf<typeof DashboardActionParamsSchema>;
export type DashboardActionResponse = TypeOf<typeof DashboardActionResponseSchema>;

View file

@ -0,0 +1,203 @@
/*
* 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 GeminiConnectorFields from './connector';
import { ConnectorFormTestProvider } from '../lib/test_utils';
import { act, fireEvent, render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useKibana } from '@kbn/triggers-actions-ui-plugin/public';
import { DEFAULT_GEMINI_MODEL } from '../../../common/gemini/constants';
import { useGetDashboard } from '../lib/gen_ai/use_get_dashboard';
import { createStartServicesMock } from '@kbn/triggers-actions-ui-plugin/public/common/lib/kibana/kibana_react.mock';
const mockUseKibanaReturnValue = createStartServicesMock();
jest.mock('@kbn/triggers-actions-ui-plugin/public/common/lib/kibana', () => ({
__esModule: true,
useKibana: jest.fn(() => ({
services: mockUseKibanaReturnValue,
})),
}));
jest.mock('../lib/gen_ai/use_get_dashboard');
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
const mockDashboard = useGetDashboard as jest.Mock;
const geminiConnector = {
actionTypeId: '.gemini',
name: 'gemini',
id: '123',
config: {
apiUrl: 'https://geminiurl.com',
defaultModel: DEFAULT_GEMINI_MODEL,
gcpRegion: 'us-central-1',
gcpProjectID: 'test-project',
},
secrets: {
credentialsJSON: JSON.stringify({
type: 'service_account',
project_id: '',
private_key_id: '',
private_key: '-----BEGIN PRIVATE KEY----------END PRIVATE KEY-----\n',
client_email: '',
client_id: '',
auth_uri: 'https://accounts.google.com/o/oauth2/auth',
token_uri: 'https://oauth2.googleapis.com/token',
auth_provider_x509_cert_url: 'https://www.googleapis.com/oauth2/v1/certs',
client_x509_cert_url: '',
}),
},
isDeprecated: false,
};
const navigateToUrl = jest.fn();
describe('GeminiConnectorFields renders', () => {
beforeEach(() => {
jest.clearAllMocks();
useKibanaMock().services.application.navigateToUrl = navigateToUrl;
mockDashboard.mockImplementation(({ connectorId }) => ({
dashboardUrl: `https://dashboardurl.com/${connectorId}`,
}));
});
test('Gemini connector fields are rendered', async () => {
const { getAllByTestId } = render(
<ConnectorFormTestProvider connector={geminiConnector}>
<GeminiConnectorFields
readOnly={false}
isEdit={false}
registerPreSubmitValidator={() => {}}
/>
</ConnectorFormTestProvider>
);
expect(getAllByTestId('config.apiUrl-input')[0]).toBeInTheDocument();
expect(getAllByTestId('config.apiUrl-input')[0]).toHaveValue(geminiConnector.config.apiUrl);
expect(getAllByTestId('config.defaultModel-input')[0]).toBeInTheDocument();
expect(getAllByTestId('config.defaultModel-input')[0]).toHaveValue(
geminiConnector.config.defaultModel
);
expect(getAllByTestId('gemini-api-doc')[0]).toBeInTheDocument();
expect(getAllByTestId('gemini-api-model-doc')[0]).toBeInTheDocument();
});
describe('Dashboard link', () => {
it('Does not render if isEdit is false and dashboardUrl is defined', async () => {
const { queryByTestId } = render(
<ConnectorFormTestProvider connector={geminiConnector}>
<GeminiConnectorFields
readOnly={false}
isEdit={false}
registerPreSubmitValidator={() => {}}
/>
</ConnectorFormTestProvider>
);
expect(queryByTestId('link-gen-ai-token-dashboard')).not.toBeInTheDocument();
});
it('Does not render if isEdit is true and dashboardUrl is null', async () => {
mockDashboard.mockImplementation((id: string) => ({
dashboardUrl: null,
}));
const { queryByTestId } = render(
<ConnectorFormTestProvider connector={geminiConnector}>
<GeminiConnectorFields readOnly={false} isEdit registerPreSubmitValidator={() => {}} />
</ConnectorFormTestProvider>
);
expect(queryByTestId('link-gen-ai-token-dashboard')).not.toBeInTheDocument();
});
it('Renders if isEdit is true and dashboardUrl is defined', async () => {
const { getByTestId } = render(
<ConnectorFormTestProvider connector={geminiConnector}>
<GeminiConnectorFields readOnly={false} isEdit registerPreSubmitValidator={() => {}} />
</ConnectorFormTestProvider>
);
expect(getByTestId('link-gen-ai-token-dashboard')).toBeInTheDocument();
});
it('On click triggers redirect with correct saved object id', async () => {
const { getByTestId } = render(
<ConnectorFormTestProvider connector={geminiConnector}>
<GeminiConnectorFields readOnly={false} isEdit registerPreSubmitValidator={() => {}} />
</ConnectorFormTestProvider>
);
fireEvent.click(getByTestId('link-gen-ai-token-dashboard'));
expect(navigateToUrl).toHaveBeenCalledWith(`https://dashboardurl.com/123`);
});
});
describe('Validation', () => {
const onSubmit = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
it('validates correctly if the apiUrl is empty', async () => {
const connector = {
...geminiConnector,
config: {
...geminiConnector.config,
apiUrl: '',
},
};
const res = render(
<ConnectorFormTestProvider connector={connector} onSubmit={onSubmit}>
<GeminiConnectorFields
readOnly={false}
isEdit={false}
registerPreSubmitValidator={() => {}}
/>
</ConnectorFormTestProvider>
);
await act(async () => {
userEvent.click(res.getByTestId('form-test-provide-submit'));
});
await waitFor(async () => {
expect(onSubmit).toHaveBeenCalled();
});
expect(onSubmit).toHaveBeenCalledWith({ data: {}, isValid: false });
});
const tests: Array<[string, string]> = [['config.apiUrl-input', 'not-valid']];
it.each(tests)('validates correctly %p', async (field, value) => {
const connector = {
...geminiConnector,
config: {
...geminiConnector.config,
headers: [],
},
};
const res = render(
<ConnectorFormTestProvider connector={connector} onSubmit={onSubmit}>
<GeminiConnectorFields
readOnly={false}
isEdit={false}
registerPreSubmitValidator={() => {}}
/>
</ConnectorFormTestProvider>
);
await act(async () => {
await userEvent.type(res.getByTestId(field), `{selectall}{backspace}${value}`, {
delay: 10,
});
});
await act(async () => {
userEvent.click(res.getByTestId('form-test-provide-submit'));
});
await waitFor(async () => {
expect(onSubmit).toHaveBeenCalled();
});
expect(onSubmit).toHaveBeenCalledWith({ data: {}, isValid: false });
});
});
});

View file

@ -0,0 +1,34 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import {
ActionConnectorFieldsProps,
SimpleConnectorForm,
} from '@kbn/triggers-actions-ui-plugin/public';
import { useFormData } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
import DashboardLink from './dashboard_link';
import { gemini } from './translations';
import { geminiConfig, geminiSecrets } from './constants';
const GeminiConnectorFields: React.FC<ActionConnectorFieldsProps> = ({ readOnly, isEdit }) => {
const [{ id, name }] = useFormData();
return (
<>
<SimpleConnectorForm
isEdit={isEdit}
readOnly={readOnly}
configFormSchema={geminiConfig}
secretsFormSchema={geminiSecrets}
/>
{isEdit && <DashboardLink connectorId={id} connectorName={name} selectedProvider={gemini} />}
</>
);
};
// eslint-disable-next-line import/no-default-export
export { GeminiConnectorFields as default };

View file

@ -0,0 +1,157 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { ConfigFieldSchema, SecretsFieldSchema } from '@kbn/triggers-actions-ui-plugin/public';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiLink } from '@elastic/eui';
import {
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_URL,
DEFAULT_TOKEN_LIMIT,
DEFAULT_GCP_REGION,
} from '../../../common/gemini/constants';
import * as i18n from './translations';
const generationConfig = {
temperature: 0,
maxOutputTokens: DEFAULT_TOKEN_LIMIT,
};
const contents = [
{
role: 'user',
parts: [
{
text: 'Write the first line of a story about a magic backpack.',
},
],
},
];
export const DEFAULT_BODY = JSON.stringify({
contents,
generation_config: generationConfig,
});
export const geminiConfig: ConfigFieldSchema[] = [
{
id: 'apiUrl',
label: i18n.API_URL_LABEL,
isUrlField: true,
defaultValue: DEFAULT_GEMINI_URL,
helpText: (
<FormattedMessage
defaultMessage="The Google Gemini API endpoint URL. For more information on the URL, refer to the {geminiAPIUrlDocs}."
id="xpack.stackConnectors.components.gemini.geminiAPIDocumentation"
values={{
geminiAPIUrlDocs: (
<EuiLink
data-test-subj="gemini-api-doc"
href="https://cloud.google.com/vertex-ai/generative-ai/docs/start/quickstarts/quickstart-multimodal#gemini-setup-environment-drest"
target="_blank"
>
{`${i18n.gemini} ${i18n.DOCUMENTATION}`}
</EuiLink>
),
}}
/>
),
},
{
id: 'gcpRegion',
label: i18n.GCP_REGION,
isUrlField: false,
defaultValue: DEFAULT_GCP_REGION,
helpText: (
<FormattedMessage
defaultMessage="Please provide the GCP region where the Vertex AI API(s) is enabled. For more information, refer to the {geminiVertexAIDocs}."
id="xpack.stackConnectors.components.gemini.geminiRegionDocumentation"
values={{
geminiVertexAIDocs: (
<EuiLink
data-test-subj="gemini-vertexai-api-doc"
href="https://cloud.google.com/vertex-ai/docs/reference/rest#rest_endpoints"
target="_blank"
>
{`${i18n.gemini} ${i18n.DOCUMENTATION}`}
</EuiLink>
),
}}
/>
),
},
{
id: 'gcpProjectID',
label: i18n.GCP_PROJECT_ID,
isUrlField: false,
helpText: (
<FormattedMessage
defaultMessage="The GCP Project ID which has Vertex AI API(s) enabled. For more information on the URL, refer to the {geminiVertexAIDocs}."
id="xpack.stackConnectors.components.gemini.geminiProjectDocumentation"
values={{
geminiVertexAIDocs: (
<EuiLink
data-test-subj="gemini-api-doc"
href="https://cloud.google.com/vertex-ai/docs/start/cloud-environment"
target="_blank"
>
{`${i18n.gemini} ${i18n.DOCUMENTATION}`}
</EuiLink>
),
}}
/>
),
},
{
id: 'defaultModel',
label: i18n.DEFAULT_MODEL_LABEL,
helpText: (
<FormattedMessage
defaultMessage="Current support is for the Gemini models. For more information, refer to the {geminiAPIModelDocs}."
id="xpack.stackConnectors.components.gemini.geminiModelDocumentation"
values={{
geminiAPIModelDocs: (
<EuiLink
data-test-subj="gemini-api-model-doc"
href="https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models/"
target="_blank"
>
{`${i18n.gemini} ${i18n.DOCUMENTATION}`}
</EuiLink>
),
}}
/>
),
defaultValue: DEFAULT_GEMINI_MODEL,
},
];
export const geminiSecrets: SecretsFieldSchema[] = [
{
id: 'credentialsJson',
label: i18n.CREDENTIALS_JSON,
isPasswordField: true,
helpText: (
<FormattedMessage
defaultMessage="To authenticate to Gemini API please provide your GCP Service Account credentials JSON data. For more information, refer to the {geminiAuthDocs}."
id="xpack.stackConnectors.components.gemini.geminiSecretDocumentation"
values={{
geminiAuthDocs: (
<EuiLink
data-test-subj="aws-api-keys-doc"
href="https://cloud.google.com/iam/docs/keys-list-get"
target="_blank"
>
{`${i18n.gemini} ${i18n.DOCUMENTATION}`}
</EuiLink>
),
}}
/>
),
},
];

View file

@ -0,0 +1,51 @@
/*
* 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 } from 'react';
import { EuiLink } from '@elastic/eui';
import { useKibana } from '@kbn/triggers-actions-ui-plugin/public';
import * as i18n from './translations';
import { useGetDashboard } from '../lib/gen_ai/use_get_dashboard';
interface Props {
connectorId: string;
connectorName: string;
selectedProvider?: string;
}
// tested from ./connector.test.tsx
export const DashboardLink: React.FC<Props> = ({
connectorId,
connectorName,
selectedProvider = 'Gemini',
}) => {
const { dashboardUrl } = useGetDashboard({ connectorId, selectedProvider });
const {
services: {
application: { navigateToUrl },
},
} = useKibana();
const onClick = useCallback(
(e) => {
e.preventDefault();
if (dashboardUrl) {
navigateToUrl(dashboardUrl);
}
},
[dashboardUrl, navigateToUrl]
);
return dashboardUrl != null ? (
// href gives us right click -> open in new tab
// onclick prevents page reload
// eslint-disable-next-line @elastic/eui/href-or-on-click
<EuiLink data-test-subj="link-gen-ai-token-dashboard" onClick={onClick} href={dashboardUrl}>
{i18n.USAGE_DASHBOARD_LINK(selectedProvider, connectorName)}
</EuiLink>
) : null;
};
// eslint-disable-next-line import/no-default-export
export { DashboardLink as default };

View file

@ -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 { 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/gemini/constants';
import { ExperimentalFeaturesService } from '../../common/experimental_features_service';
const ACTION_TYPE_ID = '.gemini';
let actionTypeModel: ActionTypeModel;
beforeAll(() => {
const connectorTypeRegistry = new TypeRegistry<ActionTypeModel>();
ExperimentalFeaturesService.init({ experimentalFeatures: experimentalFeaturesMock });
registerConnectorTypes({ connectorTypeRegistry, services: registrationServicesMock });
const getResult = connectorTypeRegistry.get(ACTION_TYPE_ID);
if (getResult !== null) {
actionTypeModel = getResult;
}
});
describe('actionTypeRegistry.get() works', () => {
test('connector type static data is as expected', () => {
expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID);
expect(actionTypeModel.selectMessage).toBe('Send a request to Google Gemini.');
expect(actionTypeModel.actionTypeTitle).toBe('Google Gemini');
});
});
describe('gemini action params validation', () => {
test('action params validation succeeds when action params is valid', async () => {
const actionParams = {
subAction: SUB_ACTION.RUN,
subActionParams: { body: '{"message": "test"}' },
};
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
errors: { body: [], subAction: [] },
});
});
test('params validation fails when body is not an object', async () => {
const actionParams = {
subAction: SUB_ACTION.RUN,
subActionParams: { body: 'message {test}' },
};
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
errors: { body: ['Body does not have a valid JSON format.'], subAction: [] },
});
});
test('params validation fails when subAction is missing', async () => {
const actionParams = {
subActionParams: { body: '{"message": "test"}' },
};
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
errors: {
body: [],
subAction: ['Action is required.'],
},
});
});
test('params validation fails when subActionParams is missing', async () => {
const actionParams = {
subAction: SUB_ACTION.RUN,
subActionParams: {},
};
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
errors: {
body: ['Body is required.'],
subAction: [],
},
});
});
});

View file

@ -0,0 +1,62 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { lazy } from 'react';
import { i18n } from '@kbn/i18n';
import type { GenericValidationResult } from '@kbn/triggers-actions-ui-plugin/public/types';
import { SUB_ACTION } from '../../../common/gemini/constants';
import { GEMINI_CONNECTOR_ID, GEMINI_TITLE } from '../../../common/gemini/constants';
import { GeminiActionParams, GeminiConnector } from './types';
interface ValidationErrors {
subAction: string[];
body: string[];
}
export function getConnectorType(): GeminiConnector {
return {
id: GEMINI_CONNECTOR_ID,
iconClass: lazy(() => import('./logo')),
selectMessage: i18n.translate('xpack.stackConnectors.components.gemini.selectMessageText', {
defaultMessage: 'Send a request to Google Gemini.',
}),
actionTypeTitle: GEMINI_TITLE,
validateParams: async (
actionParams: GeminiActionParams
): Promise<GenericValidationResult<ValidationErrors>> => {
const { subAction, subActionParams } = actionParams;
const translations = await import('./translations');
const errors: ValidationErrors = {
body: [],
subAction: [],
};
if (subAction === SUB_ACTION.TEST || subAction === SUB_ACTION.RUN) {
if (!subActionParams.body?.length) {
errors.body.push(translations.BODY_REQUIRED);
} else {
try {
JSON.parse(subActionParams.body);
} catch {
errors.body.push(translations.BODY_INVALID);
}
}
}
if (errors.body.length) return { errors };
// The internal "subAction" param should always be valid, ensure it is only if "subActionParams" are valid
if (!subAction) {
errors.subAction.push(translations.ACTION_REQUIRED);
} else if (subAction !== SUB_ACTION.RUN && subAction !== SUB_ACTION.TEST) {
errors.subAction.push(translations.INVALID_ACTION);
}
return { errors };
},
actionConnectorFields: lazy(() => import('./connector')),
actionParamsFields: lazy(() => import('./params')),
actionReadOnlyExtraComponent: lazy(() => import('./dashboard_link')),
};
}

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { getConnectorType as getGeminiConnectorType } from './gemini';

View file

@ -0,0 +1,35 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { LogoProps } from '../types';
const Logo = (props: LogoProps) => (
<svg xmlns="http://www.w3.org/2000/svg" width="56" height="56" fill="none" viewBox="0 0 16 16">
<path
d="M16 8.016A8.522 8.522 0 008.016 16h-.032A8.521 8.521 0 000 8.016v-.032A8.521 8.521 0 007.984 0h.032A8.522 8.522 0 0016 7.984v.032z"
fill="url(#prefix__paint0_radial_980_20147)"
/>
<defs>
<radialGradient
id="prefix__paint0_radial_980_20147"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(16.1326 5.4553 -43.70045 129.2322 1.588 6.503)"
>
<stop offset=".067" stopColor="#9168C0" />
<stop offset=".343" stopColor="#5684D1" />
<stop offset=".672" stopColor="#1BA1E3" />
</radialGradient>
</defs>
</svg>
);
// eslint-disable-next-line import/no-default-export
export { Logo as default };

View file

@ -0,0 +1,206 @@
/*
* 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 GeminiParamsFields from './params';
import { DEFAULT_GEMINI_URL, SUB_ACTION } from '../../../common/gemini/constants';
import { I18nProvider } from '@kbn/i18n-react';
const messageVariables = [
{
name: 'myVar',
description: 'My variable description',
useWithTripleBracesInTemplates: true,
},
];
describe('Gemini Params Fields renders', () => {
test('all params fields are rendered', () => {
const { getByTestId } = render(
<GeminiParamsFields
actionParams={{
subAction: SUB_ACTION.RUN,
subActionParams: { body: '{"message": "test"}' },
}}
errors={{ body: [] }}
editAction={() => {}}
index={0}
messageVariables={messageVariables}
/>,
{
wrapper: ({ children }) => <I18nProvider>{children}</I18nProvider>,
}
);
expect(getByTestId('bodyJsonEditor')).toBeInTheDocument();
expect(getByTestId('bodyJsonEditor')).toHaveProperty('value', '{"message": "test"}');
expect(getByTestId('bodyAddVariableButton')).toBeInTheDocument();
expect(getByTestId('gemini-model')).toBeInTheDocument();
});
test('useEffect handles the case when subAction and subActionParams are undefined', () => {
const actionParams = {
subAction: undefined,
subActionParams: undefined,
};
const editAction = jest.fn();
const errors = {};
const actionConnector = {
secrets: {
credentialsJSON: JSON.stringify({
type: 'service_account',
project_id: '',
private_key_id: '',
private_key: '-----BEGIN PRIVATE KEY----------END PRIVATE KEY-----\n',
client_email: '',
client_id: '',
auth_uri: 'https://accounts.google.com/o/oauth2/auth',
token_uri: 'https://oauth2.googleapis.com/token',
auth_provider_x509_cert_url: 'https://www.googleapis.com/oauth2/v1/certs',
client_x509_cert_url: '',
}),
},
id: 'test',
actionTypeId: '.gemini',
isPreconfigured: false,
isSystemAction: false as const,
isDeprecated: false,
name: 'My Gemini Connector',
config: {
apiUrl: DEFAULT_GEMINI_URL,
gcpRegion: 'us-central-1',
gcpProjectID: 'test-project',
},
};
render(
<GeminiParamsFields
actionParams={actionParams}
actionConnector={actionConnector}
editAction={editAction}
index={0}
messageVariables={messageVariables}
errors={errors}
/>,
{
wrapper: ({ children }) => <I18nProvider>{children}</I18nProvider>,
}
);
expect(editAction).toHaveBeenCalledTimes(2);
expect(editAction).toHaveBeenCalledWith('subAction', SUB_ACTION.RUN, 0);
});
it('handles the case when subAction only is undefined', () => {
const actionParams = {
subAction: undefined,
subActionParams: {
body: '{"key": "value"}',
},
};
const editAction = jest.fn();
const errors = {};
render(
<GeminiParamsFields
actionParams={actionParams}
editAction={editAction}
index={0}
messageVariables={messageVariables}
errors={errors}
/>,
{
wrapper: ({ children }) => <I18nProvider>{children}</I18nProvider>,
}
);
expect(editAction).toHaveBeenCalledTimes(1);
expect(editAction).toHaveBeenCalledWith('subAction', SUB_ACTION.RUN, 0);
});
it('calls editAction function with the body argument', () => {
const editAction = jest.fn();
const errors = {};
const { getByTestId } = render(
<GeminiParamsFields
actionParams={{
subAction: SUB_ACTION.RUN,
subActionParams: {
body: '{"key": "value"}',
},
}}
editAction={editAction}
index={0}
messageVariables={messageVariables}
errors={errors}
/>,
{
wrapper: ({ children }) => <I18nProvider>{children}</I18nProvider>,
}
);
const jsonEditor = getByTestId('bodyJsonEditor');
fireEvent.change(jsonEditor, { target: { value: '{"new_key": "new_value"}' } });
expect(editAction).toHaveBeenCalledWith(
'subActionParams',
{ body: '{"new_key": "new_value"}' },
0
);
});
it('removes trailing spaces from the body argument', () => {
const editAction = jest.fn();
const errors = {};
const { getByTestId } = render(
<GeminiParamsFields
actionParams={{
subAction: SUB_ACTION.RUN,
subActionParams: {
body: '{"key": "value"}',
},
}}
editAction={editAction}
index={0}
messageVariables={messageVariables}
errors={errors}
/>,
{
wrapper: ({ children }) => <I18nProvider>{children}</I18nProvider>,
}
);
const jsonEditor = getByTestId('bodyJsonEditor');
fireEvent.change(jsonEditor, { target: { value: '{"new_key": "new_value"} ' } });
expect(editAction).toHaveBeenCalledWith(
'subActionParams',
{ body: '{"new_key": "new_value"}' },
0
);
});
it('calls editAction function with the model argument', () => {
const editAction = jest.fn();
const errors = {};
const { getByTestId } = render(
<GeminiParamsFields
actionParams={{
subAction: SUB_ACTION.RUN,
subActionParams: {
body: '{"key": "value"}',
},
}}
editAction={editAction}
index={0}
messageVariables={messageVariables}
errors={errors}
/>,
{
wrapper: ({ children }) => <I18nProvider>{children}</I18nProvider>,
}
);
const model = getByTestId('gemini-model');
fireEvent.change(model, { target: { value: 'not-the-default' } });
expect(editAction).toHaveBeenCalledWith(
'subActionParams',
{ body: '{"key": "value"}', model: 'not-the-default' },
0
);
});
});

View file

@ -0,0 +1,124 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, useEffect, useMemo } from 'react';
import type { ActionParamsProps } from '@kbn/triggers-actions-ui-plugin/public';
import {
ActionConnectorMode,
JsonEditorWithMessageVariables,
} from '@kbn/triggers-actions-ui-plugin/public';
import { EuiFieldText, EuiFormRow, EuiLink } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { DEFAULT_BODY } from './constants';
import * as i18n from './translations';
import { DEFAULT_GEMINI_MODEL, SUB_ACTION } from '../../../common/gemini/constants';
import { GeminiActionParams } from './types';
const GeminiParamsFields: React.FunctionComponent<ActionParamsProps<GeminiActionParams>> = ({
actionParams,
editAction,
index,
messageVariables,
executionMode,
errors,
}) => {
const { subAction, subActionParams } = actionParams;
const { body, model } = subActionParams ?? {};
const isTest = useMemo(() => executionMode === ActionConnectorMode.Test, [executionMode]);
useEffect(() => {
if (!subAction) {
editAction('subAction', isTest ? SUB_ACTION.TEST : SUB_ACTION.RUN, index);
}
}, [editAction, index, isTest, subAction]);
useEffect(() => {
if (!subActionParams) {
editAction(
'subActionParams',
{
body: DEFAULT_BODY,
},
index
);
}
}, [editAction, index, subActionParams]);
useEffect(() => {
return () => {
// some gemini specific formatting gets messed up if we do not reset
// subActionParams on dismount (switching tabs between test and config)
editAction('subActionParams', undefined, index);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const editSubActionParams = useCallback(
(params: Partial<GeminiActionParams['subActionParams']>) => {
editAction('subActionParams', { ...subActionParams, ...params }, index);
},
[editAction, index, subActionParams]
);
return (
<>
<JsonEditorWithMessageVariables
messageVariables={messageVariables}
paramsProperty={'body'}
inputTargetValue={body}
label={i18n.BODY}
ariaLabel={i18n.BODY_DESCRIPTION}
errors={errors.body as string[]}
onDocumentsChange={(json: string) => {
editSubActionParams({ body: json.trim() });
}}
onBlur={() => {
if (!body) {
editSubActionParams({ body: '' });
}
}}
dataTestSubj="gemini-bodyJsonEditor"
/>
<EuiFormRow
fullWidth
label={i18n.MODEL}
helpText={
<FormattedMessage
defaultMessage="Optionally overwrite default model per request. Current support is for the Gemini 1.5 models. For more information, refer to the {geminiAPIModelDocs}."
id="xpack.stackConnectors.components.gemini.modelHelpText"
values={{
geminiAPIModelDocs: (
<EuiLink
data-test-subj="gemini-api-model-doc"
href="https://ai.google.dev/models/gemini/"
target="_blank"
>
{`${i18n.gemini} ${i18n.DOCUMENTATION}`}
</EuiLink>
),
}}
/>
}
>
<EuiFieldText
data-test-subj="gemini-model"
placeholder={DEFAULT_GEMINI_MODEL}
value={model}
onChange={(ev) => {
editSubActionParams({ model: ev.target.value });
}}
fullWidth
/>
</EuiFormRow>
</>
);
};
// eslint-disable-next-line import/no-default-export
export { GeminiParamsFields as default };

View file

@ -0,0 +1,109 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const API_URL_LABEL = i18n.translate(
'xpack.stackConnectors.components.gemini.apiUrlTextFieldLabel',
{
defaultMessage: 'URL',
}
);
export const GCP_REGION = i18n.translate('xpack.stackConnectors.components.gemini.gcpRegion', {
defaultMessage: 'GCP Region',
});
export const GCP_PROJECT_ID = i18n.translate(
'xpack.stackConnectors.components.gemini.gcpProjectID',
{
defaultMessage: 'GCP Project ID',
}
);
export const DEFAULT_MODEL_LABEL = i18n.translate(
'xpack.stackConnectors.components.gemini.defaultModelTextFieldLabel',
{
defaultMessage: 'Default model',
}
);
export const SECRET = i18n.translate('xpack.stackConnectors.components.gemini.secret', {
defaultMessage: 'Secret',
});
export const CREDENTIALS_JSON = i18n.translate(
'xpack.stackConnectors.components.gemini.credentialsJSON',
{
defaultMessage: 'Credentials JSON',
}
);
export const gemini = i18n.translate('xpack.stackConnectors.components.gemini.title', {
defaultMessage: 'Google Gemini',
});
export const DOCUMENTATION = i18n.translate(
'xpack.stackConnectors.components.gemini.documentation',
{
defaultMessage: 'documentation',
}
);
export const URL_LABEL = i18n.translate(
'xpack.stackConnectors.components.gemini.urlTextFieldLabel',
{
defaultMessage: 'URL',
}
);
export const BODY_REQUIRED = i18n.translate(
'xpack.stackConnectors.components.gemini.error.requiredgeminiBodyText',
{
defaultMessage: 'Body is required.',
}
);
export const BODY_INVALID = i18n.translate(
'xpack.stackConnectors.security.gemini.params.error.invalidBodyText',
{
defaultMessage: 'Body does not have a valid JSON format.',
}
);
export const ACTION_REQUIRED = i18n.translate(
'xpack.stackConnectors.security.gemini.params.error.requiredActionText',
{
defaultMessage: 'Action is required.',
}
);
export const INVALID_ACTION = i18n.translate(
'xpack.stackConnectors.security.gemini.params.error.invalidActionText',
{
defaultMessage: 'Invalid action name.',
}
);
export const BODY = i18n.translate('xpack.stackConnectors.components.gemini.bodyFieldLabel', {
defaultMessage: 'Body',
});
export const BODY_DESCRIPTION = i18n.translate(
'xpack.stackConnectors.components.gemini.bodyCodeEditorAriaLabel',
{
defaultMessage: 'Code editor',
}
);
export const MODEL = i18n.translate('xpack.stackConnectors.components.gemini.model', {
defaultMessage: 'Model',
});
export const USAGE_DASHBOARD_LINK = (apiProvider: string, connectorName: string) =>
i18n.translate('xpack.stackConnectors.components.gemini.dashboardLink', {
values: { apiProvider, connectorName },
defaultMessage: 'View {apiProvider} Usage Dashboard for "{ connectorName }" Connector',
});

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ActionTypeModel as ConnectorTypeModel } from '@kbn/triggers-actions-ui-plugin/public';
import { SUB_ACTION } from '../../../common/gemini/constants';
import { RunActionParams } from '../../../common/gemini/types';
export interface GeminiActionParams {
subAction: SUB_ACTION.RUN | SUB_ACTION.TEST | SUB_ACTION.DASHBOARD;
subActionParams: RunActionParams;
}
export interface Config {
apiUrl: string;
defaultModel: string;
gcpRegion: string;
gcpProjectID: string;
}
export interface Secrets {
credentialsJson: string;
}
export type GeminiConnector = ConnectorTypeModel<Config, Secrets, GeminiActionParams>;

View file

@ -13,6 +13,7 @@ import { getIndexConnectorType } from './es_index';
import { getJiraConnectorType } from './jira';
import { getOpenAIConnectorType } from './openai';
import { getBedrockConnectorType } from './bedrock';
import { getGeminiConnectorType } from './gemini';
import { getOpsgenieConnectorType } from './opsgenie';
import { getPagerDutyConnectorType } from './pagerduty';
import { getResilientConnectorType } from './resilient';
@ -65,6 +66,7 @@ export function registerConnectorTypes({
connectorTypeRegistry.register(getOpsgenieConnectorType());
connectorTypeRegistry.register(getOpenAIConnectorType());
connectorTypeRegistry.register(getBedrockConnectorType());
connectorTypeRegistry.register(getGeminiConnectorType());
connectorTypeRegistry.register(getTeamsConnectorType());
connectorTypeRegistry.register(getTorqConnectorType());
connectorTypeRegistry.register(getTinesConnectorType());

View file

@ -128,7 +128,12 @@ export const useGetDashboard = ({ connectorId, selectedProvider }: Props): UseGe
};
};
const getDashboardId = (selectedProvider: string, spaceId: string): string =>
`generative-ai-token-usage-${
selectedProvider.toLowerCase().includes('openai') ? 'openai' : 'bedrock'
}-${spaceId}`;
const getDashboardId = (selectedProvider: string, spaceId: string): string => {
let ai = 'openai';
if (selectedProvider.toLowerCase().includes('bedrock')) {
ai = 'bedrock';
} else if (selectedProvider.toLowerCase().includes('gemini')) {
ai = 'gemini';
}
return `generative-ai-token-usage-${ai}-${spaceId}`;
};

View file

@ -0,0 +1,192 @@
/*
* 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 { GeminiConnector } from './gemini';
import { RunActionParams } from '../../../common/gemini/types';
import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.mock';
import { actionsMock } from '@kbn/actions-plugin/server/mocks';
import { loggingSystemMock } from '@kbn/core-logging-server-mocks';
import { initDashboard } from '../lib/gen_ai/create_gen_ai_dashboard';
import { RunApiResponseSchema } from '../../../common/gemini/schema';
jest.mock('../lib/gen_ai/create_gen_ai_dashboard');
jest.mock('@kbn/actions-plugin/server/sub_action_framework/helpers/validators', () => ({
assertURL: jest.fn(),
}));
// Mock the imported function
jest.mock('@kbn/actions-plugin/server/lib/get_gcp_oauth_access_token', () => ({
getGoogleOAuthJwtAccessToken: jest.fn().mockResolvedValue('mock_access_token'),
}));
let mockRequest: jest.Mock;
describe('GeminiConnector', () => {
const defaultResponse = {
data: {
candidates: [{ content: { parts: [{ text: 'Paris' }] } }],
usageMetadata: { totalTokens: 0, promptTokens: 0, completionTokens: 0 },
},
};
const connectorResponse = {
completion: 'Paris',
usageMetadata: { totalTokens: 0, promptTokens: 0, completionTokens: 0 },
};
beforeEach(() => {
jest.clearAllMocks();
// @ts-expect-error
mockRequest = connector.request = jest.fn().mockResolvedValue(defaultResponse);
});
const connector = new GeminiConnector({
connector: { id: '1', type: '.gemini' },
configurationUtilities: actionsConfigMock.create(),
config: {
apiUrl: 'https://api.gemini.com',
defaultModel: 'gemini-1.5-pro-preview-0409',
gcpRegion: 'us-central1',
gcpProjectID: 'my-project-12345',
},
secrets: {
credentialsJson: JSON.stringify({
type: 'service_account',
project_id: '',
private_key_id: '',
private_key: '-----BEGIN PRIVATE KEY----------END PRIVATE KEY-----\n',
client_email: '',
client_id: '',
auth_uri: 'https://accounts.google.com/o/oauth2/auth',
token_uri: 'https://oauth2.googleapis.com/token',
auth_provider_x509_cert_url: 'https://www.googleapis.com/oauth2/v1/certs',
client_x509_cert_url: '',
}),
},
logger: loggingSystemMock.createLogger(),
services: actionsMock.createServices(),
});
describe('runApi', () => {
it('should send a formatted request to the API and return the response', async () => {
const runActionParams: RunActionParams = {
body: JSON.stringify({
messages: [
{
contents: [
{
role: 'user',
parts: [{ text: 'What is the capital of France?' }],
},
],
},
],
}),
model: 'test-model',
};
const response = await connector.runApi(runActionParams);
// Assertions
expect(mockRequest).toBeCalledTimes(1);
expect(mockRequest).toHaveBeenCalledWith({
url: 'https://api.gemini.com/v1/projects/my-project-12345/locations/us-central1/publishers/google/models/test-model:generateContent',
method: 'post',
data: JSON.stringify({
messages: [
{
contents: [
{
role: 'user',
parts: [{ text: 'What is the capital of France?' }],
},
],
},
],
}),
headers: {
Authorization: 'Bearer mock_access_token',
'Content-Type': 'application/json',
},
timeout: 60000,
responseSchema: RunApiResponseSchema,
signal: undefined,
});
expect(response).toEqual(connectorResponse);
});
});
describe('Token dashboard', () => {
const mockGenAi = initDashboard as jest.Mock;
beforeEach(() => {
// @ts-ignore
connector.esClient.transport.request = mockRequest;
mockRequest.mockResolvedValue({ has_all_requested: true });
mockGenAi.mockResolvedValue({ success: true });
jest.clearAllMocks();
});
it('the create dashboard API call returns available: true when user has correct permissions', async () => {
const response = await connector.getDashboard({ dashboardId: '123' });
expect(mockRequest).toBeCalledTimes(1);
expect(mockRequest).toHaveBeenCalledWith({
path: '/_security/user/_has_privileges',
method: 'POST',
body: {
index: [
{
names: ['.kibana-event-log-*'],
allow_restricted_indices: true,
privileges: ['read'],
},
],
},
});
expect(response).toEqual({ available: true });
});
it('the create dashboard API call returns available: false when user has correct permissions', async () => {
mockRequest.mockResolvedValue({ has_all_requested: false });
const response = await connector.getDashboard({ dashboardId: '123' });
expect(mockRequest).toBeCalledTimes(1);
expect(mockRequest).toHaveBeenCalledWith({
path: '/_security/user/_has_privileges',
method: 'POST',
body: {
index: [
{
names: ['.kibana-event-log-*'],
allow_restricted_indices: true,
privileges: ['read'],
},
],
},
});
expect(response).toEqual({ available: false });
});
it('the create dashboard API call returns available: false when init dashboard fails', async () => {
mockGenAi.mockResolvedValue({ success: false });
const response = await connector.getDashboard({ dashboardId: '123' });
expect(mockRequest).toBeCalledTimes(1);
expect(mockRequest).toHaveBeenCalledWith({
path: '/_security/user/_has_privileges',
method: 'POST',
body: {
index: [
{
names: ['.kibana-event-log-*'],
allow_restricted_indices: true,
privileges: ['read'],
},
],
},
});
expect(response).toEqual({ available: false });
});
});
});

View file

@ -0,0 +1,188 @@
/*
* 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 { AxiosError, Method } from 'axios';
import { SubActionRequestParams } from '@kbn/actions-plugin/server/sub_action_framework/types';
import { getGoogleOAuthJwtAccessToken } from '@kbn/actions-plugin/server/lib/get_gcp_oauth_access_token';
import { Logger } from '@kbn/core/server';
import { ConnectorTokenClientContract } from '@kbn/actions-plugin/server/types';
import { ActionsConfigurationUtilities } from '@kbn/actions-plugin/server/actions_config';
import { RunActionParamsSchema, RunApiResponseSchema } from '../../../common/gemini/schema';
import { initDashboard } from '../lib/gen_ai/create_gen_ai_dashboard';
import {
Config,
Secrets,
RunActionParams,
RunActionResponse,
RunApiResponse,
} from '../../../common/gemini/types';
import { SUB_ACTION, DEFAULT_TIMEOUT_MS } from '../../../common/gemini/constants';
import { DashboardActionParams, DashboardActionResponse } from '../../../common/gemini/types';
import { DashboardActionParamsSchema } from '../../../common/gemini/schema';
export interface GetAxiosInstanceOpts {
connectorId: string;
logger: Logger;
credentials: string;
snServiceUrl: string;
connectorTokenClient: ConnectorTokenClientContract;
configurationUtilities: ActionsConfigurationUtilities;
}
export class GeminiConnector extends SubActionConnector<Config, Secrets> {
private url;
private model;
private gcpRegion;
private gcpProjectID;
private connectorTokenClient: ConnectorTokenClientContract;
constructor(params: ServiceParams<Config, Secrets>) {
super(params);
this.url = this.config.apiUrl;
this.model = this.config.defaultModel;
this.gcpRegion = this.config.gcpRegion;
this.gcpProjectID = this.config.gcpProjectID;
this.logger = this.logger;
this.connectorID = this.connector.id;
this.connectorTokenClient = params.services.connectorTokenClient;
this.registerSubActions();
}
private registerSubActions() {
this.registerSubAction({
name: SUB_ACTION.RUN,
method: 'runApi',
schema: RunActionParamsSchema,
});
this.registerSubAction({
name: SUB_ACTION.DASHBOARD,
method: 'getDashboard',
schema: DashboardActionParamsSchema,
});
this.registerSubAction({
name: SUB_ACTION.TEST,
method: 'runApi',
schema: RunActionParamsSchema,
});
}
protected getResponseErrorMessage(error: AxiosError<{ message?: string }>): string {
if (!error.response?.status) {
return `Unexpected API Error: ${error.code ?? ''} - ${error.message ?? 'Unknown error'}`;
}
if (
error.response.status === 400 &&
error.response?.data?.message === 'The requested operation is not recognized by the service.'
) {
return `API Error: ${error.response.data.message}`;
}
if (error.response.status === 401) {
return `Unauthorized API Error${
error.response?.data?.message ? `: ${error.response.data.message}` : ''
}`;
}
return `API Error: ${error.response?.statusText}${
error.response?.data?.message ? ` - ${error.response.data.message}` : ''
}`;
}
/**
* retrieves a dashboard from the Kibana server and checks if the
* user has the necessary privileges to access it.
* @param dashboardId The ID of the dashboard to retrieve.
*/
public async getDashboard({
dashboardId,
}: DashboardActionParams): Promise<DashboardActionResponse> {
const privilege = (await this.esClient.transport.request({
path: '/_security/user/_has_privileges',
method: 'POST',
body: {
index: [
{
names: ['.kibana-event-log-*'],
allow_restricted_indices: true,
privileges: ['read'],
},
],
},
})) as { has_all_requested: boolean };
if (!privilege?.has_all_requested) {
return { available: false };
}
const response = await initDashboard({
logger: this.logger,
savedObjectsClient: this.savedObjectsClient,
dashboardId,
genAIProvider: 'Gemini',
});
return { available: response.success };
}
/** Retrieve access token based on the GCP service account credential json file */
private async getAccessToken(): Promise<string | null> {
// Validate the service account credentials JSON file input
let credentialsJSON;
try {
credentialsJSON = JSON.parse(this.secrets.credentialsJson);
} catch (error) {
throw new Error(`Failed to parse credentials JSON file: Invalid JSON format`);
}
const accessToken = await getGoogleOAuthJwtAccessToken({
connectorId: this.connector.id,
logger: this.logger,
credentials: credentialsJSON,
connectorTokenClient: this.connectorTokenClient,
});
return accessToken;
}
/**
* responsible for making a POST request to the Vertex AI API endpoint and returning the response data
* @param body The stringified request body to be sent in the POST request.
* @param model Optional model to be used for the API request. If not provided, the default model from the connector will be used.
*/
public async runApi({
body,
model: reqModel,
signal,
timeout,
}: RunActionParams): Promise<RunActionResponse> {
// set model on per request basis
const currentModel = reqModel ?? this.model;
const path = `/v1/projects/${this.gcpProjectID}/locations/${this.gcpRegion}/publishers/google/models/${currentModel}:generateContent`;
const token = await this.getAccessToken();
const requestArgs = {
url: `${this.url}${path}`,
method: 'post' as Method,
data: body,
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
signal,
timeout: timeout ?? DEFAULT_TIMEOUT_MS,
responseSchema: RunApiResponseSchema,
} as SubActionRequestParams<RunApiResponse>;
const response = await this.request(requestArgs);
const candidate = response.data.candidates[0];
const usageMetadata = response.data.usageMetadata;
const completionText = candidate.content.parts[0].text;
return { completion: completionText, usageMetadata };
}
}

View file

@ -0,0 +1,89 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.mock';
import { ActionsConfigurationUtilities } from '@kbn/actions-plugin/server/actions_config';
import axios from 'axios';
import { configValidator, getConnectorType } from '.';
import { Config, Secrets } from '../../../common/gemini/types';
import { SubActionConnectorType } from '@kbn/actions-plugin/server/sub_action_framework/types';
import { DEFAULT_GEMINI_MODEL } from '../../../common/gemini/constants';
jest.mock('axios');
jest.mock('@kbn/actions-plugin/server/lib/axios_utils', () => {
const originalUtils = jest.requireActual('@kbn/actions-plugin/server/lib/axios_utils');
return {
...originalUtils,
request: jest.fn(),
patch: jest.fn(),
};
});
axios.create = jest.fn(() => axios);
let connectorType: SubActionConnectorType<Config, Secrets>;
let configurationUtilities: jest.Mocked<ActionsConfigurationUtilities>;
describe('Gemini Connector', () => {
beforeEach(() => {
configurationUtilities = actionsConfigMock.create();
connectorType = getConnectorType();
});
test('exposes the connector as `Google Gemini` with id `.gemini`', () => {
expect(connectorType.id).toEqual('.gemini');
expect(connectorType.name).toEqual('Google Gemini');
});
describe('config validation', () => {
test('config validation passes when only required fields are provided', () => {
const config: Config = {
apiUrl: `https://us-central1-aiplatform.googleapis.com/v1/projects/test-gcpProject/locations/us-central-1/publishers/google/models/${DEFAULT_GEMINI_MODEL}:generateContent`,
defaultModel: DEFAULT_GEMINI_MODEL,
gcpRegion: 'us-central-1',
gcpProjectID: 'test-gcpProject',
};
expect(configValidator(config, { configurationUtilities })).toEqual(config);
});
test('config validation failed when a url is invalid', () => {
const config: Config = {
apiUrl: 'example.com/do-something',
defaultModel: DEFAULT_GEMINI_MODEL,
gcpRegion: 'us-central-1',
gcpProjectID: 'test-gcpProject',
};
expect(() => {
configValidator(config, { configurationUtilities });
}).toThrowErrorMatchingInlineSnapshot(
`"Error configuring Google Gemini action: Error: URL Error: Invalid URL: example.com/do-something"`
);
});
test('config validation returns an error if the specified URL is not added to allowedHosts', () => {
const configUtils = {
...actionsConfigMock.create(),
ensureUriAllowed: (_: string) => {
throw new Error(`target url is not present in allowedHosts`);
},
};
const config: Config = {
apiUrl: 'http://mylisteningserver.com:9200/endpoint',
defaultModel: DEFAULT_GEMINI_MODEL,
gcpRegion: 'us-central-1',
gcpProjectID: 'test-gcpProject',
};
expect(() => {
configValidator(config, { configurationUtilities: configUtils });
}).toThrowErrorMatchingInlineSnapshot(
`"Error configuring Google Gemini action: Error: error validating url: target url is not present in allowedHosts"`
);
});
});
});

View file

@ -0,0 +1,53 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import {
SubActionConnectorType,
ValidatorType,
} from '@kbn/actions-plugin/server/sub_action_framework/types';
import { GenerativeAIForSecurityConnectorFeatureId } from '@kbn/actions-plugin/common';
import { urlAllowListValidator } from '@kbn/actions-plugin/server';
import { ValidatorServices } from '@kbn/actions-plugin/server/types';
import { assertURL } from '@kbn/actions-plugin/server/sub_action_framework/helpers/validators';
import { GEMINI_CONNECTOR_ID, GEMINI_TITLE } from '../../../common/gemini/constants';
import { ConfigSchema, SecretsSchema } from '../../../common/gemini/schema';
import { Config, Secrets } from '../../../common/gemini/types';
import { GeminiConnector } from './gemini';
import { renderParameterTemplates } from './render';
export const getConnectorType = (): SubActionConnectorType<Config, Secrets> => ({
id: GEMINI_CONNECTOR_ID,
name: GEMINI_TITLE,
getService: (params) => new GeminiConnector(params),
schema: {
config: ConfigSchema,
secrets: SecretsSchema,
},
validators: [{ type: ValidatorType.CONFIG, validator: configValidator }],
supportedFeatureIds: [GenerativeAIForSecurityConnectorFeatureId],
minimumLicenseRequired: 'enterprise' as const,
renderParameterTemplates,
});
export const configValidator = (configObject: Config, validatorServices: ValidatorServices) => {
try {
assertURL(configObject.apiUrl);
urlAllowListValidator('apiUrl')(configObject, validatorServices);
return configObject;
} catch (err) {
throw new Error(
i18n.translate('xpack.stackConnectors.gemini.configurationErrorApiProvider', {
defaultMessage: 'Error configuring Google Gemini action: {err}',
values: {
err: err.toString(),
},
})
);
}
};

View file

@ -0,0 +1,52 @@
/*
* 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 { loggingSystemMock } from '@kbn/core/server/mocks';
import { renderParameterTemplates } from './render';
import Mustache from 'mustache';
const params = {
subAction: 'run',
subActionParams: {
body: '{"domain":"{{domain}}"}',
},
};
const variables = { domain: 'm0zepcuuu2' };
const logger = loggingSystemMock.createLogger();
describe('Gemini - renderParameterTemplates', () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('should not render body on test action', () => {
const testParams = { subAction: 'test', subActionParams: { body: 'test_json' } };
const result = renderParameterTemplates(logger, testParams, variables);
expect(result).toEqual(testParams);
});
it('should rendered body with variables', () => {
const result = renderParameterTemplates(logger, params, variables);
expect(result.subActionParams.body).toEqual(
JSON.stringify({
...variables,
})
);
});
it('should render error body', () => {
const errorMessage = 'test error';
jest.spyOn(Mustache, 'render').mockImplementation(() => {
throw new Error(errorMessage);
});
const result = renderParameterTemplates(logger, params, variables);
expect(result.subActionParams.body).toEqual(
'error rendering mustache template "{"domain":"{{domain}}"}": test error'
);
});
});

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ExecutorParams } from '@kbn/actions-plugin/server/sub_action_framework/types';
import { renderMustacheString } from '@kbn/actions-plugin/server/lib/mustache_renderer';
import { RenderParameterTemplates } from '@kbn/actions-plugin/server/types';
import { SUB_ACTION } from '../../../common/gemini/constants';
export const renderParameterTemplates: RenderParameterTemplates<ExecutorParams> = (
logger,
params,
variables
) => {
if (params?.subAction !== SUB_ACTION.RUN && params?.subAction !== SUB_ACTION.TEST) return params;
return {
...params,
subActionParams: {
...params.subActionParams,
body: renderMustacheString(logger, params.subActionParams.body as string, variables, 'json'),
},
};
};

View file

@ -19,6 +19,7 @@ import { getConnectorType as getEmailConnectorType } from './email';
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 getPagerDutyConnectorType } from './pagerduty';
import { getConnectorType as getSwimlaneConnectorType } from './swimlane';
import { getConnectorType as getServerLogConnectorType } from './server_log';
@ -105,6 +106,7 @@ export function registerConnectorTypes({
actions.registerSubActionConnectorType(getTinesConnectorType());
actions.registerSubActionConnectorType(getOpenAIConnectorType());
actions.registerSubActionConnectorType(getBedrockConnectorType());
actions.registerSubActionConnectorType(getGeminiConnectorType());
actions.registerSubActionConnectorType(getD3SecurityConnectorType());
actions.registerSubActionConnectorType(getResilientConnectorType());

View file

@ -24,7 +24,7 @@ export const initDashboard = async ({
logger: Logger;
savedObjectsClient: SavedObjectsClientContract;
dashboardId: string;
genAIProvider: 'OpenAI' | 'Bedrock';
genAIProvider: 'OpenAI' | 'Bedrock' | 'Gemini';
}): Promise<{
success: boolean;
error?: OutputError;

View file

@ -10,25 +10,39 @@ import { v4 as uuidv4 } from 'uuid';
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';
const getDashboardTitle = (title: string) => `${title} Token Usage`;
export const getDashboardTitle = (title: string) => `${title} Token Usage`;
export const getDashboard = (
genAIProvider: 'OpenAI' | 'Bedrock',
genAIProvider: 'OpenAI' | 'Bedrock' | 'Gemini',
dashboardId: string
): SavedObject<DashboardAttributes> => {
const attributes =
genAIProvider === 'OpenAI'
? {
provider: OPENAI_TITLE,
dashboardTitle: getDashboardTitle(OPENAI_TITLE),
actionTypeId: OPENAI_CONNECTOR_ID,
}
: {
provider: BEDROCK_TITLE,
dashboardTitle: getDashboardTitle(BEDROCK_TITLE),
actionTypeId: BEDROCK_CONNECTOR_ID,
};
let attributes = {
provider: OPENAI_TITLE,
dashboardTitle: getDashboardTitle(OPENAI_TITLE),
actionTypeId: OPENAI_CONNECTOR_ID,
};
if (genAIProvider === 'OpenAI') {
attributes = {
provider: OPENAI_TITLE,
dashboardTitle: getDashboardTitle(OPENAI_TITLE),
actionTypeId: OPENAI_CONNECTOR_ID,
};
} else if (genAIProvider === 'Bedrock') {
attributes = {
provider: BEDROCK_TITLE,
dashboardTitle: getDashboardTitle(BEDROCK_TITLE),
actionTypeId: BEDROCK_CONNECTOR_ID,
};
} else if (genAIProvider === 'Gemini') {
attributes = {
provider: GEMINI_TITLE,
dashboardTitle: getDashboardTitle(GEMINI_TITLE),
actionTypeId: GEMINI_CONNECTOR_ID,
};
}
const ids: Record<string, string> = {
genAiSavedObjectId: dashboardId,

View file

@ -131,7 +131,7 @@ describe('Stack Connectors Plugin', () => {
name: 'Torq',
})
);
expect(actionsSetup.registerSubActionConnectorType).toHaveBeenCalledTimes(7);
expect(actionsSetup.registerSubActionConnectorType).toHaveBeenCalledTimes(8);
expect(actionsSetup.registerSubActionConnectorType).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
@ -162,13 +162,20 @@ describe('Stack Connectors Plugin', () => {
);
expect(actionsSetup.registerSubActionConnectorType).toHaveBeenNthCalledWith(
5,
expect.objectContaining({
id: '.gemini',
name: 'Google Gemini',
})
);
expect(actionsSetup.registerSubActionConnectorType).toHaveBeenNthCalledWith(
6,
expect.objectContaining({
id: '.d3security',
name: 'D3 Security',
})
);
expect(actionsSetup.registerSubActionConnectorType).toHaveBeenNthCalledWith(
6,
7,
expect.objectContaining({
id: '.resilient',
name: 'IBM Resilient',

View file

@ -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 GeminiSimulator 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 GeminiSimulator.sendErrorResponse(response);
}
return GeminiSimulator.sendResponse(response);
}
private static sendResponse(response: http.ServerResponse) {
response.statusCode = 202;
response.setHeader('Content-Type', 'application/json');
response.end(JSON.stringify(geminiSuccessResponse, null, 4));
}
private static sendErrorResponse(response: http.ServerResponse) {
response.statusCode = 422;
response.setHeader('Content-Type', 'application/json;charset=UTF-8');
response.end(JSON.stringify(geminiFailedResponse, null, 4));
}
}
export const geminiSuccessResponse = {
refid: '80be4a0d-5f0e-4d6c-b00e-8cb918f7df1f',
};
export const geminiFailedResponse = {
error: {
statusMessage: 'Bad job',
},
};

View file

@ -0,0 +1,382 @@
/*
* 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 {
GeminiSimulator,
geminiSuccessResponse,
} from '@kbn/actions-simulators-plugin/server/gemini_simulation';
import { TaskErrorSource } from '@kbn/task-manager-plugin/common';
import { FtrProviderContext } from '../../../../../common/ftr_provider_context';
const connectorTypeId = '.gemini';
const name = 'A Gemini action';
const defaultConfig = {
gcpRegion: 'us-central-1',
gcpProjectID: 'test-project',
};
const secrets = {
credentialsJSON: JSON.stringify({
type: 'service_account',
project_id: '',
private_key_id: '',
private_key: '-----BEGIN PRIVATE KEY----------END PRIVATE KEY-----\n',
client_email: '',
client_id: '',
auth_uri: 'https://accounts.google.com/o/oauth2/auth',
token_uri: 'https://oauth2.googleapis.com/token',
auth_provider_x509_cert_url: 'https://www.googleapis.com/oauth2/v1/certs',
client_x509_cert_url: '',
}),
};
// eslint-disable-next-line import/no-default-export
export default function geminiTest({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const configService = getService('config');
const createConnector = async (url: string) => {
const { body } = await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name,
connector_type_id: connectorTypeId,
config: { ...defaultConfig, url },
secrets,
})
.expect(200);
return body.id;
};
describe('Gemini', () => {
describe('action creation', () => {
const simulator = new GeminiSimulator({
returnError: false,
proxy: {
config: configService.get('kbnTestServer.serverArgs'),
},
});
const config = { ...defaultConfig, url: '' };
before(async () => {
config.url = await simulator.start();
});
after(() => {
simulator.close();
});
it('should return 200 when creating the connector', async () => {
const { body: createdAction } = await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name,
connector_type_id: connectorTypeId,
config,
secrets,
})
.expect(200);
expect(createdAction).to.eql({
id: createdAction.id,
is_preconfigured: false,
is_system_action: false,
is_deprecated: false,
name,
connector_type_id: connectorTypeId,
is_missing_secrets: false,
config,
});
});
it('should return 400 Bad Request when creating the connector without the url, project id and region', async () => {
await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name,
connector_type_id: connectorTypeId,
config: {},
secrets,
})
.expect(400)
.then((resp: any) => {
expect(resp.body).to.eql({
statusCode: 400,
error: 'Bad Request',
message:
'error validating action type config: [url, gcpRegion, gcpProjectID]: expected value of type [string] but got [undefined]',
});
});
});
it('should return 400 Bad Request when creating the connector without the project id', async () => {
const testConfig = { gcpRegion: 'us-central-1', url: '' };
await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name,
connector_type_id: connectorTypeId,
testConfig,
secrets,
})
.expect(400)
.then((resp: any) => {
expect(resp.body).to.eql({
statusCode: 400,
error: 'Bad Request',
message:
'error validating action type config: [gcpProjectID]: expected value of type [string] but got [undefined]',
});
});
});
it('should return 400 Bad Request when creating the connector without the region', async () => {
const testConfig = { gcpProjectID: 'test-project', url: '' };
await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name,
connector_type_id: connectorTypeId,
testConfig,
secrets,
})
.expect(400)
.then((resp: any) => {
expect(resp.body).to.eql({
statusCode: 400,
error: 'Bad Request',
message:
'error validating action type config: [gcpRegion]: expected value of type [string] but got [undefined]',
});
});
});
it('should return 400 Bad Request when creating the connector with a url that is not allowed', async () => {
await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name,
connector_type_id: connectorTypeId,
config: {
url: 'http://gemini.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 validating url: target url "http://gemini.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: [token]: expected value of type [string] but got [undefined]',
});
});
});
});
describe('executor', () => {
describe('validation', () => {
const simulator = new GeminiSimulator({
proxy: {
config: configService.get('kbnTestServer.serverArgs'),
},
});
let geminiActionId: string;
before(async () => {
const url = await simulator.start();
geminiActionId = await createConnector(url);
});
after(() => {
simulator.close();
});
it('should fail when the params is empty', async () => {
const { body } = await supertest
.post(`/api/actions/connector/${geminiActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {},
});
expect(200);
expect(body).to.eql({
status: 'error',
connector_id: geminiActionId,
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/${geminiActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: { subAction: 'invalidAction' },
})
.expect(200);
expect(body).to.eql({
connector_id: geminiActionId,
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: ${geminiActionId}. Connector name: Gemini. Connector type: .gemini`,
});
});
});
describe('execution', () => {
describe('successful response simulator', () => {
const simulator = new GeminiSimulator({
proxy: {
config: configService.get('kbnTestServer.serverArgs'),
},
});
let url: string;
let geminiActionId: string;
before(async () => {
url = await simulator.start();
geminiActionId = await createConnector(url);
});
after(() => {
simulator.close();
});
it('should invoke AI with assistant AI body argument formatted to gemini expectations', async () => {
const { body } = await supertest
.post(`/api/actions/connector/${geminiActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
subAction: 'invokeAI',
subActionParams: {
contents: [
{
role: 'user',
parts: [
{
text: 'Hello',
},
],
},
{
role: 'model',
parts: [
{
text: 'Hi there, how can I help you today?',
},
],
},
{
role: 'user',
parts: [
{
text: 'Write the first line of a story about a magic backpack.',
},
],
},
],
generation_config: { temperature: 0, maxOutputTokens: 8192 },
},
},
})
.expect(200);
expect(simulator.requestData).to.eql({
contents: [
{
role: 'user',
parts: [{ text: 'Write the first line of a story about a magic backpack.' }],
},
],
generation_config: { temperature: 0, maxOutputTokens: 8192 },
});
expect(body).to.eql({
status: 'ok',
connector_id: geminiActionId,
data: { completion: geminiSuccessResponse },
});
});
});
describe('error response simulator', () => {
const simulator = new GeminiSimulator({
returnError: true,
proxy: {
config: configService.get('kbnTestServer.serverArgs'),
},
});
let geminiActionId: string;
before(async () => {
const url = await simulator.start();
geminiActionId = await createConnector(url);
});
after(() => {
simulator.close();
});
it('should return a failure when error happens', async () => {
const { body } = await supertest
.post(`/api/actions/connector/${geminiActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {},
})
.expect(200);
expect(body).to.eql({
status: 'error',
connector_id: geminiActionId,
message:
'error validating action params: [subAction]: expected value of type [string] but got [undefined]',
retry: false,
errorSource: TaskErrorSource.FRAMEWORK,
});
});
});
});
});
});
}

View file

@ -51,6 +51,7 @@ export default function createRegisteredConnectorTypeTests({ getService }: FtrPr
'.opsgenie',
'.gen-ai',
'.bedrock',
'.gemini',
'.sentinelone',
'.cases',
'.crowdstrike',

View file

@ -57,6 +57,7 @@ export default function ({ getService }: FtrProviderContext) {
'actions:.crowdstrike',
'actions:.d3security',
'actions:.email',
'actions:.gemini',
'actions:.gen-ai',
'actions:.index',
'actions:.jira',

View file

@ -23,7 +23,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
...functionalConfig.get('kbnTestServer.serverArgs'),
// used for connector simulators
`--xpack.actions.proxyUrl=http://localhost:${proxyPort}`,
`--xpack.actions.enabledActionTypes=${JSON.stringify(['.bedrock', '.gen-ai'])}`,
`--xpack.actions.enabledActionTypes=${JSON.stringify(['.bedrock', '.gen-ai', '.gemini'])}`,
],
},
testFiles: [require.resolve('..')],

View file

@ -16105,7 +16105,7 @@ ecc-jsbn@~0.1.1:
jsbn "~0.1.0"
safer-buffer "^2.1.0"
ecdsa-sig-formatter@1.0.11:
ecdsa-sig-formatter@1.0.11, ecdsa-sig-formatter@^1.0.11:
version "1.0.11"
resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf"
integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==
@ -17390,7 +17390,7 @@ extend-shallow@^3.0.0, extend-shallow@^3.0.2:
assign-symbols "^1.0.0"
is-extendable "^1.0.1"
extend@^3.0.0, extend@~3.0.2:
extend@^3.0.0, extend@^3.0.2, extend@~3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==
@ -18215,6 +18215,25 @@ gauge@^3.0.0:
strip-ansi "^6.0.1"
wide-align "^1.1.2"
gaxios@^6.0.0, gaxios@^6.1.1:
version "6.6.0"
resolved "https://registry.yarnpkg.com/gaxios/-/gaxios-6.6.0.tgz#af8242fff0bbb82a682840d5feaa91b6a1c58be4"
integrity sha512-bpOZVQV5gthH/jVCSuYuokRo2bTKOcuBiVWpjmTn6C5Agl5zclGfTljuGsQZxwwDBkli+YhZhP4TdlqTnhOezQ==
dependencies:
extend "^3.0.2"
https-proxy-agent "^7.0.1"
is-stream "^2.0.0"
node-fetch "^2.6.9"
uuid "^9.0.1"
gcp-metadata@^6.1.0:
version "6.1.0"
resolved "https://registry.yarnpkg.com/gcp-metadata/-/gcp-metadata-6.1.0.tgz#9b0dd2b2445258e7597f2024332d20611cbd6b8c"
integrity sha512-Jh/AIwwgaxan+7ZUUmRLCjtchyDiqh4KjBJ5tW3plBZb5iL/BPcso8A5DlzeD9qlw0duCamnNdpFjxwaT0KyKg==
dependencies:
gaxios "^6.0.0"
json-bigint "^1.0.0"
geckodriver@^4.4.1:
version "4.4.1"
resolved "https://registry.yarnpkg.com/geckodriver/-/geckodriver-4.4.1.tgz#b39b26a17f9166038702743f5722b6d83e0483f6"
@ -18640,6 +18659,18 @@ gonzales-pe@^4.3.0:
dependencies:
minimist "^1.2.5"
google-auth-library@^9.10.0:
version "9.10.0"
resolved "https://registry.yarnpkg.com/google-auth-library/-/google-auth-library-9.10.0.tgz#c9fb940923f7ff2569d61982ee1748578c0bbfd4"
integrity sha512-ol+oSa5NbcGdDqA+gZ3G3mev59OHBZksBTxY/tYwjtcp1H/scAFwJfSQU9/1RALoyZ7FslNbke8j4i3ipwlyuQ==
dependencies:
base64-js "^1.3.0"
ecdsa-sig-formatter "^1.0.11"
gaxios "^6.1.1"
gcp-metadata "^6.1.0"
gtoken "^7.0.0"
jws "^4.0.0"
google-protobuf@^3.6.1:
version "3.19.4"
resolved "https://registry.yarnpkg.com/google-protobuf/-/google-protobuf-3.19.4.tgz#8d32c3e34be9250956f28c0fb90955d13f311888"
@ -18727,6 +18758,14 @@ graphql@^16.6.0:
resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.8.1.tgz#1930a965bef1170603702acdb68aedd3f3cf6f07"
integrity sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==
gtoken@^7.0.0:
version "7.1.0"
resolved "https://registry.yarnpkg.com/gtoken/-/gtoken-7.1.0.tgz#d61b4ebd10132222817f7222b1e6064bd463fc26"
integrity sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==
dependencies:
gaxios "^6.0.0"
jws "^4.0.0"
gulp-brotli@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/gulp-brotli/-/gulp-brotli-3.0.0.tgz#7f5a1d8a6d43cab28056f9e56f29ae071dcfe4b4"
@ -21452,6 +21491,15 @@ jwa@^1.4.1:
ecdsa-sig-formatter "1.0.11"
safe-buffer "^5.0.1"
jwa@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/jwa/-/jwa-2.0.0.tgz#a7e9c3f29dae94027ebcaf49975c9345593410fc"
integrity sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==
dependencies:
buffer-equal-constant-time "1.0.1"
ecdsa-sig-formatter "1.0.11"
safe-buffer "^5.0.1"
jws@^3.2.2:
version "3.2.2"
resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304"
@ -21460,6 +21508,14 @@ jws@^3.2.2:
jwa "^1.4.1"
safe-buffer "^5.0.1"
jws@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/jws/-/jws-4.0.0.tgz#2d4e8cf6a318ffaa12615e9dec7e86e6c97310f4"
integrity sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==
dependencies:
jwa "^2.0.0"
safe-buffer "^5.0.1"
kdbush@^4.0.2:
version "4.0.2"
resolved "https://registry.yarnpkg.com/kdbush/-/kdbush-4.0.2.tgz#2f7b7246328b4657dd122b6c7f025fbc2c868e39"
@ -23571,7 +23627,7 @@ node-fetch-h2@^2.3.0:
dependencies:
http2-client "^1.2.5"
node-fetch@^2.6.1, node-fetch@^2.6.7:
node-fetch@^2.6.1, node-fetch@^2.6.7, node-fetch@^2.6.9:
version "2.7.0"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d"
integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==
@ -30905,7 +30961,7 @@ uuid-browser@^3.1.0:
resolved "https://registry.yarnpkg.com/uuid-browser/-/uuid-browser-3.1.0.tgz#0f05a40aef74f9e5951e20efbf44b11871e56410"
integrity sha1-DwWkCu90+eWVHiDvv0SxGHHlZBA=
uuid@9.0.0, uuid@^9, uuid@^9.0.0:
uuid@9.0.0:
version "9.0.0"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.0.tgz#592f550650024a38ceb0c562f2f6aa435761efb5"
integrity sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==
@ -30920,6 +30976,11 @@ uuid@^8.0.0, uuid@^8.3.0, uuid@^8.3.2:
resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"
integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==
uuid@^9, uuid@^9.0.0, uuid@^9.0.1:
version "9.0.1"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30"
integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==
v8-compile-cache-lib@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf"