diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 322780e137b67..5f0a5a3f4bc3d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/docs/management/action-types.asciidoc b/docs/management/action-types.asciidoc index 0d8f43925a1fc..d1dc68045110b 100644 --- a/docs/management/action-types.asciidoc +++ b/docs/management/action-types.asciidoc @@ -20,6 +20,10 @@ a| <> | Send a request to D3 Security. +a| <> + +| Send a request to {gemini}. + a| <> | Send email from your server. diff --git a/docs/management/connectors/action-types/gemini.asciidoc b/docs/management/connectors/action-types/gemini.asciidoc new file mode 100644 index 0000000000000..d1693f0b5ec28 --- /dev/null +++ b/docs/management/connectors/action-types/gemini.asciidoc @@ -0,0 +1,74 @@ +[[gemini-action-type]] +== {gemini} connector and action +++++ +{gemini} +++++ +: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 <> 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 <> 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 <> 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. diff --git a/docs/management/connectors/images/gemini-connector.png b/docs/management/connectors/images/gemini-connector.png new file mode 100644 index 0000000000000..fdb3bb8144d6f Binary files /dev/null and b/docs/management/connectors/images/gemini-connector.png differ diff --git a/docs/management/connectors/images/gemini-params.png b/docs/management/connectors/images/gemini-params.png new file mode 100644 index 0000000000000..b08456f5bf3f0 Binary files /dev/null and b/docs/management/connectors/images/gemini-params.png differ diff --git a/docs/management/connectors/index.asciidoc b/docs/management/connectors/index.asciidoc index 2439de69f364e..a5f0c7fbd5503 100644 --- a/docs/management/connectors/index.asciidoc +++ b/docs/management/connectors/index.asciidoc @@ -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] diff --git a/docs/settings/alert-action-settings.asciidoc b/docs/settings/alert-action-settings.asciidoc index 7bd1a09396d01..6528e1a60b7bc 100644 --- a/docs/settings/alert-action-settings.asciidoc +++ b/docs/settings/alert-action-settings.asciidoc @@ -138,7 +138,7 @@ WARNING: This feature is available in {kib} 7.17.4 and 8.3.0 onwards but is not A boolean value indicating that a footer with a relevant link should be added to emails sent as alerting actions. Default: true. `xpack.actions.enabledActionTypes` {ess-icon}:: -A list of action types that are enabled. It defaults to `["*"]`, enabling all types. The names for built-in {kib} action types are prefixed with a `.` and include: `.email`, `.index`, `.jira`, `.opsgenie`, `.pagerduty`, `.resilient`, `.server-log`, `.servicenow`, .`servicenow-itom`, `.servicenow-sir`, `.slack`, `.swimlane`, `.teams`, `.tines`, `.torq`, `.xmatters`, `.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 <>, specifies the {bedrock} request URL. +* For an <>, specifies the {gemini} request URL. * For a <>, specifies the OpenAI request URL. * For a <>, specifies the {ibm-r} instance URL. * For a <>, specifies the Jira instance URL. @@ -341,6 +342,7 @@ The default model to use for requests, which varies by connector: + -- * For an <>, current support is for the Anthropic Claude models. Defaults to `anthropic.claude-3-sonnet-20240229-v1:0`. +* For a <>, current support is for the Gemini models. Defaults to `gemini-1.5-pro-preview-0409`. * For a <>, it is optional and applicable only when `xpack.actions.preconfigured..config.apiProvider` is `OpenAI`. -- @@ -483,6 +485,9 @@ For an <>, specifies the AWS access key `xpack.actions.preconfigured..secrets.apikey`:: An API key secret that varies by connector: + +`xpack.actions.preconfigured..secrets.credentialsJSON`:: +For an <>, specifies the GCP service account credentials JSON file for authentication. + -- * For a <>, specifies the OpenAI or Azure OpenAI API key for authentication. diff --git a/package.json b/package.json index f8b779401c0b0..ca17249520735 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/x-pack/packages/kbn-elastic-assistant/impl/mock/connectors.ts b/x-pack/packages/kbn-elastic-assistant/impl/mock/connectors.ts index 05b4e96d2b0da..34dfeb98ce29d 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/mock/connectors.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/mock/connectors.ts @@ -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[] = [ diff --git a/x-pack/plugins/actions/docs/openapi/bundled_serverless.json b/x-pack/plugins/actions/docs/openapi/bundled_serverless.json index 0f55981305631..821f74fb1424c 100644 --- a/x-pack/plugins/actions/docs/openapi/bundled_serverless.json +++ b/x-pack/plugins/actions/docs/openapi/bundled_serverless.json @@ -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 } ] } diff --git a/x-pack/plugins/actions/docs/openapi/components/examples/get_connector_types_generativeai_response.yaml b/x-pack/plugins/actions/docs/openapi/components/examples/get_connector_types_generativeai_response.yaml index 96eff6e72f247..b271d7f0f3df3 100644 --- a/x-pack/plugins/actions/docs/openapi/components/examples/get_connector_types_generativeai_response.yaml +++ b/x-pack/plugins/actions/docs/openapi/components/examples/get_connector_types_generativeai_response.yaml @@ -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 diff --git a/x-pack/plugins/actions/docs/openapi/components/schemas/connector_response_properties.yaml b/x-pack/plugins/actions/docs/openapi/components/schemas/connector_response_properties.yaml index e64bc1aaf4658..2622f6a80dc99 100644 --- a/x-pack/plugins/actions/docs/openapi/components/schemas/connector_response_properties.yaml +++ b/x-pack/plugins/actions/docs/openapi/components/schemas/connector_response_properties.yaml @@ -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' diff --git a/x-pack/plugins/actions/docs/openapi/components/schemas/connector_types.yaml b/x-pack/plugins/actions/docs/openapi/components/schemas/connector_types.yaml index 950d6470cefe3..f202efc087b00 100644 --- a/x-pack/plugins/actions/docs/openapi/components/schemas/connector_types.yaml +++ b/x-pack/plugins/actions/docs/openapi/components/schemas/connector_types.yaml @@ -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 diff --git a/x-pack/plugins/actions/docs/openapi/components/schemas/create_connector_request.yaml b/x-pack/plugins/actions/docs/openapi/components/schemas/create_connector_request.yaml index 35720488e1468..7de48825999f3 100644 --- a/x-pack/plugins/actions/docs/openapi/components/schemas/create_connector_request.yaml +++ b/x-pack/plugins/actions/docs/openapi/components/schemas/create_connector_request.yaml @@ -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' diff --git a/x-pack/plugins/actions/docs/openapi/components/schemas/update_connector_request.yaml b/x-pack/plugins/actions/docs/openapi/components/schemas/update_connector_request.yaml index 1947f7433186e..d87e508ba22d8 100644 --- a/x-pack/plugins/actions/docs/openapi/components/schemas/update_connector_request.yaml +++ b/x-pack/plugins/actions/docs/openapi/components/schemas/update_connector_request.yaml @@ -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' diff --git a/x-pack/plugins/actions/server/integration_tests/__snapshots__/connector_types.test.ts.snap b/x-pack/plugins/actions/server/integration_tests/__snapshots__/connector_types.test.ts.snap index b41e9ef70df78..10f8265b15cc3 100644 --- a/x-pack/plugins/actions/server/integration_tests/__snapshots__/connector_types.test.ts.snap +++ b/x-pack/plugins/actions/server/integration_tests/__snapshots__/connector_types.test.ts.snap @@ -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 { diff --git a/x-pack/plugins/actions/server/integration_tests/mocks/connector_types.ts b/x-pack/plugins/actions/server/integration_tests/mocks/connector_types.ts index d90756d1bb594..c2ad283861b4f 100644 --- a/x-pack/plugins/actions/server/integration_tests/mocks/connector_types.ts +++ b/x-pack/plugins/actions/server/integration_tests/mocks/connector_types.ts @@ -26,6 +26,7 @@ export const connectorTypes: string[] = [ '.tines', '.gen-ai', '.bedrock', + '.gemini', '.d3security', '.resilient', '.sentinelone', diff --git a/x-pack/plugins/actions/server/lib/gen_ai_token_tracking.test.ts b/x-pack/plugins/actions/server/lib/gen_ai_token_tracking.test.ts index 7e112398d4508..c3624178046fc 100644 --- a/x-pack/plugins/actions/server/lib/gen_ai_token_tracking.test.ts +++ b/x-pack/plugins/actions/server/lib/gen_ai_token_tracking.test.ts @@ -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); }); diff --git a/x-pack/plugins/actions/server/lib/gen_ai_token_tracking.ts b/x-pack/plugins/actions/server/lib/gen_ai_token_tracking.ts index 41dffcbaafebf..15c5b234d24bc 100644 --- a/x-pack/plugins/actions/server/lib/gen_ai_token_tracking.ts +++ b/x-pack/plugins/actions/server/lib/gen_ai_token_tracking.ts @@ -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'; diff --git a/x-pack/plugins/actions/server/lib/get_gcp_oauth_access_token.test.ts b/x-pack/plugins/actions/server/lib/get_gcp_oauth_access_token.test.ts new file mode 100644 index 0000000000000..436d75dc1c775 --- /dev/null +++ b/x-pack/plugins/actions/server/lib/get_gcp_oauth_access_token.test.ts @@ -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; + 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 + }); +}); diff --git a/x-pack/plugins/actions/server/lib/get_gcp_oauth_access_token.ts b/x-pack/plugins/actions/server/lib/get_gcp_oauth_access_token.ts new file mode 100644 index 0000000000000..3efe7900ea6b6 --- /dev/null +++ b/x-pack/plugins/actions/server/lib/get_gcp_oauth_access_token.ts @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { 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; +}; diff --git a/x-pack/plugins/stack_connectors/common/gemini/constants.ts b/x-pack/plugins/stack_connectors/common/gemini/constants.ts new file mode 100644 index 0000000000000..2df2e4f635e33 --- /dev/null +++ b/x-pack/plugins/stack_connectors/common/gemini/constants.ts @@ -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; diff --git a/x-pack/plugins/stack_connectors/common/gemini/schema.ts b/x-pack/plugins/stack_connectors/common/gemini/schema.ts new file mode 100644 index 0000000000000..aa29f92916db9 --- /dev/null +++ b/x-pack/plugins/stack_connectors/common/gemini/schema.ts @@ -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(), +}); diff --git a/x-pack/plugins/stack_connectors/common/gemini/types.ts b/x-pack/plugins/stack_connectors/common/gemini/types.ts new file mode 100644 index 0000000000000..12d3ed4a6b1c4 --- /dev/null +++ b/x-pack/plugins/stack_connectors/common/gemini/types.ts @@ -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; +export type Secrets = TypeOf; +export type RunActionParams = TypeOf; +export type RunApiResponse = TypeOf; +export type RunActionResponse = TypeOf; +export type DashboardActionParams = TypeOf; +export type DashboardActionResponse = TypeOf; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/gemini/connector.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/gemini/connector.test.tsx new file mode 100644 index 0000000000000..55e7385926ad1 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/gemini/connector.test.tsx @@ -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; +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( + + {}} + /> + + ); + + 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( + + {}} + /> + + ); + 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( + + {}} /> + + ); + expect(queryByTestId('link-gen-ai-token-dashboard')).not.toBeInTheDocument(); + }); + it('Renders if isEdit is true and dashboardUrl is defined', async () => { + const { getByTestId } = render( + + {}} /> + + ); + expect(getByTestId('link-gen-ai-token-dashboard')).toBeInTheDocument(); + }); + it('On click triggers redirect with correct saved object id', async () => { + const { getByTestId } = render( + + {}} /> + + ); + 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( + + {}} + /> + + ); + + 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( + + {}} + /> + + ); + + await act(async () => { + await userEvent.type(res.getByTestId(field), `{selectall}{backspace}${value}`, { + delay: 10, + }); + }); + + await act(async () => { + userEvent.click(res.getByTestId('form-test-provide-submit')); + }); + await waitFor(async () => { + expect(onSubmit).toHaveBeenCalled(); + }); + + expect(onSubmit).toHaveBeenCalledWith({ data: {}, isValid: false }); + }); + }); +}); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/gemini/connector.tsx b/x-pack/plugins/stack_connectors/public/connector_types/gemini/connector.tsx new file mode 100644 index 0000000000000..72124b2b9857c --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/gemini/connector.tsx @@ -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 = ({ readOnly, isEdit }) => { + const [{ id, name }] = useFormData(); + return ( + <> + + {isEdit && } + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { GeminiConnectorFields as default }; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/gemini/constants.tsx b/x-pack/plugins/stack_connectors/public/connector_types/gemini/constants.tsx new file mode 100644 index 0000000000000..162f78efabc48 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/gemini/constants.tsx @@ -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: ( + + {`${i18n.gemini} ${i18n.DOCUMENTATION}`} + + ), + }} + /> + ), + }, + { + id: 'gcpRegion', + label: i18n.GCP_REGION, + isUrlField: false, + defaultValue: DEFAULT_GCP_REGION, + helpText: ( + + {`${i18n.gemini} ${i18n.DOCUMENTATION}`} + + ), + }} + /> + ), + }, + { + id: 'gcpProjectID', + label: i18n.GCP_PROJECT_ID, + isUrlField: false, + helpText: ( + + {`${i18n.gemini} ${i18n.DOCUMENTATION}`} + + ), + }} + /> + ), + }, + { + id: 'defaultModel', + label: i18n.DEFAULT_MODEL_LABEL, + helpText: ( + + {`${i18n.gemini} ${i18n.DOCUMENTATION}`} + + ), + }} + /> + ), + defaultValue: DEFAULT_GEMINI_MODEL, + }, +]; + +export const geminiSecrets: SecretsFieldSchema[] = [ + { + id: 'credentialsJson', + label: i18n.CREDENTIALS_JSON, + isPasswordField: true, + helpText: ( + + {`${i18n.gemini} ${i18n.DOCUMENTATION}`} + + ), + }} + /> + ), + }, +]; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/gemini/dashboard_link.tsx b/x-pack/plugins/stack_connectors/public/connector_types/gemini/dashboard_link.tsx new file mode 100644 index 0000000000000..6812866396612 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/gemini/dashboard_link.tsx @@ -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 = ({ + 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 + + {i18n.USAGE_DASHBOARD_LINK(selectedProvider, connectorName)} + + ) : null; +}; + +// eslint-disable-next-line import/no-default-export +export { DashboardLink as default }; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/gemini/gemini.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/gemini/gemini.test.tsx new file mode 100644 index 0000000000000..3b384314fd121 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/gemini/gemini.test.tsx @@ -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(); + 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: [], + }, + }); + }); +}); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/gemini/gemini.tsx b/x-pack/plugins/stack_connectors/public/connector_types/gemini/gemini.tsx new file mode 100644 index 0000000000000..f40120053f446 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/gemini/gemini.tsx @@ -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> => { + 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')), + }; +} diff --git a/x-pack/plugins/stack_connectors/public/connector_types/gemini/index.ts b/x-pack/plugins/stack_connectors/public/connector_types/gemini/index.ts new file mode 100644 index 0000000000000..c826ff12f6424 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/gemini/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { getConnectorType as getGeminiConnectorType } from './gemini'; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/gemini/logo.tsx b/x-pack/plugins/stack_connectors/public/connector_types/gemini/logo.tsx new file mode 100644 index 0000000000000..dd09c31fb7079 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/gemini/logo.tsx @@ -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) => ( + + + + + + + + + + +); + +// eslint-disable-next-line import/no-default-export +export { Logo as default }; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/gemini/params.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/gemini/params.test.tsx new file mode 100644 index 0000000000000..b8e3b3029b284 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/gemini/params.test.tsx @@ -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( + {}} + index={0} + messageVariables={messageVariables} + />, + { + wrapper: ({ children }) => {children}, + } + ); + 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( + , + { + wrapper: ({ children }) => {children}, + } + ); + 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( + , + { + wrapper: ({ children }) => {children}, + } + ); + 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( + , + { + wrapper: ({ children }) => {children}, + } + ); + 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( + , + { + wrapper: ({ children }) => {children}, + } + ); + 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( + , + { + wrapper: ({ children }) => {children}, + } + ); + 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 + ); + }); +}); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/gemini/params.tsx b/x-pack/plugins/stack_connectors/public/connector_types/gemini/params.tsx new file mode 100644 index 0000000000000..3de9998adba13 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/gemini/params.tsx @@ -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> = ({ + 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) => { + editAction('subActionParams', { ...subActionParams, ...params }, index); + }, + [editAction, index, subActionParams] + ); + + return ( + <> + { + editSubActionParams({ body: json.trim() }); + }} + onBlur={() => { + if (!body) { + editSubActionParams({ body: '' }); + } + }} + dataTestSubj="gemini-bodyJsonEditor" + /> + + {`${i18n.gemini} ${i18n.DOCUMENTATION}`} + + ), + }} + /> + } + > + { + editSubActionParams({ model: ev.target.value }); + }} + fullWidth + /> + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { GeminiParamsFields as default }; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/gemini/translations.ts b/x-pack/plugins/stack_connectors/public/connector_types/gemini/translations.ts new file mode 100644 index 0000000000000..7d9d0e00d0a37 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/gemini/translations.ts @@ -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', + }); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/gemini/types.ts b/x-pack/plugins/stack_connectors/public/connector_types/gemini/types.ts new file mode 100644 index 0000000000000..4ee1eb4f45df0 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/gemini/types.ts @@ -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; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/index.ts b/x-pack/plugins/stack_connectors/public/connector_types/index.ts index 4cc52b39b43c9..893b756338dcb 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/index.ts +++ b/x-pack/plugins/stack_connectors/public/connector_types/index.ts @@ -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()); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/lib/gen_ai/use_get_dashboard.ts b/x-pack/plugins/stack_connectors/public/connector_types/lib/gen_ai/use_get_dashboard.ts index dd1f6596d9201..0c22c39583850 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/lib/gen_ai/use_get_dashboard.ts +++ b/x-pack/plugins/stack_connectors/public/connector_types/lib/gen_ai/use_get_dashboard.ts @@ -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}`; +}; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/gemini/gemini.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/gemini/gemini.test.ts new file mode 100644 index 0000000000000..af5fdc8cb781f --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/gemini/gemini.test.ts @@ -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 }); + }); + }); +}); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/gemini/gemini.ts b/x-pack/plugins/stack_connectors/server/connector_types/gemini/gemini.ts new file mode 100644 index 0000000000000..61b8834927a48 --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/gemini/gemini.ts @@ -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 { + private url; + private model; + private gcpRegion; + private gcpProjectID; + private connectorTokenClient: ConnectorTokenClientContract; + + constructor(params: ServiceParams) { + 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 { + 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 { + // 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 { + // 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; + + 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 }; + } +} diff --git a/x-pack/plugins/stack_connectors/server/connector_types/gemini/index.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/gemini/index.test.ts new file mode 100644 index 0000000000000..f53518f702ce8 --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/gemini/index.test.ts @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { 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; +let configurationUtilities: jest.Mocked; + +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"` + ); + }); + }); +}); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/gemini/index.ts b/x-pack/plugins/stack_connectors/server/connector_types/gemini/index.ts new file mode 100644 index 0000000000000..6859cb82e672c --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/gemini/index.ts @@ -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 => ({ + 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(), + }, + }) + ); + } +}; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/gemini/render.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/gemini/render.test.ts new file mode 100644 index 0000000000000..9486e385f7805 --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/gemini/render.test.ts @@ -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' + ); + }); +}); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/gemini/render.ts b/x-pack/plugins/stack_connectors/server/connector_types/gemini/render.ts new file mode 100644 index 0000000000000..28559a0457c42 --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/gemini/render.ts @@ -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 = ( + 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'), + }, + }; +}; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/index.ts b/x-pack/plugins/stack_connectors/server/connector_types/index.ts index 992a76556272b..04c7004f7325b 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/index.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/index.ts @@ -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()); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/lib/gen_ai/create_gen_ai_dashboard.ts b/x-pack/plugins/stack_connectors/server/connector_types/lib/gen_ai/create_gen_ai_dashboard.ts index 665d4258d54d1..ed15c31dc29c7 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/lib/gen_ai/create_gen_ai_dashboard.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/lib/gen_ai/create_gen_ai_dashboard.ts @@ -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; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/lib/gen_ai/gen_ai_dashboard.ts b/x-pack/plugins/stack_connectors/server/connector_types/lib/gen_ai/gen_ai_dashboard.ts index 9fd492e1559c9..144704b8af677 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/lib/gen_ai/gen_ai_dashboard.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/lib/gen_ai/gen_ai_dashboard.ts @@ -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 => { - 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 = { genAiSavedObjectId: dashboardId, diff --git a/x-pack/plugins/stack_connectors/server/plugin.test.ts b/x-pack/plugins/stack_connectors/server/plugin.test.ts index 826e60ac14af8..cd8d59f3a9f60 100644 --- a/x-pack/plugins/stack_connectors/server/plugin.test.ts +++ b/x-pack/plugins/stack_connectors/server/plugin.test.ts @@ -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', diff --git a/x-pack/test/alerting_api_integration/common/plugins/actions_simulators/server/gemini_simulation.ts b/x-pack/test/alerting_api_integration/common/plugins/actions_simulators/server/gemini_simulation.ts new file mode 100644 index 0000000000000..c842873920544 --- /dev/null +++ b/x-pack/test/alerting_api_integration/common/plugins/actions_simulators/server/gemini_simulation.ts @@ -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 + ) { + 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', + }, +}; diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/gemini.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/gemini.ts new file mode 100644 index 0000000000000..d483d11db96ec --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/gemini.ts @@ -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, + }); + }); + }); + }); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/check_registered_connector_types.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/check_registered_connector_types.ts index 87a258cd022e4..ac491f0e99aad 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/check_registered_connector_types.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/check_registered_connector_types.ts @@ -51,6 +51,7 @@ export default function createRegisteredConnectorTypeTests({ getService }: FtrPr '.opsgenie', '.gen-ai', '.bedrock', + '.gemini', '.sentinelone', '.cases', '.crowdstrike', diff --git a/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts b/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts index 163c416bea314..ff650c32de9af 100644 --- a/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts +++ b/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts @@ -57,6 +57,7 @@ export default function ({ getService }: FtrProviderContext) { 'actions:.crowdstrike', 'actions:.d3security', 'actions:.email', + 'actions:.gemini', 'actions:.gen-ai', 'actions:.index', 'actions:.jira', diff --git a/x-pack/test/security_solution_api_integration/test_suites/genai/invoke_ai/trial_license_complete_tier/configs/ess.config.ts b/x-pack/test/security_solution_api_integration/test_suites/genai/invoke_ai/trial_license_complete_tier/configs/ess.config.ts index 722b39a700026..2674d0fe84764 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/genai/invoke_ai/trial_license_complete_tier/configs/ess.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/genai/invoke_ai/trial_license_complete_tier/configs/ess.config.ts @@ -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('..')], diff --git a/yarn.lock b/yarn.lock index 181a7fe9a4c6c..e6ad3ab361898 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"