From 77bee8a31821d75d89c3b2bf8404142e10b81022 Mon Sep 17 00:00:00 2001 From: DenovVasil Date: Wed, 20 Nov 2024 11:04:14 +0200 Subject: [PATCH] feat(gemini): impl connector --- bundle/default-bundle/pom.xml | 4 + bundle/pom.xml | 5 + .../util/GoogleServiceSupplierUtil.java | 27 +- .../google-gemini-outbound-connector.json | 584 +++++++++++++++++ ...ogle-gemini-outbound-connector-hybrid.json | 589 ++++++++++++++++++ connectors/google/google-gemini/pom.xml | 75 +++ .../gemini/GeminiConnectorFunction.java | 63 ++ .../connector/gemini/caller/GeminiCaller.java | 56 ++ .../mapper/FunctionDeclarationMapper.java | 42 ++ .../gemini/mapper/GenerativeModelMapper.java | 113 ++++ .../gemini/mapper/PromptsMapper.java | 85 +++ .../gemini/model/BlockingDegree.java | 14 + .../connector/gemini/model/GeminiRequest.java | 46 ++ .../gemini/model/GeminiRequestData.java | 350 +++++++++++ .../connector/gemini/model/ModelVersion.java | 27 + .../gemini/supplier/VertexAISupplier.java | 31 + ...tor.api.outbound.OutboundConnectorFunction | 1 + .../google-gemini/src/main/resources/icon.svg | 1 + .../gemini/GeminiConnectorFunctionTest.java | 82 +++ .../io/camunda/connector/gemini/TestUtil.java | 24 + .../gemini/caller/GeminiCallerTest.java | 69 ++ .../mapper/FunctionDeclarationMapperTest.java | 78 +++ .../mapper/GenerativeModelMapperTest.java | 155 +++++ .../gemini/mapper/PromptsMapperTest.java | 112 ++++ .../gemini/supplier/VertexAISupplierTest.java | 66 ++ .../test/resources/correct_function_call.json | 23 + .../test/resources/fully_filled_model.json | 60 ++ .../resources/incorrect_function_call.json | 15 + .../resources/only_required_fields_model.json | 22 + .../src/test/resources/prompts.json | 9 + .../resources/prompts_with_empty_entry.json | 9 + .../resources/prompts_with_null_entry.json | 8 + .../prompts_with_wrong_value_type.json | 5 + connectors/google/pom.xml | 1 + 34 files changed, 2839 insertions(+), 12 deletions(-) create mode 100644 connectors/google/google-gemini/element-templates/google-gemini-outbound-connector.json create mode 100644 connectors/google/google-gemini/element-templates/hybrid/google-gemini-outbound-connector-hybrid.json create mode 100644 connectors/google/google-gemini/pom.xml create mode 100644 connectors/google/google-gemini/src/main/java/io/camunda/connector/gemini/GeminiConnectorFunction.java create mode 100644 connectors/google/google-gemini/src/main/java/io/camunda/connector/gemini/caller/GeminiCaller.java create mode 100644 connectors/google/google-gemini/src/main/java/io/camunda/connector/gemini/mapper/FunctionDeclarationMapper.java create mode 100644 connectors/google/google-gemini/src/main/java/io/camunda/connector/gemini/mapper/GenerativeModelMapper.java create mode 100644 connectors/google/google-gemini/src/main/java/io/camunda/connector/gemini/mapper/PromptsMapper.java create mode 100644 connectors/google/google-gemini/src/main/java/io/camunda/connector/gemini/model/BlockingDegree.java create mode 100644 connectors/google/google-gemini/src/main/java/io/camunda/connector/gemini/model/GeminiRequest.java create mode 100644 connectors/google/google-gemini/src/main/java/io/camunda/connector/gemini/model/GeminiRequestData.java create mode 100644 connectors/google/google-gemini/src/main/java/io/camunda/connector/gemini/model/ModelVersion.java create mode 100644 connectors/google/google-gemini/src/main/java/io/camunda/connector/gemini/supplier/VertexAISupplier.java create mode 100644 connectors/google/google-gemini/src/main/resources/META-INF/services/io.camunda.connector.api.outbound.OutboundConnectorFunction create mode 100644 connectors/google/google-gemini/src/main/resources/icon.svg create mode 100644 connectors/google/google-gemini/src/test/java/io/camunda/connector/gemini/GeminiConnectorFunctionTest.java create mode 100644 connectors/google/google-gemini/src/test/java/io/camunda/connector/gemini/TestUtil.java create mode 100644 connectors/google/google-gemini/src/test/java/io/camunda/connector/gemini/caller/GeminiCallerTest.java create mode 100644 connectors/google/google-gemini/src/test/java/io/camunda/connector/gemini/mapper/FunctionDeclarationMapperTest.java create mode 100644 connectors/google/google-gemini/src/test/java/io/camunda/connector/gemini/mapper/GenerativeModelMapperTest.java create mode 100644 connectors/google/google-gemini/src/test/java/io/camunda/connector/gemini/mapper/PromptsMapperTest.java create mode 100644 connectors/google/google-gemini/src/test/java/io/camunda/connector/gemini/supplier/VertexAISupplierTest.java create mode 100644 connectors/google/google-gemini/src/test/resources/correct_function_call.json create mode 100644 connectors/google/google-gemini/src/test/resources/fully_filled_model.json create mode 100644 connectors/google/google-gemini/src/test/resources/incorrect_function_call.json create mode 100644 connectors/google/google-gemini/src/test/resources/only_required_fields_model.json create mode 100644 connectors/google/google-gemini/src/test/resources/prompts.json create mode 100644 connectors/google/google-gemini/src/test/resources/prompts_with_empty_entry.json create mode 100644 connectors/google/google-gemini/src/test/resources/prompts_with_null_entry.json create mode 100644 connectors/google/google-gemini/src/test/resources/prompts_with_wrong_value_type.json diff --git a/bundle/default-bundle/pom.xml b/bundle/default-bundle/pom.xml index a3db6b58f2..d9b88bf1d5 100644 --- a/bundle/default-bundle/pom.xml +++ b/bundle/default-bundle/pom.xml @@ -117,6 +117,10 @@ io.camunda.connector connector-aws-comprehend + + io.camunda.connector + connector-google-gemini + diff --git a/bundle/pom.xml b/bundle/pom.xml index d907bf5f59..ec364f3c57 100644 --- a/bundle/pom.xml +++ b/bundle/pom.xml @@ -159,6 +159,11 @@ connector-aws-comprehend ${project.version} + + io.camunda.connector + connector-google-gemini + ${project.version} + diff --git a/connectors/google/google-base/src/main/java/io/camunda/google/supplier/util/GoogleServiceSupplierUtil.java b/connectors/google/google-base/src/main/java/io/camunda/google/supplier/util/GoogleServiceSupplierUtil.java index ed10627f65..40b58f68c8 100644 --- a/connectors/google/google-base/src/main/java/io/camunda/google/supplier/util/GoogleServiceSupplierUtil.java +++ b/connectors/google/google-base/src/main/java/io/camunda/google/supplier/util/GoogleServiceSupplierUtil.java @@ -24,24 +24,27 @@ public final class GoogleServiceSupplierUtil { private GoogleServiceSupplierUtil() {} public static HttpCredentialsAdapter getHttpHttpCredentialsAdapter(final Authentication auth) { - Credentials creds = null; + Credentials creds = getCredentials(auth); if (auth.authType() == AuthenticationType.BEARER) { - AccessToken accessToken = new AccessToken(auth.bearerToken(), null); - creds = new GoogleCredentials(accessToken).createScoped(DriveScopes.DRIVE); - } - - if (auth.authType() == AuthenticationType.REFRESH) { - creds = - UserCredentials.newBuilder() - .setClientId(auth.oauthClientId()) - .setClientSecret(auth.oauthClientSecret()) - .setRefreshToken(auth.oauthRefreshToken()) - .build(); + creds = ((GoogleCredentials) creds).createScoped(DriveScopes.DRIVE); } return new HttpCredentialsAdapter(creds); } + public static Credentials getCredentials(Authentication auth) { + if (auth.authType() == AuthenticationType.BEARER) { + AccessToken accessToken = new AccessToken(auth.bearerToken(), null); + GoogleCredentials googleCredentials = new GoogleCredentials(accessToken); + return googleCredentials; + } + return UserCredentials.newBuilder() + .setClientId(auth.oauthClientId()) + .setClientSecret(auth.oauthClientSecret()) + .setRefreshToken(auth.oauthRefreshToken()) + .build(); + } + public static NetHttpTransport getNetHttpTransport() { try { return GoogleNetHttpTransport.newTrustedTransport(); diff --git a/connectors/google/google-gemini/element-templates/google-gemini-outbound-connector.json b/connectors/google/google-gemini/element-templates/google-gemini-outbound-connector.json new file mode 100644 index 0000000000..120b121086 --- /dev/null +++ b/connectors/google/google-gemini/element-templates/google-gemini-outbound-connector.json @@ -0,0 +1,584 @@ +{ + "$schema" : "https://unpkg.com/@camunda/zeebe-element-templates-json-schema/resources/schema.json", + "name" : "Google Gemini Outbound Connector", + "id" : "io.camunda.connectors.GoogleGemini.v1", + "description" : " A large language model (LLM) created by Google AI. It's a multimodal model, meaning it can understand and work with different types of information like text, code, audio, images, and video", + "documentationRef" : "https://docs.camunda.io/docs/components/connectors/out-of-the-box-connectors/google-gemini/", + "version" : 1, + "category" : { + "id" : "connectors", + "name" : "Connectors" + }, + "appliesTo" : [ "bpmn:Task" ], + "elementType" : { + "value" : "bpmn:ServiceTask" + }, + "groups" : [ { + "id" : "authentication", + "label" : "Authentication" + }, { + "id" : "input", + "label" : "Configure input" + }, { + "id" : "output", + "label" : "Output mapping" + }, { + "id" : "error", + "label" : "Error handling" + }, { + "id" : "retries", + "label" : "Retries" + } ], + "properties" : [ { + "value" : "io.camunda:google-gemini:1", + "binding" : { + "property" : "type", + "type" : "zeebe:taskDefinition" + }, + "type" : "Hidden" + }, { + "id" : "authentication.authType", + "label" : "Type", + "optional" : false, + "value" : "refresh", + "constraints" : { + "notEmpty" : true + }, + "group" : "authentication", + "binding" : { + "name" : "authentication.authType", + "type" : "zeebe:input" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "Bearer token", + "value" : "bearer" + }, { + "name" : "Refresh token", + "value" : "refresh" + } ] + }, { + "id" : "authentication.bearerToken", + "label" : "Bearer token", + "description" : "Enter a valid Google API Bearer token", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "authentication", + "binding" : { + "name" : "authentication.bearerToken", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "authentication.authType", + "equals" : "bearer", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "authentication.oauthClientId", + "label" : "Client ID", + "description" : "Enter Google API Client ID", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "authentication", + "binding" : { + "name" : "authentication.oauthClientId", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "authentication.authType", + "equals" : "refresh", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "authentication.oauthClientSecret", + "label" : "Client secret", + "description" : "Enter Google API client Secret", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "authentication", + "binding" : { + "name" : "authentication.oauthClientSecret", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "authentication.authType", + "equals" : "refresh", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "authentication.oauthRefreshToken", + "label" : "Refresh token", + "description" : "Enter a valid Google API refresh token", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "authentication", + "binding" : { + "name" : "authentication.oauthRefreshToken", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "authentication.authType", + "equals" : "refresh", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "input.projectId", + "label" : "Project ID", + "description" : "Project identifier.", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "group" : "input", + "binding" : { + "name" : "input.projectId", + "type" : "zeebe:input" + }, + "type" : "String" + }, { + "id" : "input.region", + "label" : "Region", + "description" : "Input region.", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "group" : "input", + "binding" : { + "name" : "input.region", + "type" : "zeebe:input" + }, + "type" : "String" + }, { + "id" : "input.model", + "label" : "Model", + "description" : "Select gemini model.", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "group" : "input", + "binding" : { + "name" : "input.model", + "type" : "zeebe:input" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "gemini-1.5-flash-001", + "value" : "GEMINI_1_5_FLASH_001" + }, { + "name" : "gemini-1.5-flash-002", + "value" : "GEMINI_1_5_FLASH_002" + }, { + "name" : "gemini-1.5-pro-001", + "value" : "GEMINI_1_5_PRO_001" + }, { + "name" : "gemini-1.5-pro-002", + "value" : "GEMINI_1_5_PRO_002" + }, { + "name" : "gemini-1.0-pro-001", + "value" : "GEMINI_1_0_PRO_001" + }, { + "name" : "gemini-1.0-pro-002", + "value" : "GEMINI_1_0_PRO_002" + }, { + "name" : "gemini-1.0-pro-vision-001", + "value" : "GEMINI_1_0_PRO_VISION_001" + } ] + }, { + "id" : "input.prompts", + "label" : "Prompt", + "description" : "Insert prompt.", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "required", + "group" : "input", + "binding" : { + "name" : "input.prompts", + "type" : "zeebe:input" + }, + "type" : "String" + }, { + "id" : "input.systemInstrText", + "label" : "System instructions", + "description" : "System instructions inform how the model should respond.", + "optional" : true, + "group" : "input", + "binding" : { + "name" : "input.systemInstrText", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "input.model", + "oneOf" : [ "GEMINI_1_5_FLASH_001", "GEMINI_1_5_FLASH_002", "GEMINI_1_5_PRO_001", "GEMINI_1_5_PRO_002", "GEMINI_1_0_PRO_002" ], + "type" : "simple" + }, + "tooltip" : "System instructions inform how the model should respond. Use them to give the model context to understand the task, provide more custom responses and adhere to specific guidelines. Instructions apply each time you send a request to the model.", + "type" : "String" + }, { + "id" : "input.grounding", + "label" : "Grounding", + "description" : "Customize grounding by Vertex AI Search.", + "optional" : false, + "value" : false, + "group" : "input", + "binding" : { + "name" : "input.grounding", + "type" : "zeebe:input" + }, + "tooltip" : "Grounding connects model output to verifiable sources of information. This is useful in situations where accuracy and reliability are important.", + "type" : "Boolean" + }, { + "id" : "input.dataStorePath", + "label" : "Vertex AI data store path", + "description" : "Vertex AI datastore path", + "optional" : true, + "constraints" : { + "notEmpty" : false, + "pattern" : { + "value" : "(^projects\\/.*\\/locations\\/.*\\/collections\\/.*\\/dataStores\\/.*$)", + "message" : "value must match this template: projects/{}/locations/{}/collections/{}/dataStores/{}" + } + }, + "group" : "input", + "binding" : { + "name" : "input.dataStorePath", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "input.grounding", + "equals" : true, + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "input.safetySettings", + "label" : "Safety Filter Settings", + "description" : "You can adjust the likelihood of receiving a model response that could contain harmful content. Content is blocked based on the probability that it's harmful.", + "optional" : false, + "value" : false, + "group" : "input", + "binding" : { + "name" : "input.safetySettings", + "type" : "zeebe:input" + }, + "type" : "Boolean" + }, { + "id" : "input.hateSpeach", + "label" : "Hate speech", + "optional" : true, + "value" : "OFF", + "group" : "input", + "binding" : { + "name" : "input.hateSpeach", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "input.safetySettings", + "equals" : true, + "type" : "simple" + }, + "tooltip" : "You can adjust the likelihood of receiving a model response that could contain harmful content. Content is blocked based on the probability that it's harmful.", + "type" : "Dropdown", + "choices" : [ { + "name" : "OFF", + "value" : "OFF" + }, { + "name" : "Block few", + "value" : "BLOCK_ONLY_HIGH" + }, { + "name" : "Block some", + "value" : "BLOCK_MEDIUM_AND_ABOVE" + }, { + "name" : "Block most", + "value" : "BLOCK_LOW_AND_ABOVE" + } ] + }, { + "id" : "input.dangerousContent", + "label" : "Dangerous content", + "optional" : true, + "value" : "OFF", + "group" : "input", + "binding" : { + "name" : "input.dangerousContent", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "input.safetySettings", + "equals" : true, + "type" : "simple" + }, + "tooltip" : "You can adjust the likelihood of receiving a model response that could contain harmful content. Content is blocked based on the probability that it's harmful.", + "type" : "Dropdown", + "choices" : [ { + "name" : "OFF", + "value" : "OFF" + }, { + "name" : "Block few", + "value" : "BLOCK_ONLY_HIGH" + }, { + "name" : "Block some", + "value" : "BLOCK_MEDIUM_AND_ABOVE" + }, { + "name" : "Block most", + "value" : "BLOCK_LOW_AND_ABOVE" + } ] + }, { + "id" : "input.sexuallyExplicit", + "label" : "Sexually explicit content", + "optional" : true, + "value" : "OFF", + "group" : "input", + "binding" : { + "name" : "input.sexuallyExplicit", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "input.safetySettings", + "equals" : true, + "type" : "simple" + }, + "tooltip" : "You can adjust the likelihood of receiving a model response that could contain harmful content. Content is blocked based on the probability that it's harmful.", + "type" : "Dropdown", + "choices" : [ { + "name" : "OFF", + "value" : "OFF" + }, { + "name" : "Block few", + "value" : "BLOCK_ONLY_HIGH" + }, { + "name" : "Block some", + "value" : "BLOCK_MEDIUM_AND_ABOVE" + }, { + "name" : "Block most", + "value" : "BLOCK_LOW_AND_ABOVE" + } ] + }, { + "id" : "input.harassment", + "label" : "Harassment content", + "optional" : true, + "value" : "OFF", + "group" : "input", + "binding" : { + "name" : "input.harassment", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "input.safetySettings", + "equals" : true, + "type" : "simple" + }, + "tooltip" : "You can adjust the likelihood of receiving a model response that could contain harmful content. Content is blocked based on the probability that it's harmful.", + "type" : "Dropdown", + "choices" : [ { + "name" : "OFF", + "value" : "OFF" + }, { + "name" : "Block few", + "value" : "BLOCK_ONLY_HIGH" + }, { + "name" : "Block some", + "value" : "BLOCK_MEDIUM_AND_ABOVE" + }, { + "name" : "Block most", + "value" : "BLOCK_LOW_AND_ABOVE" + } ] + }, { + "id" : "input.stopSequences", + "label" : "Add stop sequence", + "description" : "Vertex AI datastore path", + "optional" : true, + "feel" : "required", + "group" : "input", + "binding" : { + "name" : "input.stopSequences", + "type" : "zeebe:input" + }, + "tooltip" : "A stop sequence is a series of characters (including spaces) that stops response generation if the model encounters it. The sequence is not included as part of the response. You can add up to five stop sequences.", + "type" : "String" + }, { + "id" : "input.temperature", + "label" : "Temperature", + "optional" : true, + "constraints" : { + "notEmpty" : false, + "pattern" : { + "value" : "(^(([0-1]\\.[0-9])|([0-2]))$)|(^$)", + "message" : "value must be in the range from 0 to 2 in increments of 0.1" + } + }, + "group" : "input", + "binding" : { + "name" : "input.temperature", + "type" : "zeebe:input" + }, + "tooltip" : "Temperature controls the randomness in token selection.\nA lower temperature is good when you expect a true or correct response. \nA temperature of 0 means the highest probability token is usually selected.\nA higher temperature can lead to diverse or unexpected results. Some models have a higher temperature max to encourage more random responses.", + "type" : "String" + }, { + "id" : "input.maxOutputTokens", + "label" : "Output token limit from 1 to 8192", + "optional" : false, + "constraints" : { + "notEmpty" : true, + "pattern" : { + "value" : "(^([1-9]|[1-9]\\d{1,2}|[1-7]\\d{3}|8(0[0-9]{2}|1[0-8][0-9]|19[0-2]))$)|(^$)", + "message" : "value must be in the range from 1 to 8192 in increments of 1" + } + }, + "group" : "input", + "binding" : { + "name" : "input.maxOutputTokens", + "type" : "zeebe:input" + }, + "tooltip" : "Output token limit determines the maximum amount of text output from one prompt. A token is approximately four characters.", + "type" : "String" + }, { + "id" : "input.seed", + "label" : "Seed", + "optional" : true, + "constraints" : { + "notEmpty" : false, + "pattern" : { + "value" : "(^-?\\d*$)", + "message" : "value must be whole numbers that range from -2,147,483,647 to 2,147,483,647" + } + }, + "group" : "input", + "binding" : { + "name" : "input.seed", + "type" : "zeebe:input" + }, + "tooltip" : "Setting a seed value is useful when you make repeated requests and want the same model response.\nDeterministic outcome isn’t guaranteed. Changing the model or other settings can cause variations in the response even when you use the same seed value.", + "type" : "String" + }, { + "id" : "input.topK", + "label" : "Top-K ", + "optional" : true, + "constraints" : { + "notEmpty" : false, + "pattern" : { + "value" : "(^([1-9]|[1-3][0-9]|40)$)(^$)", + "message" : "value must be an integer between 1 and 40" + } + }, + "group" : "input", + "binding" : { + "name" : "input.topK", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "input.model", + "equals" : "GEMINI_1_0_PRO_001", + "type" : "simple" + }, + "tooltip" : "Top-K specifies the number of candidate tokens when the model is selecting an output token. Use a lower value for less random responses and a higher value for more random responses.", + "type" : "String" + }, { + "id" : "input.topP", + "label" : "Top-P", + "optional" : true, + "constraints" : { + "notEmpty" : false, + "pattern" : { + "value" : "(^((0\\.[0-9])|1|0)$)|(^$)", + "message" : "value must be in the range from 0 to 1 in increments of 0.1" + } + }, + "group" : "input", + "binding" : { + "name" : "input.topP", + "type" : "zeebe:input" + }, + "tooltip" : "Top-p changes how the model selects tokens for output. Tokens are selected from most probable to least until the sum of their probabilities equals the top-p value. For example, if tokens A, B, and C have a probability of .3, .2, and .1 and the top-p value is .5, then the model will select either A or B as the next token (using temperature). For the least variable results, set top-P to 0.", + "type" : "String" + }, { + "id" : "input.functionCalls", + "label" : "Function call description", + "description" : "Describe function calls.", + "optional" : true, + "feel" : "required", + "group" : "input", + "binding" : { + "name" : "input.functionCalls", + "type" : "zeebe:input" + }, + "type" : "String" + }, { + "id" : "resultVariable", + "label" : "Result variable", + "description" : "Name of variable to store the response in", + "group" : "output", + "binding" : { + "key" : "resultVariable", + "type" : "zeebe:taskHeader" + }, + "type" : "String" + }, { + "id" : "resultExpression", + "label" : "Result expression", + "description" : "Expression to map the response into process variables", + "feel" : "required", + "group" : "output", + "binding" : { + "key" : "resultExpression", + "type" : "zeebe:taskHeader" + }, + "type" : "Text" + }, { + "id" : "errorExpression", + "label" : "Error expression", + "description" : "Expression to handle errors. Details in the documentation.", + "feel" : "required", + "group" : "error", + "binding" : { + "key" : "errorExpression", + "type" : "zeebe:taskHeader" + }, + "type" : "Text" + }, { + "id" : "retryCount", + "label" : "Retries", + "description" : "Number of retries", + "value" : "3", + "feel" : "optional", + "group" : "retries", + "binding" : { + "property" : "retries", + "type" : "zeebe:taskDefinition" + }, + "type" : "String" + }, { + "id" : "retryBackoff", + "label" : "Retry backoff", + "description" : "ISO-8601 duration to wait between retries", + "value" : "PT0S", + "feel" : "optional", + "group" : "retries", + "binding" : { + "key" : "retryBackoff", + "type" : "zeebe:taskHeader" + }, + "type" : "String" + } ], + "icon" : { + "contents" : "data:image/svg+xml;base64,PHN2ZyBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNiAxNiI+PHBhdGggZD0iTTE2IDguMDE2QTguNTIyIDguNTIyIDAgMDA4LjAxNiAxNmgtLjAzMkE4LjUyMSA4LjUyMSAwIDAwMCA4LjAxNnYtLjAzMkE4LjUyMSA4LjUyMSAwIDAwNy45ODQgMGguMDMyQTguNTIyIDguNTIyIDAgMDAxNiA3Ljk4NHYuMDMyeiIgZmlsbD0idXJsKCNwcmVmaXhfX3BhaW50MF9yYWRpYWxfOTgwXzIwMTQ3KSIvPjxkZWZzPjxyYWRpYWxHcmFkaWVudCBpZD0icHJlZml4X19wYWludDBfcmFkaWFsXzk4MF8yMDE0NyIgY3g9IjAiIGN5PSIwIiByPSIxIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCgxNi4xMzI2IDUuNDU1MyAtNDMuNzAwNDUgMTI5LjIzMjIgMS41ODggNi41MDMpIj48c3RvcCBvZmZzZXQ9Ii4wNjciIHN0b3AtY29sb3I9IiM5MTY4QzAiLz48c3RvcCBvZmZzZXQ9Ii4zNDMiIHN0b3AtY29sb3I9IiM1Njg0RDEiLz48c3RvcCBvZmZzZXQ9Ii42NzIiIHN0b3AtY29sb3I9IiMxQkExRTMiLz48L3JhZGlhbEdyYWRpZW50PjwvZGVmcz48L3N2Zz4=" + } +} \ No newline at end of file diff --git a/connectors/google/google-gemini/element-templates/hybrid/google-gemini-outbound-connector-hybrid.json b/connectors/google/google-gemini/element-templates/hybrid/google-gemini-outbound-connector-hybrid.json new file mode 100644 index 0000000000..6bd43a28c1 --- /dev/null +++ b/connectors/google/google-gemini/element-templates/hybrid/google-gemini-outbound-connector-hybrid.json @@ -0,0 +1,589 @@ +{ + "$schema" : "https://unpkg.com/@camunda/zeebe-element-templates-json-schema/resources/schema.json", + "name" : "Hybrid Google Gemini Outbound Connector", + "id" : "io.camunda.connectors.GoogleGemini.v1-hybrid", + "description" : " A large language model (LLM) created by Google AI. It's a multimodal model, meaning it can understand and work with different types of information like text, code, audio, images, and video", + "documentationRef" : "https://docs.camunda.io/docs/components/connectors/out-of-the-box-connectors/google-gemini/", + "version" : 1, + "category" : { + "id" : "connectors", + "name" : "Connectors" + }, + "appliesTo" : [ "bpmn:Task" ], + "elementType" : { + "value" : "bpmn:ServiceTask" + }, + "groups" : [ { + "id" : "taskDefinitionType", + "label" : "Task definition type" + }, { + "id" : "authentication", + "label" : "Authentication" + }, { + "id" : "input", + "label" : "Configure input" + }, { + "id" : "output", + "label" : "Output mapping" + }, { + "id" : "error", + "label" : "Error handling" + }, { + "id" : "retries", + "label" : "Retries" + } ], + "properties" : [ { + "id" : "taskDefinitionType", + "value" : "io.camunda:google-gemini:1", + "group" : "taskDefinitionType", + "binding" : { + "property" : "type", + "type" : "zeebe:taskDefinition" + }, + "type" : "String" + }, { + "id" : "authentication.authType", + "label" : "Type", + "optional" : false, + "value" : "refresh", + "constraints" : { + "notEmpty" : true + }, + "group" : "authentication", + "binding" : { + "name" : "authentication.authType", + "type" : "zeebe:input" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "Bearer token", + "value" : "bearer" + }, { + "name" : "Refresh token", + "value" : "refresh" + } ] + }, { + "id" : "authentication.bearerToken", + "label" : "Bearer token", + "description" : "Enter a valid Google API Bearer token", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "authentication", + "binding" : { + "name" : "authentication.bearerToken", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "authentication.authType", + "equals" : "bearer", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "authentication.oauthClientId", + "label" : "Client ID", + "description" : "Enter Google API Client ID", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "authentication", + "binding" : { + "name" : "authentication.oauthClientId", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "authentication.authType", + "equals" : "refresh", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "authentication.oauthClientSecret", + "label" : "Client secret", + "description" : "Enter Google API client Secret", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "authentication", + "binding" : { + "name" : "authentication.oauthClientSecret", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "authentication.authType", + "equals" : "refresh", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "authentication.oauthRefreshToken", + "label" : "Refresh token", + "description" : "Enter a valid Google API refresh token", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "optional", + "group" : "authentication", + "binding" : { + "name" : "authentication.oauthRefreshToken", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "authentication.authType", + "equals" : "refresh", + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "input.projectId", + "label" : "Project ID", + "description" : "Project identifier.", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "group" : "input", + "binding" : { + "name" : "input.projectId", + "type" : "zeebe:input" + }, + "type" : "String" + }, { + "id" : "input.region", + "label" : "Region", + "description" : "Input region.", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "group" : "input", + "binding" : { + "name" : "input.region", + "type" : "zeebe:input" + }, + "type" : "String" + }, { + "id" : "input.model", + "label" : "Model", + "description" : "Select gemini model.", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "group" : "input", + "binding" : { + "name" : "input.model", + "type" : "zeebe:input" + }, + "type" : "Dropdown", + "choices" : [ { + "name" : "gemini-1.5-flash-001", + "value" : "GEMINI_1_5_FLASH_001" + }, { + "name" : "gemini-1.5-flash-002", + "value" : "GEMINI_1_5_FLASH_002" + }, { + "name" : "gemini-1.5-pro-001", + "value" : "GEMINI_1_5_PRO_001" + }, { + "name" : "gemini-1.5-pro-002", + "value" : "GEMINI_1_5_PRO_002" + }, { + "name" : "gemini-1.0-pro-001", + "value" : "GEMINI_1_0_PRO_001" + }, { + "name" : "gemini-1.0-pro-002", + "value" : "GEMINI_1_0_PRO_002" + }, { + "name" : "gemini-1.0-pro-vision-001", + "value" : "GEMINI_1_0_PRO_VISION_001" + } ] + }, { + "id" : "input.prompts", + "label" : "Prompt", + "description" : "Insert prompt.", + "optional" : false, + "constraints" : { + "notEmpty" : true + }, + "feel" : "required", + "group" : "input", + "binding" : { + "name" : "input.prompts", + "type" : "zeebe:input" + }, + "type" : "String" + }, { + "id" : "input.systemInstrText", + "label" : "System instructions", + "description" : "System instructions inform how the model should respond.", + "optional" : true, + "group" : "input", + "binding" : { + "name" : "input.systemInstrText", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "input.model", + "oneOf" : [ "GEMINI_1_5_FLASH_001", "GEMINI_1_5_FLASH_002", "GEMINI_1_5_PRO_001", "GEMINI_1_5_PRO_002", "GEMINI_1_0_PRO_002" ], + "type" : "simple" + }, + "tooltip" : "System instructions inform how the model should respond. Use them to give the model context to understand the task, provide more custom responses and adhere to specific guidelines. Instructions apply each time you send a request to the model.", + "type" : "String" + }, { + "id" : "input.grounding", + "label" : "Grounding", + "description" : "Customize grounding by Vertex AI Search.", + "optional" : false, + "value" : false, + "group" : "input", + "binding" : { + "name" : "input.grounding", + "type" : "zeebe:input" + }, + "tooltip" : "Grounding connects model output to verifiable sources of information. This is useful in situations where accuracy and reliability are important.", + "type" : "Boolean" + }, { + "id" : "input.dataStorePath", + "label" : "Vertex AI data store path", + "description" : "Vertex AI datastore path", + "optional" : true, + "constraints" : { + "notEmpty" : false, + "pattern" : { + "value" : "(^projects\\/.*\\/locations\\/.*\\/collections\\/.*\\/dataStores\\/.*$)", + "message" : "value must match this template: projects/{}/locations/{}/collections/{}/dataStores/{}" + } + }, + "group" : "input", + "binding" : { + "name" : "input.dataStorePath", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "input.grounding", + "equals" : true, + "type" : "simple" + }, + "type" : "String" + }, { + "id" : "input.safetySettings", + "label" : "Safety Filter Settings", + "description" : "You can adjust the likelihood of receiving a model response that could contain harmful content. Content is blocked based on the probability that it's harmful.", + "optional" : false, + "value" : false, + "group" : "input", + "binding" : { + "name" : "input.safetySettings", + "type" : "zeebe:input" + }, + "type" : "Boolean" + }, { + "id" : "input.hateSpeach", + "label" : "Hate speech", + "optional" : true, + "value" : "OFF", + "group" : "input", + "binding" : { + "name" : "input.hateSpeach", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "input.safetySettings", + "equals" : true, + "type" : "simple" + }, + "tooltip" : "You can adjust the likelihood of receiving a model response that could contain harmful content. Content is blocked based on the probability that it's harmful.", + "type" : "Dropdown", + "choices" : [ { + "name" : "OFF", + "value" : "OFF" + }, { + "name" : "Block few", + "value" : "BLOCK_ONLY_HIGH" + }, { + "name" : "Block some", + "value" : "BLOCK_MEDIUM_AND_ABOVE" + }, { + "name" : "Block most", + "value" : "BLOCK_LOW_AND_ABOVE" + } ] + }, { + "id" : "input.dangerousContent", + "label" : "Dangerous content", + "optional" : true, + "value" : "OFF", + "group" : "input", + "binding" : { + "name" : "input.dangerousContent", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "input.safetySettings", + "equals" : true, + "type" : "simple" + }, + "tooltip" : "You can adjust the likelihood of receiving a model response that could contain harmful content. Content is blocked based on the probability that it's harmful.", + "type" : "Dropdown", + "choices" : [ { + "name" : "OFF", + "value" : "OFF" + }, { + "name" : "Block few", + "value" : "BLOCK_ONLY_HIGH" + }, { + "name" : "Block some", + "value" : "BLOCK_MEDIUM_AND_ABOVE" + }, { + "name" : "Block most", + "value" : "BLOCK_LOW_AND_ABOVE" + } ] + }, { + "id" : "input.sexuallyExplicit", + "label" : "Sexually explicit content", + "optional" : true, + "value" : "OFF", + "group" : "input", + "binding" : { + "name" : "input.sexuallyExplicit", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "input.safetySettings", + "equals" : true, + "type" : "simple" + }, + "tooltip" : "You can adjust the likelihood of receiving a model response that could contain harmful content. Content is blocked based on the probability that it's harmful.", + "type" : "Dropdown", + "choices" : [ { + "name" : "OFF", + "value" : "OFF" + }, { + "name" : "Block few", + "value" : "BLOCK_ONLY_HIGH" + }, { + "name" : "Block some", + "value" : "BLOCK_MEDIUM_AND_ABOVE" + }, { + "name" : "Block most", + "value" : "BLOCK_LOW_AND_ABOVE" + } ] + }, { + "id" : "input.harassment", + "label" : "Harassment content", + "optional" : true, + "value" : "OFF", + "group" : "input", + "binding" : { + "name" : "input.harassment", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "input.safetySettings", + "equals" : true, + "type" : "simple" + }, + "tooltip" : "You can adjust the likelihood of receiving a model response that could contain harmful content. Content is blocked based on the probability that it's harmful.", + "type" : "Dropdown", + "choices" : [ { + "name" : "OFF", + "value" : "OFF" + }, { + "name" : "Block few", + "value" : "BLOCK_ONLY_HIGH" + }, { + "name" : "Block some", + "value" : "BLOCK_MEDIUM_AND_ABOVE" + }, { + "name" : "Block most", + "value" : "BLOCK_LOW_AND_ABOVE" + } ] + }, { + "id" : "input.stopSequences", + "label" : "Add stop sequence", + "description" : "Vertex AI datastore path", + "optional" : true, + "feel" : "required", + "group" : "input", + "binding" : { + "name" : "input.stopSequences", + "type" : "zeebe:input" + }, + "tooltip" : "A stop sequence is a series of characters (including spaces) that stops response generation if the model encounters it. The sequence is not included as part of the response. You can add up to five stop sequences.", + "type" : "String" + }, { + "id" : "input.temperature", + "label" : "Temperature", + "optional" : true, + "constraints" : { + "notEmpty" : false, + "pattern" : { + "value" : "(^(([0-1]\\.[0-9])|([0-2]))$)|(^$)", + "message" : "value must be in the range from 0 to 2 in increments of 0.1" + } + }, + "group" : "input", + "binding" : { + "name" : "input.temperature", + "type" : "zeebe:input" + }, + "tooltip" : "Temperature controls the randomness in token selection.\nA lower temperature is good when you expect a true or correct response. \nA temperature of 0 means the highest probability token is usually selected.\nA higher temperature can lead to diverse or unexpected results. Some models have a higher temperature max to encourage more random responses.", + "type" : "String" + }, { + "id" : "input.maxOutputTokens", + "label" : "Output token limit from 1 to 8192", + "optional" : false, + "constraints" : { + "notEmpty" : true, + "pattern" : { + "value" : "(^([1-9]|[1-9]\\d{1,2}|[1-7]\\d{3}|8(0[0-9]{2}|1[0-8][0-9]|19[0-2]))$)|(^$)", + "message" : "value must be in the range from 1 to 8192 in increments of 1" + } + }, + "group" : "input", + "binding" : { + "name" : "input.maxOutputTokens", + "type" : "zeebe:input" + }, + "tooltip" : "Output token limit determines the maximum amount of text output from one prompt. A token is approximately four characters.", + "type" : "String" + }, { + "id" : "input.seed", + "label" : "Seed", + "optional" : true, + "constraints" : { + "notEmpty" : false, + "pattern" : { + "value" : "(^-?\\d*$)", + "message" : "value must be whole numbers that range from -2,147,483,647 to 2,147,483,647" + } + }, + "group" : "input", + "binding" : { + "name" : "input.seed", + "type" : "zeebe:input" + }, + "tooltip" : "Setting a seed value is useful when you make repeated requests and want the same model response.\nDeterministic outcome isn’t guaranteed. Changing the model or other settings can cause variations in the response even when you use the same seed value.", + "type" : "String" + }, { + "id" : "input.topK", + "label" : "Top-K ", + "optional" : true, + "constraints" : { + "notEmpty" : false, + "pattern" : { + "value" : "(^([1-9]|[1-3][0-9]|40)$)(^$)", + "message" : "value must be an integer between 1 and 40" + } + }, + "group" : "input", + "binding" : { + "name" : "input.topK", + "type" : "zeebe:input" + }, + "condition" : { + "property" : "input.model", + "equals" : "GEMINI_1_0_PRO_001", + "type" : "simple" + }, + "tooltip" : "Top-K specifies the number of candidate tokens when the model is selecting an output token. Use a lower value for less random responses and a higher value for more random responses.", + "type" : "String" + }, { + "id" : "input.topP", + "label" : "Top-P", + "optional" : true, + "constraints" : { + "notEmpty" : false, + "pattern" : { + "value" : "(^((0\\.[0-9])|1|0)$)|(^$)", + "message" : "value must be in the range from 0 to 1 in increments of 0.1" + } + }, + "group" : "input", + "binding" : { + "name" : "input.topP", + "type" : "zeebe:input" + }, + "tooltip" : "Top-p changes how the model selects tokens for output. Tokens are selected from most probable to least until the sum of their probabilities equals the top-p value. For example, if tokens A, B, and C have a probability of .3, .2, and .1 and the top-p value is .5, then the model will select either A or B as the next token (using temperature). For the least variable results, set top-P to 0.", + "type" : "String" + }, { + "id" : "input.functionCalls", + "label" : "Function call description", + "description" : "Describe function calls.", + "optional" : true, + "feel" : "required", + "group" : "input", + "binding" : { + "name" : "input.functionCalls", + "type" : "zeebe:input" + }, + "type" : "String" + }, { + "id" : "resultVariable", + "label" : "Result variable", + "description" : "Name of variable to store the response in", + "group" : "output", + "binding" : { + "key" : "resultVariable", + "type" : "zeebe:taskHeader" + }, + "type" : "String" + }, { + "id" : "resultExpression", + "label" : "Result expression", + "description" : "Expression to map the response into process variables", + "feel" : "required", + "group" : "output", + "binding" : { + "key" : "resultExpression", + "type" : "zeebe:taskHeader" + }, + "type" : "Text" + }, { + "id" : "errorExpression", + "label" : "Error expression", + "description" : "Expression to handle errors. Details in the documentation.", + "feel" : "required", + "group" : "error", + "binding" : { + "key" : "errorExpression", + "type" : "zeebe:taskHeader" + }, + "type" : "Text" + }, { + "id" : "retryCount", + "label" : "Retries", + "description" : "Number of retries", + "value" : "3", + "feel" : "optional", + "group" : "retries", + "binding" : { + "property" : "retries", + "type" : "zeebe:taskDefinition" + }, + "type" : "String" + }, { + "id" : "retryBackoff", + "label" : "Retry backoff", + "description" : "ISO-8601 duration to wait between retries", + "value" : "PT0S", + "feel" : "optional", + "group" : "retries", + "binding" : { + "key" : "retryBackoff", + "type" : "zeebe:taskHeader" + }, + "type" : "String" + } ], + "icon" : { + "contents" : "data:image/svg+xml;base64,PHN2ZyBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNiAxNiI+PHBhdGggZD0iTTE2IDguMDE2QTguNTIyIDguNTIyIDAgMDA4LjAxNiAxNmgtLjAzMkE4LjUyMSA4LjUyMSAwIDAwMCA4LjAxNnYtLjAzMkE4LjUyMSA4LjUyMSAwIDAwNy45ODQgMGguMDMyQTguNTIyIDguNTIyIDAgMDAxNiA3Ljk4NHYuMDMyeiIgZmlsbD0idXJsKCNwcmVmaXhfX3BhaW50MF9yYWRpYWxfOTgwXzIwMTQ3KSIvPjxkZWZzPjxyYWRpYWxHcmFkaWVudCBpZD0icHJlZml4X19wYWludDBfcmFkaWFsXzk4MF8yMDE0NyIgY3g9IjAiIGN5PSIwIiByPSIxIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCgxNi4xMzI2IDUuNDU1MyAtNDMuNzAwNDUgMTI5LjIzMjIgMS41ODggNi41MDMpIj48c3RvcCBvZmZzZXQ9Ii4wNjciIHN0b3AtY29sb3I9IiM5MTY4QzAiLz48c3RvcCBvZmZzZXQ9Ii4zNDMiIHN0b3AtY29sb3I9IiM1Njg0RDEiLz48c3RvcCBvZmZzZXQ9Ii42NzIiIHN0b3AtY29sb3I9IiMxQkExRTMiLz48L3JhZGlhbEdyYWRpZW50PjwvZGVmcz48L3N2Zz4=" + } +} \ No newline at end of file diff --git a/connectors/google/google-gemini/pom.xml b/connectors/google/google-gemini/pom.xml new file mode 100644 index 0000000000..73d0bec3ff --- /dev/null +++ b/connectors/google/google-gemini/pom.xml @@ -0,0 +1,75 @@ + + + 4.0.0 + + io.camunda.connector + connector-google-parent + 8.7.0-SNAPSHOT + ../pom.xml + + + connector-google-gemini + Camunda Google Gemini Connector + connector-google-gemini + jar + + + + Camunda Self-Managed Free Edition license + https://camunda.com/legal/terms/cloud-terms-and-conditions/camunda-cloud-self-managed-free-edition-terms/ + + + Camunda Self-Managed Enterprise Edition license + + + + + Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH +under one or more contributor license agreements. Licensed under a proprietary license. +See the License.txt file for more information. You may not use this file +except in compliance with the proprietary license. + + + + + io.camunda.connector + connector-google-base + ${project.version} + + + com.google.cloud + google-cloud-vertexai + 1.12.0 + + + + + + + io.camunda.connector + element-template-generator-maven-plugin + ${project.version} + + + + io.camunda.connector.gemini.GeminiConnectorFunction + + + io.camunda.connectors.GoogleGemini.v1 + google-gemini-outbound-connector.json + + + true + + + + io.camunda.connector:connector-google-base + + + + + + + \ No newline at end of file diff --git a/connectors/google/google-gemini/src/main/java/io/camunda/connector/gemini/GeminiConnectorFunction.java b/connectors/google/google-gemini/src/main/java/io/camunda/connector/gemini/GeminiConnectorFunction.java new file mode 100644 index 0000000000..015f14a575 --- /dev/null +++ b/connectors/google/google-gemini/src/main/java/io/camunda/connector/gemini/GeminiConnectorFunction.java @@ -0,0 +1,63 @@ +/* + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. Licensed under a proprietary license. + * See the License.txt file for more information. You may not use this file + * except in compliance with the proprietary license. + */ +package io.camunda.connector.gemini; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.cloud.vertexai.api.Content; +import com.google.protobuf.util.JsonFormat; +import io.camunda.connector.api.annotation.OutboundConnector; +import io.camunda.connector.api.json.ConnectorsObjectMapperSupplier; +import io.camunda.connector.api.outbound.OutboundConnectorContext; +import io.camunda.connector.api.outbound.OutboundConnectorFunction; +import io.camunda.connector.gemini.caller.GeminiCaller; +import io.camunda.connector.gemini.model.GeminiRequest; +import io.camunda.connector.generator.java.annotation.ElementTemplate; +import java.util.HashMap; + +@OutboundConnector( + name = "Google Gemini Outbound Connector", + inputVariables = {"authentication", "input"}, + type = "io.camunda:google-gemini:1") +@ElementTemplate( + id = "io.camunda.connectors.GoogleGemini.v1", + name = "Google Gemini Outbound Connector", + description = + " A large language model (LLM) created by Google AI. It's a multimodal model, meaning it can understand" + + " and work with different types of information like text, code, audio, images, and video", + inputDataClass = GeminiRequest.class, + version = 1, + propertyGroups = { + @ElementTemplate.PropertyGroup(id = "authentication", label = "Authentication"), + @ElementTemplate.PropertyGroup(id = "input", label = "Configure input") + }, + documentationRef = + "https://docs.camunda.io/docs/components/connectors/out-of-the-box-connectors/google-gemini/", + icon = "icon.svg") +public class GeminiConnectorFunction implements OutboundConnectorFunction { + + private final GeminiCaller caller; + private final ObjectMapper objectMapper; + + public GeminiConnectorFunction(GeminiCaller caller, ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + this.caller = caller; + } + + public GeminiConnectorFunction() { + this.objectMapper = ConnectorsObjectMapperSupplier.getCopy(); + this.caller = new GeminiCaller(objectMapper); + } + + @Override + public Object execute(OutboundConnectorContext context) throws Exception { + var geminiRequest = context.bindVariables(GeminiRequest.class); + + Content content = caller.generateContent(geminiRequest); + String json = JsonFormat.printer().print(content); + return objectMapper.readValue(json, HashMap.class); + } +} diff --git a/connectors/google/google-gemini/src/main/java/io/camunda/connector/gemini/caller/GeminiCaller.java b/connectors/google/google-gemini/src/main/java/io/camunda/connector/gemini/caller/GeminiCaller.java new file mode 100644 index 0000000000..dd6f6f2867 --- /dev/null +++ b/connectors/google/google-gemini/src/main/java/io/camunda/connector/gemini/caller/GeminiCaller.java @@ -0,0 +1,56 @@ +/* + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. Licensed under a proprietary license. + * See the License.txt file for more information. You may not use this file + * except in compliance with the proprietary license. + */ +package io.camunda.connector.gemini.caller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.cloud.vertexai.VertexAI; +import com.google.cloud.vertexai.api.Content; +import com.google.cloud.vertexai.api.GenerateContentResponse; +import com.google.cloud.vertexai.generativeai.ContentMaker; +import com.google.cloud.vertexai.generativeai.GenerativeModel; +import com.google.cloud.vertexai.generativeai.ResponseHandler; +import io.camunda.connector.gemini.mapper.FunctionDeclarationMapper; +import io.camunda.connector.gemini.mapper.GenerativeModelMapper; +import io.camunda.connector.gemini.mapper.PromptsMapper; +import io.camunda.connector.gemini.model.GeminiRequest; +import io.camunda.connector.gemini.model.GeminiRequestData; +import io.camunda.connector.gemini.supplier.VertexAISupplier; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class GeminiCaller { + + private static final Logger LOGGER = LoggerFactory.getLogger(GeminiCaller.class); + + private final GenerativeModelMapper generativeModelMapper; + private final PromptsMapper promptsMapper; + + public GeminiCaller(ObjectMapper objectMapper) { + FunctionDeclarationMapper functionDeclarationMapper = + new FunctionDeclarationMapper(objectMapper); + this.generativeModelMapper = new GenerativeModelMapper(functionDeclarationMapper); + this.promptsMapper = new PromptsMapper(objectMapper); + } + + public GeminiCaller(GenerativeModelMapper generativeModelMapper, PromptsMapper promptsMapper) { + this.generativeModelMapper = generativeModelMapper; + this.promptsMapper = promptsMapper; + } + + public Content generateContent(GeminiRequest geminiRequest) throws Exception { + GeminiRequestData requestData = geminiRequest.getInput(); + LOGGER.debug("Starting gemini generate content with request data: {}", requestData); + + try (VertexAI vertexAi = VertexAISupplier.getVertexAI(geminiRequest)) { + GenerativeModel model = generativeModelMapper.map(requestData, vertexAi); + + var content = ContentMaker.fromMultiModalData(promptsMapper.map(requestData.prompts())); + GenerateContentResponse response = model.generateContent(content); + return ResponseHandler.getContent(response); + } + } +} diff --git a/connectors/google/google-gemini/src/main/java/io/camunda/connector/gemini/mapper/FunctionDeclarationMapper.java b/connectors/google/google-gemini/src/main/java/io/camunda/connector/gemini/mapper/FunctionDeclarationMapper.java new file mode 100644 index 0000000000..a3a0040665 --- /dev/null +++ b/connectors/google/google-gemini/src/main/java/io/camunda/connector/gemini/mapper/FunctionDeclarationMapper.java @@ -0,0 +1,42 @@ +/* + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. Licensed under a proprietary license. + * See the License.txt file for more information. You may not use this file + * except in compliance with the proprietary license. + */ +package io.camunda.connector.gemini.mapper; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.cloud.vertexai.api.FunctionDeclaration; +import com.google.protobuf.util.JsonFormat; +import java.util.List; +import java.util.Optional; + +public class FunctionDeclarationMapper { + + public static final String DESERIALIZATION_EX_MSG = + "Exception during function call deserialization"; + + private final ObjectMapper objectMapper; + + public FunctionDeclarationMapper(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + public List map(List functionCalls) { + return Optional.ofNullable(functionCalls).orElse(List.of()).stream() + .map(call -> convertToFunctionDeclaration(call, objectMapper)) + .toList(); + } + + private FunctionDeclaration convertToFunctionDeclaration(Object call, ObjectMapper objectMapper) { + try { + String jsonString = objectMapper.writeValueAsString(objectMapper.valueToTree(call)); + FunctionDeclaration.Builder builder = FunctionDeclaration.newBuilder(); + JsonFormat.parser().merge(jsonString, builder); + return builder.build(); + } catch (Exception e) { + throw new RuntimeException(DESERIALIZATION_EX_MSG, e); + } + } +} diff --git a/connectors/google/google-gemini/src/main/java/io/camunda/connector/gemini/mapper/GenerativeModelMapper.java b/connectors/google/google-gemini/src/main/java/io/camunda/connector/gemini/mapper/GenerativeModelMapper.java new file mode 100644 index 0000000000..b51d0d77cc --- /dev/null +++ b/connectors/google/google-gemini/src/main/java/io/camunda/connector/gemini/mapper/GenerativeModelMapper.java @@ -0,0 +1,113 @@ +/* + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. Licensed under a proprietary license. + * See the License.txt file for more information. You may not use this file + * except in compliance with the proprietary license. + */ +package io.camunda.connector.gemini.mapper; + +import static com.google.cloud.vertexai.api.HarmCategory.*; + +import com.google.cloud.vertexai.VertexAI; +import com.google.cloud.vertexai.api.*; +import com.google.cloud.vertexai.generativeai.ContentMaker; +import com.google.cloud.vertexai.generativeai.GenerativeModel; +import io.camunda.connector.gemini.model.BlockingDegree; +import io.camunda.connector.gemini.model.GeminiRequestData; +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; +import org.apache.commons.lang3.StringUtils; + +public class GenerativeModelMapper { + + private final FunctionDeclarationMapper functionDeclarationMapper; + + public GenerativeModelMapper(FunctionDeclarationMapper functionDeclarationMapper) { + this.functionDeclarationMapper = functionDeclarationMapper; + } + + public GenerativeModel map(GeminiRequestData requestData, VertexAI vertexAi) { + GenerativeModel.Builder modelBuilder = + new GenerativeModel.Builder() + .setModelName(requestData.model().getVersion()) + .setVertexAi(vertexAi) + .setGenerationConfig(buildGenerationConfig(requestData)) + .setSafetySettings(prepareSafetySettings(requestData)) + .setTools(collectTools(requestData)); + + Optional.ofNullable(requestData.systemInstrText()) + .filter(StringUtils::isNotBlank) + .map(ContentMaker::fromString) + .ifPresent(modelBuilder::setSystemInstruction); + + return modelBuilder.build(); + } + + private List collectTools(GeminiRequestData requestData) { + return Stream.concat( + Optional.ofNullable(requestData.functionCalls()) + .filter(functionCalls -> !functionCalls.isEmpty()) + .map(this::prepareFunctionDeclarationsTool) + .stream(), + Optional.ofNullable(requestData.dataStorePath()) + .filter(StringUtils::isNotBlank) + .map(this::prepareGrounding) + .stream()) + .toList(); + } + + private Tool prepareFunctionDeclarationsTool(List functionCalls) { + return Tool.newBuilder() + .addAllFunctionDeclarations(functionDeclarationMapper.map(functionCalls)) + .build(); + } + + private Tool prepareGrounding(String dataStorePath) { + return Tool.newBuilder() + .setRetrieval( + Retrieval.newBuilder() + .setVertexAiSearch(VertexAISearch.newBuilder().setDatastore(dataStorePath))) + .build(); + } + + private GenerationConfig buildGenerationConfig(GeminiRequestData requestData) { + GenerationConfig.Builder builder = + GenerationConfig.newBuilder() + .setMaxOutputTokens(requestData.maxOutputTokens()) + .setTemperature(requestData.temperature()) + .setTopP(requestData.topP()) + .setSeed(requestData.seed()); + + Optional.of(requestData.topK()).filter(topK -> topK != 0).ifPresent(builder::setTopK); + + Optional.ofNullable(requestData.stopSequences()).ifPresent(builder::addAllStopSequences); + + return builder.build(); + } + + private List prepareSafetySettings(GeminiRequestData requestData) { + return List.of( + createSafetySetting(HARM_CATEGORY_HATE_SPEECH, requestData.hateSpeach()), + createSafetySetting(HARM_CATEGORY_DANGEROUS_CONTENT, requestData.dangerousContent()), + createSafetySetting(HARM_CATEGORY_SEXUALLY_EXPLICIT, requestData.sexuallyExplicit()), + createSafetySetting(HARM_CATEGORY_HARASSMENT, requestData.harassment())); + } + + private SafetySetting createSafetySetting(HarmCategory category, BlockingDegree degree) { + return SafetySetting.newBuilder() + .setCategory(category) + .setThreshold(mapHarmBlock(degree)) + .build(); + } + + private SafetySetting.HarmBlockThreshold mapHarmBlock(BlockingDegree blockingDegree) { + return switch (blockingDegree) { + case null -> SafetySetting.HarmBlockThreshold.OFF; + case OFF -> SafetySetting.HarmBlockThreshold.OFF; + case BLOCK_ONLY_HIGH -> SafetySetting.HarmBlockThreshold.BLOCK_ONLY_HIGH; + case BLOCK_MEDIUM_AND_ABOVE -> SafetySetting.HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE; + case BLOCK_LOW_AND_ABOVE -> SafetySetting.HarmBlockThreshold.BLOCK_LOW_AND_ABOVE; + }; + } +} diff --git a/connectors/google/google-gemini/src/main/java/io/camunda/connector/gemini/mapper/PromptsMapper.java b/connectors/google/google-gemini/src/main/java/io/camunda/connector/gemini/mapper/PromptsMapper.java new file mode 100644 index 0000000000..1ee8a11f47 --- /dev/null +++ b/connectors/google/google-gemini/src/main/java/io/camunda/connector/gemini/mapper/PromptsMapper.java @@ -0,0 +1,85 @@ +/* + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. Licensed under a proprietary license. + * See the License.txt file for more information. You may not use this file + * except in compliance with the proprietary license. + */ +package io.camunda.connector.gemini.mapper; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.cloud.vertexai.generativeai.PartMaker; +import java.util.*; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class PromptsMapper { + + private static final Logger LOGGER = LoggerFactory.getLogger(PromptsMapper.class); + private static final TypeReference> typeRef = + new TypeReference<>() {}; + + public static final String INVALID_PROMPT_MSG_FORMAT = "Invalid prompt format: %s"; + public static final String EMPTY_PROMPT_MSG = "Prompt can not be empty"; + + public static final String MIME_KEY = "mime"; + public static final String URI_KEY = "uri"; + public static final String TEXT_KEY = "text"; + + private final ObjectMapper objectMapper; + + public PromptsMapper(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + public Object[] map(List prompts) { + return prompts.stream() + .map( + prompt -> + Optional.ofNullable(prompt) + .map(this::convertToStringsMap) + .map(this::mapToMediaOrText) + .orElseThrow(() -> createInvalidPromptException(EMPTY_PROMPT_MSG))) + .toArray(); + } + + private LinkedHashMap convertToStringsMap(Object o) { + try { + return objectMapper.convertValue(o, typeRef); + } catch (RuntimeException e) { + throw createInvalidPromptException(INVALID_PROMPT_MSG_FORMAT.formatted(o), e); + } + } + + private Object mapToMediaOrText(Map map) { + switch (map.size()) { + case 1 -> { + validateEntry(map, TEXT_KEY); + return map.get(TEXT_KEY); + } + case 2 -> { + validateEntry(map, MIME_KEY, URI_KEY); + return PartMaker.fromMimeTypeAndData(map.get(MIME_KEY), map.get(URI_KEY)); + } + default -> throw createInvalidPromptException(String.format(INVALID_PROMPT_MSG_FORMAT, map)); + } + } + + private void validateEntry(Map map, String... keys) { + for (String key : keys) { + if (!map.containsKey(key) || StringUtils.isBlank(map.get(key))) { + throw createInvalidPromptException(String.format(INVALID_PROMPT_MSG_FORMAT, map)); + } + } + } + + private RuntimeException createInvalidPromptException(String message) { + return createInvalidPromptException(message, null); + } + + private RuntimeException createInvalidPromptException(String message, Exception e) { + LOGGER.debug(message); + return new IllegalArgumentException(message, e); + } +} diff --git a/connectors/google/google-gemini/src/main/java/io/camunda/connector/gemini/model/BlockingDegree.java b/connectors/google/google-gemini/src/main/java/io/camunda/connector/gemini/model/BlockingDegree.java new file mode 100644 index 0000000000..a28efdf986 --- /dev/null +++ b/connectors/google/google-gemini/src/main/java/io/camunda/connector/gemini/model/BlockingDegree.java @@ -0,0 +1,14 @@ +/* + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. Licensed under a proprietary license. + * See the License.txt file for more information. You may not use this file + * except in compliance with the proprietary license. + */ +package io.camunda.connector.gemini.model; + +public enum BlockingDegree { + OFF, + BLOCK_ONLY_HIGH, + BLOCK_MEDIUM_AND_ABOVE, + BLOCK_LOW_AND_ABOVE +} diff --git a/connectors/google/google-gemini/src/main/java/io/camunda/connector/gemini/model/GeminiRequest.java b/connectors/google/google-gemini/src/main/java/io/camunda/connector/gemini/model/GeminiRequest.java new file mode 100644 index 0000000000..b22af1f344 --- /dev/null +++ b/connectors/google/google-gemini/src/main/java/io/camunda/connector/gemini/model/GeminiRequest.java @@ -0,0 +1,46 @@ +/* + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. Licensed under a proprietary license. + * See the License.txt file for more information. You may not use this file + * except in compliance with the proprietary license. + */ +package io.camunda.connector.gemini.model; + +import io.camunda.google.model.GoogleBaseRequest; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import java.util.Objects; + +public class GeminiRequest extends GoogleBaseRequest { + @Valid @NotNull private GeminiRequestData input; + + public GeminiRequestData getInput() { + return input; + } + + public void setInput(@Valid @NotNull GeminiRequestData input) { + this.input = input; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + GeminiRequest that = (GeminiRequest) o; + return Objects.equals(authentication, that.authentication) && Objects.equals(input, that.input); + } + + @Override + public int hashCode() { + return Objects.hash(authentication, input); + } + + @Override + public String toString() { + return "GeminiRequest{" + "authentication=" + authentication + ", input=" + input + '}'; + } +} diff --git a/connectors/google/google-gemini/src/main/java/io/camunda/connector/gemini/model/GeminiRequestData.java b/connectors/google/google-gemini/src/main/java/io/camunda/connector/gemini/model/GeminiRequestData.java new file mode 100644 index 0000000000..166dc5b1d5 --- /dev/null +++ b/connectors/google/google-gemini/src/main/java/io/camunda/connector/gemini/model/GeminiRequestData.java @@ -0,0 +1,350 @@ +/* + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. Licensed under a proprietary license. + * See the License.txt file for more information. You may not use this file + * except in compliance with the proprietary license. + */ +package io.camunda.connector.gemini.model; + +import io.camunda.connector.feel.annotation.FEEL; +import io.camunda.connector.generator.dsl.Property; +import io.camunda.connector.generator.java.annotation.TemplateProperty; +import jakarta.validation.constraints.NotNull; +import java.util.List; + +public record GeminiRequestData( + @TemplateProperty( + label = "Project ID", + group = "input", + description = "Project identifier.", + feel = Property.FeelMode.disabled) + @NotNull + String projectId, + @TemplateProperty( + label = "Region", + group = "input", + description = "Input region.", + feel = Property.FeelMode.disabled) + @NotNull + String region, + @TemplateProperty( + label = "Model", + group = "input", + description = "Select gemini model.", + feel = Property.FeelMode.disabled, + choices = { + @TemplateProperty.DropdownPropertyChoice( + value = "GEMINI_1_5_FLASH_001", + label = "gemini-1.5-flash-001"), + @TemplateProperty.DropdownPropertyChoice( + value = "GEMINI_1_5_FLASH_002", + label = "gemini-1.5-flash-002"), + @TemplateProperty.DropdownPropertyChoice( + value = "GEMINI_1_5_PRO_001", + label = "gemini-1.5-pro-001"), + @TemplateProperty.DropdownPropertyChoice( + value = "GEMINI_1_5_PRO_002", + label = "gemini-1.5-pro-002"), + @TemplateProperty.DropdownPropertyChoice( + value = "GEMINI_1_0_PRO_001", + label = "gemini-1.0-pro-001"), + @TemplateProperty.DropdownPropertyChoice( + value = "GEMINI_1_0_PRO_002", + label = "gemini-1.0-pro-002"), + @TemplateProperty.DropdownPropertyChoice( + value = "GEMINI_1_0_PRO_VISION_001", + label = "gemini-1.0-pro-vision-001") + }) + @NotNull + ModelVersion model, + @FEEL + @TemplateProperty( + label = "Prompt", + group = "input", + description = "Insert prompt.", + feel = Property.FeelMode.required) + @NotNull + List prompts, + @TemplateProperty( + label = "System instructions", + group = "input", + description = "System instructions inform how the model should respond.", + feel = Property.FeelMode.disabled, + tooltip = + "System instructions inform how the model should respond." + + " Use them to give the model context to understand the task, " + + "provide more custom responses and adhere to specific guidelines. " + + "Instructions apply each time you send a request to the model." + + "", + condition = + @TemplateProperty.PropertyCondition( + property = "input.model", + oneOf = { + "GEMINI_1_5_FLASH_001", + "GEMINI_1_5_FLASH_002", + "GEMINI_1_5_PRO_001", + "GEMINI_1_5_PRO_002", + "GEMINI_1_0_PRO_002" + }), + optional = true) + String systemInstrText, + @TemplateProperty( + label = "Grounding", + group = "input", + description = "Customize grounding by Vertex AI Search.", + type = TemplateProperty.PropertyType.Boolean, + defaultValueType = TemplateProperty.DefaultValueType.Boolean, + tooltip = + "Grounding connects model output to verifiable sources of information. " + + "This is useful in situations where accuracy and reliability are important." + + "", + feel = Property.FeelMode.disabled, + defaultValue = "false") + boolean grounding, + @TemplateProperty( + label = "Vertex AI data store path", + group = "input", + description = "Vertex AI datastore path", + feel = Property.FeelMode.disabled, + condition = + @TemplateProperty.PropertyCondition( + property = "input.grounding", + equalsBoolean = TemplateProperty.EqualsBoolean.TRUE), + optional = true, + constraints = + @TemplateProperty.PropertyConstraints( + pattern = + @TemplateProperty.Pattern( + value = + "(^projects\\/.*\\/locations\\/.*\\/collections\\/.*\\/dataStores\\/.*$)", + message = + "value must match this template: projects/{}/locations/{}/collections/{}/dataStores/{}"))) + String dataStorePath, + @TemplateProperty( + label = "Safety Filter Settings", + group = "input", + type = TemplateProperty.PropertyType.Boolean, + defaultValueType = TemplateProperty.DefaultValueType.Boolean, + description = + "You can adjust the likelihood of receiving a model response that could contain harmful content." + + " Content is blocked based on the probability that it's harmful." + + "", + feel = Property.FeelMode.disabled, + defaultValue = "false") + boolean safetySettings, + @TemplateProperty( + label = "Hate speech", + group = "input", + feel = Property.FeelMode.disabled, + defaultValue = "OFF", + choices = { + @TemplateProperty.DropdownPropertyChoice(value = "OFF", label = "OFF"), + @TemplateProperty.DropdownPropertyChoice( + value = "BLOCK_ONLY_HIGH", + label = "Block few"), + @TemplateProperty.DropdownPropertyChoice( + value = "BLOCK_MEDIUM_AND_ABOVE", + label = "Block some"), + @TemplateProperty.DropdownPropertyChoice( + value = "BLOCK_LOW_AND_ABOVE", + label = "Block most"), + }, + condition = + @TemplateProperty.PropertyCondition( + property = "input.safetySettings", + equalsBoolean = TemplateProperty.EqualsBoolean.TRUE), + tooltip = + "You can adjust the likelihood of receiving a model response that could contain harmful content. " + + "Content is blocked based on the probability that it's harmful." + + "", + optional = true) + BlockingDegree hateSpeach, + @TemplateProperty( + label = "Dangerous content", + group = "input", + feel = Property.FeelMode.disabled, + defaultValue = "OFF", + choices = { + @TemplateProperty.DropdownPropertyChoice(value = "OFF", label = "OFF"), + @TemplateProperty.DropdownPropertyChoice( + value = "BLOCK_ONLY_HIGH", + label = "Block few"), + @TemplateProperty.DropdownPropertyChoice( + value = "BLOCK_MEDIUM_AND_ABOVE", + label = "Block some"), + @TemplateProperty.DropdownPropertyChoice( + value = "BLOCK_LOW_AND_ABOVE", + label = "Block most"), + }, + condition = + @TemplateProperty.PropertyCondition( + property = "input.safetySettings", + equalsBoolean = TemplateProperty.EqualsBoolean.TRUE), + tooltip = + "You can adjust the likelihood of receiving a model response that could contain harmful content. " + + "Content is blocked based on the probability that it's harmful." + + "", + optional = true) + BlockingDegree dangerousContent, + @TemplateProperty( + label = "Sexually explicit content", + group = "input", + feel = Property.FeelMode.disabled, + defaultValue = "OFF", + choices = { + @TemplateProperty.DropdownPropertyChoice(value = "OFF", label = "OFF"), + @TemplateProperty.DropdownPropertyChoice( + value = "BLOCK_ONLY_HIGH", + label = "Block few"), + @TemplateProperty.DropdownPropertyChoice( + value = "BLOCK_MEDIUM_AND_ABOVE", + label = "Block some"), + @TemplateProperty.DropdownPropertyChoice( + value = "BLOCK_LOW_AND_ABOVE", + label = "Block most"), + }, + condition = + @TemplateProperty.PropertyCondition( + property = "input.safetySettings", + equalsBoolean = TemplateProperty.EqualsBoolean.TRUE), + tooltip = + "You can adjust the likelihood of receiving a model response that could contain harmful content. " + + "Content is blocked based on the probability that it's harmful." + + "", + optional = true) + BlockingDegree sexuallyExplicit, + @TemplateProperty( + label = "Harassment content", + group = "input", + feel = Property.FeelMode.disabled, + defaultValue = "OFF", + choices = { + @TemplateProperty.DropdownPropertyChoice(value = "OFF", label = "OFF"), + @TemplateProperty.DropdownPropertyChoice( + value = "BLOCK_ONLY_HIGH", + label = "Block few"), + @TemplateProperty.DropdownPropertyChoice( + value = "BLOCK_MEDIUM_AND_ABOVE", + label = "Block some"), + @TemplateProperty.DropdownPropertyChoice( + value = "BLOCK_LOW_AND_ABOVE", + label = "Block most"), + }, + condition = + @TemplateProperty.PropertyCondition( + property = "input.safetySettings", + equalsBoolean = TemplateProperty.EqualsBoolean.TRUE), + tooltip = + "You can adjust the likelihood of receiving a model response that could contain harmful content. " + + "Content is blocked based on the probability that it's harmful." + + "", + optional = true) + BlockingDegree harassment, + @FEEL + @TemplateProperty( + label = "Add stop sequence", + group = "input", + description = "Vertex AI datastore path", + feel = Property.FeelMode.required, + optional = true, + tooltip = + "A stop sequence is a series of characters (including spaces) that stops response generation if the model encounters it." + + " The sequence is not included as part of the response. You can add up to five stop sequences.") + List stopSequences, + @TemplateProperty( + label = "Temperature", + group = "input", + feel = Property.FeelMode.disabled, + optional = true, + tooltip = + "Temperature controls the randomness in token selection.\n" + + "A lower temperature is good when you expect a true or correct response. \n" + + "A temperature of 0 means the highest probability token is usually selected.\n" + + "A higher temperature can lead to diverse or unexpected results. Some models have a higher temperature max to encourage more random responses.", + constraints = + @TemplateProperty.PropertyConstraints( + pattern = + @TemplateProperty.Pattern( + value = "(^(([0-1]\\.[0-9])|([0-2]))$)|(^$)", + message = + "value must be in the range from 0 to 2 in increments of 0.1"))) + float temperature, + @TemplateProperty( + label = "Output token limit from 1 to 8192", + group = "input", + feel = Property.FeelMode.disabled, + tooltip = + "Output token limit determines the maximum amount of text output from one prompt. " + + "A token is approximately four characters.", + constraints = + @TemplateProperty.PropertyConstraints( + pattern = + @TemplateProperty.Pattern( + value = + "(^([1-9]|[1-9]\\d{1,2}|[1-7]\\d{3}|8(0[0-9]{2}|1[0-8][0-9]|19[0-2]))$)|(^$)", + message = + "value must be in the range from 1 to 8192 in increments of 1"))) + @NotNull + int maxOutputTokens, + @TemplateProperty( + label = "Seed", + group = "input", + feel = Property.FeelMode.disabled, + optional = true, + tooltip = + "Setting a seed value is useful when you make repeated requests and want the same model response.\n" + + "Deterministic outcome isn’t guaranteed. Changing the model or other settings can cause variations " + + "in the response even when you use the same seed value.", + constraints = + @TemplateProperty.PropertyConstraints( + pattern = + @TemplateProperty.Pattern( + value = "(^-?\\d*$)", + message = + "value must be whole numbers that range from -2,147,483,647 to 2,147,483,647"))) + int seed, + @TemplateProperty( + label = "Top-K ", + group = "input", + feel = Property.FeelMode.disabled, + optional = true, + condition = + @TemplateProperty.PropertyCondition( + property = "input.model", + equals = "GEMINI_1_0_PRO_001"), + tooltip = + "Top-K specifies the number of candidate tokens when the model is selecting an output token. " + + "Use a lower value for less random responses and a higher value for more random responses.", + constraints = + @TemplateProperty.PropertyConstraints( + pattern = + @TemplateProperty.Pattern( + value = "(^([1-9]|[1-3][0-9]|40)$)(^$)", + message = "value must be an integer between 1 and 40"))) + float topK, + @TemplateProperty( + label = "Top-P", + group = "input", + feel = Property.FeelMode.disabled, + optional = true, + tooltip = + "Top-p changes how the model selects tokens for output." + + " Tokens are selected from most probable to least until the sum of their probabilities equals the top-p value." + + " For example, if tokens A, B, and C have a probability of .3, .2, and .1 and the top-p value is .5, then the model will select either A or B as the next token (using temperature)." + + " For the least variable results, set top-P to 0.", + constraints = + @TemplateProperty.PropertyConstraints( + pattern = + @TemplateProperty.Pattern( + value = "(^((0\\.[0-9])|1|0)$)|(^$)", + message = + "value must be in the range from 0 to 1 in increments of 0.1"))) + float topP, + @FEEL + @TemplateProperty( + label = "Function call description", + description = "Describe function calls.", + group = "input", + feel = Property.FeelMode.required, + optional = true) + List functionCalls) {} diff --git a/connectors/google/google-gemini/src/main/java/io/camunda/connector/gemini/model/ModelVersion.java b/connectors/google/google-gemini/src/main/java/io/camunda/connector/gemini/model/ModelVersion.java new file mode 100644 index 0000000000..c75ddeb0a7 --- /dev/null +++ b/connectors/google/google-gemini/src/main/java/io/camunda/connector/gemini/model/ModelVersion.java @@ -0,0 +1,27 @@ +/* + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. Licensed under a proprietary license. + * See the License.txt file for more information. You may not use this file + * except in compliance with the proprietary license. + */ +package io.camunda.connector.gemini.model; + +public enum ModelVersion { + GEMINI_1_5_FLASH_001("gemini-1.5-flash-001"), + GEMINI_1_5_FLASH_002("gemini-1.5-flash-002"), + GEMINI_1_5_PRO_001("gemini-1.5-pro-001"), + GEMINI_1_5_PRO_002("gemini-1.5-pro-002"), + GEMINI_1_0_PRO_001("gemini-1.0-pro-001"), + GEMINI_1_0_PRO_002("gemini-1.0-pro-002"), + GEMINI_1_0_PRO_VISION_001("gemini-1.0-pro-vision-001"); + + final String version; + + ModelVersion(String version) { + this.version = version; + } + + public String getVersion() { + return version; + } +} diff --git a/connectors/google/google-gemini/src/main/java/io/camunda/connector/gemini/supplier/VertexAISupplier.java b/connectors/google/google-gemini/src/main/java/io/camunda/connector/gemini/supplier/VertexAISupplier.java new file mode 100644 index 0000000000..581a19694a --- /dev/null +++ b/connectors/google/google-gemini/src/main/java/io/camunda/connector/gemini/supplier/VertexAISupplier.java @@ -0,0 +1,31 @@ +/* + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. Licensed under a proprietary license. + * See the License.txt file for more information. You may not use this file + * except in compliance with the proprietary license. + */ +package io.camunda.connector.gemini.supplier; + +import static io.camunda.google.supplier.util.GoogleServiceSupplierUtil.getCredentials; + +import com.google.cloud.vertexai.Transport; +import com.google.cloud.vertexai.VertexAI; +import com.google.common.collect.ImmutableMap; +import io.camunda.connector.gemini.model.GeminiRequest; +import io.camunda.connector.gemini.model.GeminiRequestData; + +public final class VertexAISupplier { + + private VertexAISupplier() {} + + public static VertexAI getVertexAI(GeminiRequest geminiRequest) { + GeminiRequestData requestData = geminiRequest.getInput(); + return new VertexAI.Builder() + .setProjectId(requestData.projectId()) + .setLocation(requestData.region()) + .setTransport(Transport.REST) + .setCustomHeaders(ImmutableMap.of()) + .setCredentials(getCredentials(geminiRequest.getAuthentication())) + .build(); + } +} diff --git a/connectors/google/google-gemini/src/main/resources/META-INF/services/io.camunda.connector.api.outbound.OutboundConnectorFunction b/connectors/google/google-gemini/src/main/resources/META-INF/services/io.camunda.connector.api.outbound.OutboundConnectorFunction new file mode 100644 index 0000000000..a8347c439d --- /dev/null +++ b/connectors/google/google-gemini/src/main/resources/META-INF/services/io.camunda.connector.api.outbound.OutboundConnectorFunction @@ -0,0 +1 @@ +io.camunda.connector.gemini.GeminiConnectorFunction \ No newline at end of file diff --git a/connectors/google/google-gemini/src/main/resources/icon.svg b/connectors/google/google-gemini/src/main/resources/icon.svg new file mode 100644 index 0000000000..787c837107 --- /dev/null +++ b/connectors/google/google-gemini/src/main/resources/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/connectors/google/google-gemini/src/test/java/io/camunda/connector/gemini/GeminiConnectorFunctionTest.java b/connectors/google/google-gemini/src/test/java/io/camunda/connector/gemini/GeminiConnectorFunctionTest.java new file mode 100644 index 0000000000..3daacfeb76 --- /dev/null +++ b/connectors/google/google-gemini/src/test/java/io/camunda/connector/gemini/GeminiConnectorFunctionTest.java @@ -0,0 +1,82 @@ +/* + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. Licensed under a proprietary license. + * See the License.txt file for more information. You may not use this file + * except in compliance with the proprietary license. + */ +package io.camunda.connector.gemini; + +import static io.camunda.connector.gemini.TestUtil.readValue; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.cloud.vertexai.VertexAI; +import com.google.cloud.vertexai.api.Candidate; +import com.google.cloud.vertexai.api.Content; +import com.google.cloud.vertexai.api.GenerateContentResponse; +import com.google.cloud.vertexai.api.Part; +import com.google.cloud.vertexai.generativeai.GenerativeModel; +import io.camunda.connector.api.json.ConnectorsObjectMapperSupplier; +import io.camunda.connector.api.outbound.OutboundConnectorContext; +import io.camunda.connector.gemini.caller.GeminiCaller; +import io.camunda.connector.gemini.mapper.GenerativeModelMapper; +import io.camunda.connector.gemini.mapper.PromptsMapper; +import io.camunda.connector.gemini.model.GeminiRequestData; +import io.camunda.connector.test.outbound.OutboundConnectorContextBuilder; +import io.camunda.connector.validation.impl.DefaultValidationProvider; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class GeminiConnectorFunctionTest { + + @Mock private GenerativeModelMapper generativeModelMapper; + private ObjectMapper objectMapper; + private GeminiCaller caller; + + @BeforeEach + void setUp() { + objectMapper = ConnectorsObjectMapperSupplier.getCopy(); + PromptsMapper promptsMapper = new PromptsMapper(objectMapper); + caller = new GeminiCaller(generativeModelMapper, promptsMapper); + } + + @Test + void execute() throws Exception { + var generativeModel = mock(GenerativeModel.class); + + when(generativeModel.generateContent(any(Content.class))).thenReturn(getResponse()); + when(generativeModelMapper.map(any(GeminiRequestData.class), any(VertexAI.class))) + .thenReturn(generativeModel); + + OutboundConnectorContext context = + OutboundConnectorContextBuilder.create() + .secret("MyToken", "MyRealToken") + .variables(readValue("src/test/resources/fully_filled_model.json", JsonNode.class)) + .validation(new DefaultValidationProvider()) + .build(); + + Object content = new GeminiConnectorFunction(caller, objectMapper).execute(context); + assertThat(content).isInstanceOf(Map.class); + } + + private GenerateContentResponse getResponse() { + return GenerateContentResponse.newBuilder() + .addCandidates( + Candidate.newBuilder() + .setContent( + Content.newBuilder() + .addParts(Part.newBuilder().setText("just a text").build()) + .build()) + .build()) + .build(); + } +} diff --git a/connectors/google/google-gemini/src/test/java/io/camunda/connector/gemini/TestUtil.java b/connectors/google/google-gemini/src/test/java/io/camunda/connector/gemini/TestUtil.java new file mode 100644 index 0000000000..8197ab1de4 --- /dev/null +++ b/connectors/google/google-gemini/src/test/java/io/camunda/connector/gemini/TestUtil.java @@ -0,0 +1,24 @@ +/* + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. Licensed under a proprietary license. + * See the License.txt file for more information. You may not use this file + * except in compliance with the proprietary license. + */ +package io.camunda.connector.gemini; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.File; +import java.io.IOException; + +public class TestUtil { + + private TestUtil() {} + + private static final ObjectMapper objectMapper = + new ObjectMapper().configure(DeserializationFeature.FAIL_ON_IGNORED_PROPERTIES, false); + + public static T readValue(String path, Class valueType) throws IOException { + return objectMapper.readValue(new File(path), valueType); + } +} diff --git a/connectors/google/google-gemini/src/test/java/io/camunda/connector/gemini/caller/GeminiCallerTest.java b/connectors/google/google-gemini/src/test/java/io/camunda/connector/gemini/caller/GeminiCallerTest.java new file mode 100644 index 0000000000..934b60a3be --- /dev/null +++ b/connectors/google/google-gemini/src/test/java/io/camunda/connector/gemini/caller/GeminiCallerTest.java @@ -0,0 +1,69 @@ +/* + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. Licensed under a proprietary license. + * See the License.txt file for more information. You may not use this file + * except in compliance with the proprietary license. + */ +package io.camunda.connector.gemini.caller; + +import static io.camunda.connector.gemini.TestUtil.readValue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import com.google.cloud.vertexai.VertexAI; +import com.google.cloud.vertexai.api.Candidate; +import com.google.cloud.vertexai.api.Content; +import com.google.cloud.vertexai.api.GenerateContentResponse; +import com.google.cloud.vertexai.api.Part; +import com.google.cloud.vertexai.generativeai.GenerativeModel; +import io.camunda.connector.api.json.ConnectorsObjectMapperSupplier; +import io.camunda.connector.gemini.mapper.GenerativeModelMapper; +import io.camunda.connector.gemini.mapper.PromptsMapper; +import io.camunda.connector.gemini.model.GeminiRequest; +import io.camunda.connector.gemini.model.GeminiRequestData; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class GeminiCallerTest { + + @Mock private GenerativeModelMapper generativeModelMapper; + + private GeminiCaller caller; + + @BeforeEach + void setUp() { + var ObjectMapper = ConnectorsObjectMapperSupplier.getCopy(); + var promptsMapper = new PromptsMapper(ObjectMapper); + caller = new GeminiCaller(generativeModelMapper, promptsMapper); + } + + @Test + void generateContent() throws Exception { + var generativeModel = mock(GenerativeModel.class); + + when(generativeModel.generateContent(any(Content.class))) + .thenReturn( + GenerateContentResponse.newBuilder() + .addCandidates( + Candidate.newBuilder() + .setContent( + Content.newBuilder() + .addParts(Part.newBuilder().setText("just a text").build()) + .build()) + .build()) + .build()); + + when(generativeModelMapper.map(any(GeminiRequestData.class), any(VertexAI.class))) + .thenReturn(generativeModel); + + var geminiRequest = + readValue("src/test/resources/only_required_fields_model.json", GeminiRequest.class); + caller.generateContent(geminiRequest); + + verify(generativeModel, times(1)).generateContent(any(Content.class)); + } +} diff --git a/connectors/google/google-gemini/src/test/java/io/camunda/connector/gemini/mapper/FunctionDeclarationMapperTest.java b/connectors/google/google-gemini/src/test/java/io/camunda/connector/gemini/mapper/FunctionDeclarationMapperTest.java new file mode 100644 index 0000000000..4908b579c8 --- /dev/null +++ b/connectors/google/google-gemini/src/test/java/io/camunda/connector/gemini/mapper/FunctionDeclarationMapperTest.java @@ -0,0 +1,78 @@ +/* + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. Licensed under a proprietary license. + * See the License.txt file for more information. You may not use this file + * except in compliance with the proprietary license. + */ +package io.camunda.connector.gemini.mapper; + +import static io.camunda.connector.gemini.TestUtil.readValue; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.google.cloud.vertexai.api.FunctionDeclaration; +import com.google.cloud.vertexai.api.Schema; +import com.google.cloud.vertexai.api.Type; +import io.camunda.connector.api.json.ConnectorsObjectMapperSupplier; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class FunctionDeclarationMapperTest { + + private FunctionDeclarationMapper functionDeclarationMapper; + + @BeforeEach + void setUp() { + var objectMapper = ConnectorsObjectMapperSupplier.getCopy(); + functionDeclarationMapper = new FunctionDeclarationMapper(objectMapper); + } + + @Test + void mapWithCorrectInput() throws Exception { + List input = readValue("src/test/resources/correct_function_call.json", List.class); + + List expectedDeclarations = + List.of( + FunctionDeclaration.newBuilder() + .setName("get_exchange_rate") + .setDescription("Get the exchange rate for currencies between countries") + .setParameters( + Schema.newBuilder() + .setType(Type.OBJECT) + .putProperties( + "currency_date", + Schema.newBuilder() + .setType(Type.STRING) + .setDescription( + "A date that must always be in YYYY-MM-DD format or the value 'latest' if a time period is not specified") + .build()) + .putProperties( + "currency_from", + Schema.newBuilder() + .setType(Type.STRING) + .setDescription("The currency to convert from in ISO 4217 format") + .build()) + .putProperties( + "currency_to", + Schema.newBuilder() + .setType(Type.STRING) + .setDescription("The currency to convert to in ISO 4217 format") + .build()) + .build()) + .build()); + + List resultDeclarations = functionDeclarationMapper.map(input); + + assertThat(resultDeclarations).isEqualTo(expectedDeclarations); + } + + @Test + void mapWithIncorrectInput() throws Exception { + List input = readValue("src/test/resources/incorrect_function_call.json", List.class); + + RuntimeException ex = + assertThrows(RuntimeException.class, () -> functionDeclarationMapper.map(input)); + assertThat(ex.getMessage()).isEqualTo(FunctionDeclarationMapper.DESERIALIZATION_EX_MSG); + } +} diff --git a/connectors/google/google-gemini/src/test/java/io/camunda/connector/gemini/mapper/GenerativeModelMapperTest.java b/connectors/google/google-gemini/src/test/java/io/camunda/connector/gemini/mapper/GenerativeModelMapperTest.java new file mode 100644 index 0000000000..c066738b6c --- /dev/null +++ b/connectors/google/google-gemini/src/test/java/io/camunda/connector/gemini/mapper/GenerativeModelMapperTest.java @@ -0,0 +1,155 @@ +/* + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. Licensed under a proprietary license. + * See the License.txt file for more information. You may not use this file + * except in compliance with the proprietary license. + */ +package io.camunda.connector.gemini.mapper; + +import static com.google.cloud.vertexai.api.SafetySetting.HarmBlockThreshold.*; +import static io.camunda.connector.gemini.TestUtil.readValue; +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.cloud.vertexai.api.*; +import io.camunda.connector.api.json.ConnectorsObjectMapperSupplier; +import io.camunda.connector.gemini.model.GeminiRequest; +import io.camunda.connector.gemini.supplier.VertexAISupplier; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class GenerativeModelMapperTest { + + private GenerativeModelMapper generativeModelMapper; + + @BeforeEach() + void setUp() { + var objectMapper = ConnectorsObjectMapperSupplier.getCopy(); + var functionDeclarationMapper = new FunctionDeclarationMapper(objectMapper); + generativeModelMapper = new GenerativeModelMapper(functionDeclarationMapper); + } + + @Test + void mapWithAllVariables() throws Exception { + var geminiRequest = + readValue("src/test/resources/fully_filled_model.json", GeminiRequest.class); + var requestData = geminiRequest.getInput(); + + var generativeModel = + generativeModelMapper.map(requestData, VertexAISupplier.getVertexAI(geminiRequest)); + + assertThat(generativeModel.getModelName()).isEqualTo(requestData.model().getVersion()); + assertThat(generativeModel.getGenerationConfig()).isEqualTo(prepareGenConfForAllFields()); + assertThat(generativeModel.getSafetySettings()) + .containsExactlyInAnyOrderElementsOf( + prepareSafetySettings( + OFF, BLOCK_ONLY_HIGH, BLOCK_MEDIUM_AND_ABOVE, BLOCK_LOW_AND_ABOVE)); + assertThat(generativeModel.getTools()) + .containsExactlyInAnyOrderElementsOf(List.of(prepareFunctions(), prepareGrounding())); + } + + @Test + void mapOnlyWithRequiredVariables() throws Exception { + var geminiRequest = + readValue("src/test/resources/only_required_fields_model.json", GeminiRequest.class); + var requestData = geminiRequest.getInput(); + + var generativeModel = + generativeModelMapper.map(requestData, VertexAISupplier.getVertexAI(geminiRequest)); + + assertThat(generativeModel.getModelName()).isEqualTo(requestData.model().getVersion()); + assertThat(generativeModel.getGenerationConfig()) + .isEqualTo(prepareGenConfigForRequiredFields()); + assertThat(generativeModel.getSafetySettings()) + .containsExactlyInAnyOrderElementsOf(prepareSafetySettings(OFF, OFF, OFF, OFF)); + assertThat(generativeModel.getTools()).isEmpty(); + } + + private GenerationConfig prepareGenConfForAllFields() { + return GenerationConfig.newBuilder() + .setMaxOutputTokens(600) + .setTemperature(2.0f) + .setTopP(0.9f) + .setTopK(2) + .setSeed(1) + .addAllStopSequences(List.of("text1", "text2")) + .build(); + } + + private GenerationConfig prepareGenConfigForRequiredFields() { + return GenerationConfig.newBuilder() + .setMaxOutputTokens(600) + .setTemperature(0) + .setTopP(0) + .setSeed(0) + .build(); + } + + private List prepareSafetySettings( + SafetySetting.HarmBlockThreshold hate, + SafetySetting.HarmBlockThreshold dangerous, + SafetySetting.HarmBlockThreshold sexually, + SafetySetting.HarmBlockThreshold harassment) { + return List.of( + SafetySetting.newBuilder() + .setCategory(HarmCategory.HARM_CATEGORY_HATE_SPEECH) + .setThreshold(hate) + .build(), + SafetySetting.newBuilder() + .setCategory(HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT) + .setThreshold(dangerous) + .build(), + SafetySetting.newBuilder() + .setCategory(HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT) + .setThreshold(sexually) + .build(), + SafetySetting.newBuilder() + .setCategory(HarmCategory.HARM_CATEGORY_HARASSMENT) + .setThreshold(harassment) + .build()); + } + + private Tool prepareFunctions() { + List functions = + List.of( + FunctionDeclaration.newBuilder() + .setName("get_exchange_rate") + .setDescription("Get the exchange rate for currencies between countries") + .setParameters( + Schema.newBuilder() + .setType(Type.OBJECT) + .putProperties( + "currency_date", + Schema.newBuilder() + .setType(Type.STRING) + .setDescription( + "A date that must always be in YYYY-MM-DD format or the value 'latest' if a time period is not specified") + .build()) + .putProperties( + "currency_from", + Schema.newBuilder() + .setType(Type.STRING) + .setDescription("The currency to convert from in ISO 4217 format") + .build()) + .putProperties( + "currency_to", + Schema.newBuilder() + .setType(Type.STRING) + .setDescription("The currency to convert to in ISO 4217 format") + .build()) + .build()) + .build()); + return Tool.newBuilder().addAllFunctionDeclarations(functions).build(); + } + + private Tool prepareGrounding() { + return Tool.newBuilder() + .setRetrieval( + Retrieval.newBuilder() + .setVertexAiSearch( + VertexAISearch.newBuilder() + .setDatastore( + "projects/silicon-bolt-438910-q6/locations/global/collections/default_collection/dataStores/mma-rules_1730735591574"))) + .build(); + } +} diff --git a/connectors/google/google-gemini/src/test/java/io/camunda/connector/gemini/mapper/PromptsMapperTest.java b/connectors/google/google-gemini/src/test/java/io/camunda/connector/gemini/mapper/PromptsMapperTest.java new file mode 100644 index 0000000000..11c1a9b15a --- /dev/null +++ b/connectors/google/google-gemini/src/test/java/io/camunda/connector/gemini/mapper/PromptsMapperTest.java @@ -0,0 +1,112 @@ +/* + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. Licensed under a proprietary license. + * See the License.txt file for more information. You may not use this file + * except in compliance with the proprietary license. + */ +package io.camunda.connector.gemini.mapper; + +import static io.camunda.connector.gemini.TestUtil.readValue; +import static io.camunda.connector.gemini.mapper.PromptsMapper.*; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.google.cloud.vertexai.generativeai.PartMaker; +import io.camunda.connector.api.json.ConnectorsObjectMapperSupplier; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class PromptsMapperTest { + + private final PromptsMapper promptsMapper = + new PromptsMapper(ConnectorsObjectMapperSupplier.getCopy()); + + @Test + void mapWithValidPrompts() throws Exception { + List input = readValue("src/test/resources/prompts.json", List.class); + + Object[] result = promptsMapper.map(input); + + Object[] expected = { + "tell me abut this band", + PartMaker.fromMimeTypeAndData("video/*", "https://youtu.be/Snhb-97lMcQ?si=_cFMlEcGldkQ6h63") + }; + + assertThat(result).isEqualTo(expected); + } + + @Test + void mapWithOneEmptyEntryShouldThrowEx() throws Exception { + List input = readValue("src/test/resources/prompts_with_empty_entry.json", List.class); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> promptsMapper.map(input)); + assertThat(ex).hasMessage(INVALID_PROMPT_MSG_FORMAT.formatted(Map.of())); + } + + @Test + void mapNullEntryShouldThrowEx() throws Exception { + List input = readValue("src/test/resources/prompts_with_null_entry.json", List.class); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> promptsMapper.map(input)); + assertThat(ex).hasMessage(EMPTY_PROMPT_MSG); + } + + @Test + void mapWithWrongValueTypeShouldThrowEx() throws Exception { + List input = + readValue("src/test/resources/prompts_with_wrong_value_type.json", List.class); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> promptsMapper.map(input)); + assertThat(ex).hasMessage(INVALID_PROMPT_MSG_FORMAT.formatted(Map.of(TEXT_KEY, List.of(1)))); + } + + @ParameterizedTest + @ValueSource(strings = {MIME_KEY, URI_KEY, TEXT_KEY}) + void mapWithPossibleKeyWithEmptyValueShouldThrowEx(String key) { + Map map = Map.of(key, " "); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> promptsMapper.map(List.of(map))); + assertThat(ex).hasMessage(String.format(INVALID_PROMPT_MSG_FORMAT, map)); + } + + @Test + void mapWithImpossibleKeyShouldThrowEx() { + Map map = Map.of("texttt", "tell me"); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> promptsMapper.map(List.of(map))); + assertThat(ex).hasMessage(String.format(INVALID_PROMPT_MSG_FORMAT, map)); + } + + @Test + void mapWithThreeKeysInOneEntryShouldThrowEx() { + Map map = + Map.of( + MIME_KEY, "video/*", + URI_KEY, "http/", + TEXT_KEY, "tell me"); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> promptsMapper.map(List.of(map))); + assertThat(ex).hasMessage(String.format(INVALID_PROMPT_MSG_FORMAT, map)); + } + + @Test + void mapWithTwoKeysEntryShouldConsistOfMimeAndUri() { + Map map = + Map.of( + MIME_KEY, "video/*", + TEXT_KEY, "tell me"); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> promptsMapper.map(List.of(map))); + assertThat(ex).hasMessage(String.format(INVALID_PROMPT_MSG_FORMAT, map)); + } +} diff --git a/connectors/google/google-gemini/src/test/java/io/camunda/connector/gemini/supplier/VertexAISupplierTest.java b/connectors/google/google-gemini/src/test/java/io/camunda/connector/gemini/supplier/VertexAISupplierTest.java new file mode 100644 index 0000000000..f1a0a1d828 --- /dev/null +++ b/connectors/google/google-gemini/src/test/java/io/camunda/connector/gemini/supplier/VertexAISupplierTest.java @@ -0,0 +1,66 @@ +/* + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. Licensed under a proprietary license. + * See the License.txt file for more information. You may not use this file + * except in compliance with the proprietary license. + */ +package io.camunda.connector.gemini.supplier; + +import static io.camunda.google.supplier.util.GoogleServiceSupplierUtil.getCredentials; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +import io.camunda.connector.gemini.model.GeminiRequest; +import io.camunda.connector.gemini.model.GeminiRequestData; +import io.camunda.connector.gemini.model.ModelVersion; +import io.camunda.google.model.Authentication; +import io.camunda.google.model.AuthenticationType; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class VertexAISupplierTest { + + private GeminiRequest geminiRequest; + + @BeforeEach + public void setUp() { + geminiRequest = new GeminiRequest(); + geminiRequest.setAuthentication( + new Authentication(AuthenticationType.BEARER, "barer", "", "", "")); + geminiRequest.setInput(getGeminiRequestData()); + } + + @Test + void getVertexAI() throws Exception { + GeminiRequestData requestData = geminiRequest.getInput(); + var vertexAIResult = VertexAISupplier.getVertexAI(geminiRequest); + + assertThat(vertexAIResult.getProjectId()).isEqualTo(requestData.projectId()); + assertThat(vertexAIResult.getLocation()).isEqualTo(requestData.region()); + assertThat(vertexAIResult.getCredentials()) + .isEqualTo(getCredentials(geminiRequest.getAuthentication())); + } + + private GeminiRequestData getGeminiRequestData() { + return new GeminiRequestData( + "project", + "region", + ModelVersion.GEMINI_1_5_FLASH_001, + List.of("text"), + "systemInstr", + false, + "path", + false, + null, + null, + null, + null, + List.of("stop"), + 1, + 1, + 2, + 3, + 1, + List.of("function call")); + } +} diff --git a/connectors/google/google-gemini/src/test/resources/correct_function_call.json b/connectors/google/google-gemini/src/test/resources/correct_function_call.json new file mode 100644 index 0000000000..6d2a6dbbfc --- /dev/null +++ b/connectors/google/google-gemini/src/test/resources/correct_function_call.json @@ -0,0 +1,23 @@ +[ + { + "name": "get_exchange_rate", + "description":"Get the exchange rate for currencies between countries", + "parameters": { + "type": "OBJECT", + "properties": { + "currency_date": { + "type": "STRING", + "description": "A date that must always be in YYYY-MM-DD format or the value 'latest' if a time period is not specified" + }, + "currency_from": { + "type": "STRING", + "description": "The currency to convert from in ISO 4217 format" + }, + "currency_to": { + "type": "STRING", + "description": "The currency to convert to in ISO 4217 format" + } + } + } + } +] \ No newline at end of file diff --git a/connectors/google/google-gemini/src/test/resources/fully_filled_model.json b/connectors/google/google-gemini/src/test/resources/fully_filled_model.json new file mode 100644 index 0000000000..d5e83b105c --- /dev/null +++ b/connectors/google/google-gemini/src/test/resources/fully_filled_model.json @@ -0,0 +1,60 @@ +{ + "authentication" : { + "authType" : "bearer", + "bearerToken" : "token" + }, + "input": { + "projectId": "projectId", + "model": "GEMINI_1_5_FLASH_001", + "region": "region", + "grounding": "true", + "prompts": [ + { + "text": "tell me abut this band" + }, + { + "mime": "video/*", + "uri": "https://youtu.be/Snhb-97lMcQ?si=_cFMlEcGldkQ6h63" + } + ], + "temperature": "2", + "topK": 2, + "maxOutputTokens": "600", + "topP": "0.9", + "seed": "1", + "functionCalls": [ + { + "name": "get_exchange_rate", + "description": "Get the exchange rate for currencies between countries", + "parameters": { + "type": "OBJECT", + "properties": { + "currency_date": { + "type": "STRING", + "description": "A date that must always be in YYYY-MM-DD format or the value 'latest' if a time period is not specified" + }, + "currency_from": { + "type": "STRING", + "description": "The currency to convert from in ISO 4217 format" + }, + "currency_to": { + "type": "STRING", + "description": "The currency to convert to in ISO 4217 format" + } + } + } + } + ], + "stopSequences": [ + "text1", + "text2" + ], + "dangerousContent": "BLOCK_ONLY_HIGH", + "harassment": "BLOCK_LOW_AND_ABOVE", + "hateSpeach": "OFF", + "sexuallyExplicit": "BLOCK_MEDIUM_AND_ABOVE", + "dataStorePath": "projects/silicon-bolt-438910-q6/locations/global/collections/default_collection/dataStores/mma-rules_1730735591574", + "systemInstrText": "You are a helpful customer service agent", + "safetySettings": "true" + } +} diff --git a/connectors/google/google-gemini/src/test/resources/incorrect_function_call.json b/connectors/google/google-gemini/src/test/resources/incorrect_function_call.json new file mode 100644 index 0000000000..21aaf84261 --- /dev/null +++ b/connectors/google/google-gemini/src/test/resources/incorrect_function_call.json @@ -0,0 +1,15 @@ +[ + { + "name": "get_exchange_rate", + "description": "Get the exchange rate for currencies between countries", + "parameters": { + "type": "OBJECT", + "properties": { + "currency_date": { + "type": "String", + "description": "A date that must always be in YYYY-MM-DD format or the value 'latest' if a time period is not specified" + } + } + } + } +] \ No newline at end of file diff --git a/connectors/google/google-gemini/src/test/resources/only_required_fields_model.json b/connectors/google/google-gemini/src/test/resources/only_required_fields_model.json new file mode 100644 index 0000000000..3790befc3c --- /dev/null +++ b/connectors/google/google-gemini/src/test/resources/only_required_fields_model.json @@ -0,0 +1,22 @@ +{ + "input": { + "model": "GEMINI_1_5_FLASH_001", + "prompts": [ + { + "text": "tell me abut this band" + }, + { + "mime": "video/*", + "uri": "https://youtu.be/Snhb-97lMcQ?si=_cFMlEcGldkQ6h63" + } + ], + "projectId": "projectId", + "systemInstrText": "You are a helpful customer service agent", + "region": "region", + "maxOutputTokens": "600" + }, + "authentication": { + "authType": "bearer", + "bearerToken": "d" + } +} \ No newline at end of file diff --git a/connectors/google/google-gemini/src/test/resources/prompts.json b/connectors/google/google-gemini/src/test/resources/prompts.json new file mode 100644 index 0000000000..fcb34717ce --- /dev/null +++ b/connectors/google/google-gemini/src/test/resources/prompts.json @@ -0,0 +1,9 @@ +[ + { + "text": "tell me abut this band" + }, + { + "mime": "video/*", + "uri": "https://youtu.be/Snhb-97lMcQ?si=_cFMlEcGldkQ6h63" + } +] \ No newline at end of file diff --git a/connectors/google/google-gemini/src/test/resources/prompts_with_empty_entry.json b/connectors/google/google-gemini/src/test/resources/prompts_with_empty_entry.json new file mode 100644 index 0000000000..7750d90b0e --- /dev/null +++ b/connectors/google/google-gemini/src/test/resources/prompts_with_empty_entry.json @@ -0,0 +1,9 @@ +[ + { + "text": "tell me abut this band" + }, + { + + } + +] \ No newline at end of file diff --git a/connectors/google/google-gemini/src/test/resources/prompts_with_null_entry.json b/connectors/google/google-gemini/src/test/resources/prompts_with_null_entry.json new file mode 100644 index 0000000000..fb3d02f0a1 --- /dev/null +++ b/connectors/google/google-gemini/src/test/resources/prompts_with_null_entry.json @@ -0,0 +1,8 @@ +[ + { + "text": "tell me abut this band" + }, + + null + +] \ No newline at end of file diff --git a/connectors/google/google-gemini/src/test/resources/prompts_with_wrong_value_type.json b/connectors/google/google-gemini/src/test/resources/prompts_with_wrong_value_type.json new file mode 100644 index 0000000000..f54560820f --- /dev/null +++ b/connectors/google/google-gemini/src/test/resources/prompts_with_wrong_value_type.json @@ -0,0 +1,5 @@ +[ + { + "text": [1] + } +] \ No newline at end of file diff --git a/connectors/google/pom.xml b/connectors/google/pom.xml index d23f529a1d..7b139c0757 100644 --- a/connectors/google/pom.xml +++ b/connectors/google/pom.xml @@ -19,6 +19,7 @@ google-base google-drive google-sheets + google-gemini